mirror of
https://github.com/ansible/awx.git
synced 2026-02-10 14:14:43 -03:30
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e5d65a71 | ||
|
|
67a5ad7dd6 | ||
|
|
06b1243857 | ||
|
|
fb2cea7274 | ||
|
|
57b2cd402b | ||
|
|
d7864c58c1 | ||
|
|
933de6aa97 | ||
|
|
ed5074c09c | ||
|
|
5c751f3f8e | ||
|
|
be6ed623f6 | ||
|
|
2eac5a8873 | ||
|
|
09a0448c3e | ||
|
|
9818440d0f | ||
|
|
8217d14e36 | ||
|
|
28e792056d | ||
|
|
59b5104431 | ||
|
|
c759c83daf | ||
|
|
407356239b | ||
|
|
1d1e1787c4 | ||
|
|
df43221c24 | ||
|
|
09c961fc56 | ||
|
|
cd72bb6cb2 | ||
|
|
26616a409f | ||
|
|
3c71ab1bd7 | ||
|
|
47cffd3c02 | ||
|
|
63249dc241 | ||
|
|
0d8b1d172c | ||
|
|
851c802ea8 | ||
|
|
1d65b8cd53 | ||
|
|
f0449adcf8 | ||
|
|
1ecd38a4ee | ||
|
|
defb65d3d5 | ||
|
|
f283a6ef68 | ||
|
|
f9e8c03ec6 | ||
|
|
77d0958490 | ||
|
|
058049aa1b | ||
|
|
bad064b577 | ||
|
|
faf0fa9040 | ||
|
|
0cdcbdfea6 | ||
|
|
05ac2c1ec2 | ||
|
|
1dd7651d49 | ||
|
|
49c0b77c60 | ||
|
|
119c907279 | ||
|
|
8f37afeec4 | ||
|
|
a07dabae9e | ||
|
|
e6c124962b | ||
|
|
bb15132031 | ||
|
|
0d4226a903 | ||
|
|
d149e23170 | ||
|
|
32c08a09c3 | ||
|
|
1fbcd7e434 | ||
|
|
a1700404cd | ||
|
|
b04be850b5 | ||
|
|
503886b704 | ||
|
|
ee28dff7cb | ||
|
|
37b3cc72b2 | ||
|
|
bc22fa56dc | ||
|
|
4af4252604 | ||
|
|
b1a1c82169 | ||
|
|
935c7a5328 | ||
|
|
792662f3d6 | ||
|
|
d4e4e3020c | ||
|
|
7b13a42daa | ||
|
|
ac105ccd05 | ||
|
|
b7070b7a72 | ||
|
|
70141f3d77 | ||
|
|
4907aa35a9 | ||
|
|
f051c4d58a | ||
|
|
bd224a75db | ||
|
|
eb2d7c6a77 | ||
|
|
0b824ee058 | ||
|
|
30b6fd27b3 | ||
|
|
1792b1350c | ||
|
|
6a61b7ce49 | ||
|
|
5df37d4279 | ||
|
|
64485c1066 | ||
|
|
427e1cd214 | ||
|
|
796a61da86 | ||
|
|
afe09695d4 | ||
|
|
f774ef8635 | ||
|
|
bfd224eb7c | ||
|
|
7479b9faca | ||
|
|
e204325d1d | ||
|
|
c75c6ae03d | ||
|
|
1b6acdf84d | ||
|
|
5c0432b979 | ||
|
|
c7869f0408 | ||
|
|
5650344fe8 | ||
|
|
ae29eb9673 | ||
|
|
dc997346b6 | ||
|
|
70a371b212 | ||
|
|
429e752c26 | ||
|
|
679256fd25 | ||
|
|
1bb6601782 | ||
|
|
98b9d4358d | ||
|
|
bd3c4ca50f | ||
|
|
1aa90af342 | ||
|
|
558dfb685e | ||
|
|
a69a40a429 | ||
|
|
749afd53a1 | ||
|
|
7dc1157f69 | ||
|
|
b768b0222e |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,22 +2,39 @@
|
||||
|
||||
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>`.
|
||||
|
||||
## 15.0.1 (October 20, 2020)
|
||||
- Added several optimizations to improve performance for a variety of high-load simultaneous job launch use cases https://github.com/ansible/awx/pull/8403
|
||||
- Added the ability to source roles and collections from requirements.yaml files (not just requirements.yml) - https://github.com/ansible/awx/issues/4540
|
||||
- awx.awx collection modules now provide a clearer error message for incompatible versions of awxkit - https://github.com/ansible/awx/issues/8127
|
||||
- Fixed a bug in notification messages that contain certain unicode characters - https://github.com/ansible/awx/issues/7400
|
||||
- Fixed a bug that prevents the deletion of Workflow Approval records - https://github.com/ansible/awx/issues/8305
|
||||
- Fixed a bug that broke the selection of webhook credentials - https://github.com/ansible/awx/issues/7892
|
||||
- Fixed a bug which can cause confusing behavior for social auth logins across distinct browser tabs - https://github.com/ansible/awx/issues/8154
|
||||
- Fixed several bugs in the output of Workflow Job Templates using the `awx export` tool - https://github.com/ansible/awx/issues/7798 https://github.com/ansible/awx/pull/7847
|
||||
- Fixed a race condition that can lead to missing hosts when running parallel inventory syncs - https://github.com/ansible/awx/issues/5571
|
||||
- Fixed an HTTP 500 error when certain LDAP group parameters aren't properly set - https://github.com/ansible/awx/issues/7622
|
||||
- Updated a few dependencies in response to several CVEs:
|
||||
* CVE-2020-7720
|
||||
* CVE-2020-7743
|
||||
* CVE-2020-7676
|
||||
|
||||
## 15.0.0 (September 30, 2020)
|
||||
- Added improved support for fetching Ansible collections from private Galaxy content sources (such as https://github.com/ansible/galaxy_ng) - https://github.com/ansible/awx/issues/7813
|
||||
**Note:** as part of this change, new Organizations created in the AWX API will _no longer_ automatically synchronize roles and collections from galaxy.ansible.com by default. More details on this change can be found at: https://github.com/ansible/awx/issues/8341#issuecomment-707310633
|
||||
- AWX now utilizes a version of certifi that auto-discovers certificates in the system certificate store - https://github.com/ansible/awx/pull/8242
|
||||
- Added support for arbitrary custom inventory plugin configuration: https://github.com/ansible/awx/issues/5150
|
||||
- Added improved support for fetching Ansible collections from private Galaxy content sources (such as https://github.com/ansible/galaxy_ng) - https://github.com/ansible/awx/issues/7813
|
||||
- Added an optional setting to disable the auto-creation of organizations and teams on successful SAML login. - https://github.com/ansible/awx/pull/8069
|
||||
- Added a number of optimizations to Ansible Tower's callback receiver to improve the speed of stdout processing for simultaneous playbooks runs - https://github.com/ansible/awx/pull/8193 https://github.com/ansible/awx/pull/8191
|
||||
- Added a number of optimizations to AWX's callback receiver to improve the speed of stdout processing for simultaneous playbooks runs - https://github.com/ansible/awx/pull/8193 https://github.com/ansible/awx/pull/8191
|
||||
- Added the ability to use `!include` and `!import` constructors when constructing YAML for use with the AWX CLI - https://github.com/ansible/awx/issues/8135
|
||||
- Fixed a bug that prevented certain users from being able to edit approval nodes in Workflows - https://github.com/ansible/awx/pull/8253
|
||||
- Fixed a bug that broke password prompting for credentials in certain cases - https://github.com/ansible/awx/issues/8202
|
||||
- Fixed a bug which can cause PostgreSQL deadlocks when running many parallel playbooks against large shared inventories - https://github.com/ansible/awx/issues/8145
|
||||
- Fixed a bug which can cause delays in Ansible Tower's task manager when large numbers of simultaneous jobs are scheduled - https://github.com/ansible/awx/issues/7655
|
||||
- Fixed a bug which can cause delays in AWX's task manager when large numbers of simultaneous jobs are scheduled - https://github.com/ansible/awx/issues/7655
|
||||
- Fixed a bug which can cause certain scheduled jobs - those that run every X minute(s) or hour(s) - to fail to run at the proper time - https://github.com/ansible/awx/issues/8071
|
||||
- Fixed a performance issue for playbooks that store large amounts of data using the `set_stats` module - https://github.com/ansible/awx/issues/8006
|
||||
- Fixed a bug related to AWX's handling of the auth_path argument for the HashiVault KeyValue credential plugin - https://github.com/ansible/awx/pull/7991
|
||||
- Fixed a bug that broke support for Remote Archive SCM Type project syncs on platforms that utilize Python2 - https://github.com/ansible/awx/pull/8057
|
||||
- Updated to the latest version of Django Rest Framework.
|
||||
- Updated to the latest version of Django Rest Framework to address CVE-2020-25626
|
||||
- Updated to the latest version of Django to address CVE-2020-24583 and CVE-2020-24584
|
||||
- Updated to the latest verson of channels_redis to address a bug that slowly causes Daphne processes to leak memory over time - https://github.com/django/channels_redis/issues/212
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ Before you can run a deployment, you'll need the following installed in your loc
|
||||
- [docker](https://pypi.org/project/docker/) Python module
|
||||
+ This is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it.
|
||||
+ We use this module instead of `docker-py` because it is what the `docker-compose` Python module requires.
|
||||
- [community.general.docker_image collection](https://docs.ansible.com/ansible/latest/collections/community/general/docker_image_module.html)
|
||||
+ This is only required if you are using Ansible >= 2.10
|
||||
- [GNU Make](https://www.gnu.org/software/make/)
|
||||
- [Git](https://git-scm.com/) Requires Version 1.8.4+
|
||||
- Python 3.6+
|
||||
@@ -662,6 +664,7 @@ The preferred way to install the AWX CLI is through pip directly from PyPI:
|
||||
|
||||
To build the docs, spin up a real AWX server, `pip3 install sphinx sphinxcontrib-autoprogram`, and run:
|
||||
|
||||
~ cd awxkit/awxkit/cli/docs
|
||||
~ TOWER_HOST=https://awx.example.org TOWER_USERNAME=example TOWER_PASSWORD=secret make clean html
|
||||
~ cd build/html/ && python -m http.server
|
||||
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ..
|
||||
|
||||
6
Makefile
6
Makefile
@@ -214,7 +214,11 @@ requirements_awx_dev:
|
||||
|
||||
requirements_collections:
|
||||
mkdir -p $(COLLECTION_BASE)
|
||||
ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE)
|
||||
n=0; \
|
||||
until [ "$$n" -ge 5 ]; do \
|
||||
ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE) && break; \
|
||||
n=$$((n+1)); \
|
||||
done
|
||||
|
||||
requirements: requirements_ansible requirements_awx requirements_collections
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ register(
|
||||
help_text=_('Number of seconds that a user is inactive before they will need to login again.'),
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
register(
|
||||
'SESSIONS_PER_USER',
|
||||
@@ -49,6 +50,7 @@ register(
|
||||
'in the number of seconds.'),
|
||||
category=_('Authentication'),
|
||||
category_slug='authentication',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
register(
|
||||
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS',
|
||||
|
||||
@@ -39,7 +39,7 @@ class Metadata(metadata.SimpleMetadata):
|
||||
'min_length', 'max_length',
|
||||
'min_value', 'max_value',
|
||||
'category', 'category_slug',
|
||||
'defined_in_file'
|
||||
'defined_in_file', 'unit',
|
||||
]
|
||||
|
||||
for attr in text_attrs:
|
||||
|
||||
@@ -3438,6 +3438,12 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
|
||||
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
|
||||
if obj.webhook_credential_id:
|
||||
res['webhook_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.webhook_credential_id})
|
||||
if obj.inventory_id:
|
||||
res['inventory'] = self.reverse(
|
||||
'api:inventory_detail', kwargs={
|
||||
'pk': obj.inventory_id
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
def validate_extra_vars(self, value):
|
||||
|
||||
@@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView):
|
||||
approval_template,
|
||||
context=self.get_serializer_context()
|
||||
).data
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def check_permissions(self, request):
|
||||
obj = self.get_object().workflow_job_template
|
||||
|
||||
@@ -129,12 +129,14 @@ class SettingsRegistry(object):
|
||||
placeholder = field_kwargs.pop('placeholder', empty)
|
||||
encrypted = bool(field_kwargs.pop('encrypted', False))
|
||||
defined_in_file = bool(field_kwargs.pop('defined_in_file', False))
|
||||
unit = field_kwargs.pop('unit', None)
|
||||
if getattr(field_kwargs.get('child', None), 'source', None) is not None:
|
||||
field_kwargs['child'].source = None
|
||||
field_instance = field_class(**field_kwargs)
|
||||
field_instance.category_slug = category_slug
|
||||
field_instance.category = category
|
||||
field_instance.depends_on = depends_on
|
||||
field_instance.unit = unit
|
||||
if placeholder is not empty:
|
||||
field_instance.placeholder = placeholder
|
||||
field_instance.defined_in_file = defined_in_file
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -148,7 +148,7 @@ register(
|
||||
default='https://example.com',
|
||||
schemes=('http', 'https'),
|
||||
allow_plain_hostname=True, # Allow hostname only without TLD.
|
||||
label=_('Automation Analytics upload URL.'),
|
||||
label=_('Automation Analytics upload URL'),
|
||||
help_text=_('This setting is used to to configure data collection for the Automation Analytics dashboard'),
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
@@ -253,6 +253,7 @@ register(
|
||||
help_text=_('The number of seconds to sleep between status checks for jobs running on isolated instances.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -264,6 +265,7 @@ register(
|
||||
'This includes the time needed to copy source control files (playbooks) to the isolated instance.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -276,6 +278,7 @@ register(
|
||||
'Value should be substantially greater than expected network latency.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -497,6 +500,7 @@ register(
|
||||
'timeout should be imposed. A timeout set on an individual job template will override this.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -509,6 +513,7 @@ register(
|
||||
'timeout should be imposed. A timeout set on an individual inventory source will override this.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -521,6 +526,7 @@ register(
|
||||
'timeout should be imposed. A timeout set on an individual project will override this.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -535,6 +541,7 @@ register(
|
||||
'Use a value of 0 to indicate that no timeout should be imposed.'),
|
||||
category=_('Jobs'),
|
||||
category_slug='jobs',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
@@ -542,7 +549,7 @@ register(
|
||||
field_class=fields.IntegerField,
|
||||
allow_null=False,
|
||||
default=200,
|
||||
label=_('Maximum number of forks per job.'),
|
||||
label=_('Maximum number of forks per job'),
|
||||
help_text=_('Saving a Job Template with more than this number of forks will result in an error. '
|
||||
'When set to 0, no limit is applied.'),
|
||||
category=_('Jobs'),
|
||||
@@ -672,6 +679,7 @@ register(
|
||||
'aggregator protocols.'),
|
||||
category=_('Logging'),
|
||||
category_slug='logging',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
register(
|
||||
'LOG_AGGREGATOR_VERIFY_CERT',
|
||||
@@ -752,7 +760,8 @@ register(
|
||||
default=14400, # every 4 hours
|
||||
min_value=1800, # every 30 minutes
|
||||
category=_('System'),
|
||||
category_slug='system'
|
||||
category_slug='system',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -874,21 +874,20 @@ class Command(BaseCommand):
|
||||
Load inventory from in-memory groups to the database, overwriting or
|
||||
merging as appropriate.
|
||||
'''
|
||||
with advisory_lock('inventory_{}_update'.format(self.inventory.id)):
|
||||
# FIXME: Attribute changes to superuser?
|
||||
# Perform __in queries in batches (mainly for unit tests using SQLite).
|
||||
self._batch_size = 500
|
||||
self._build_db_instance_id_map()
|
||||
self._build_mem_instance_id_map()
|
||||
if self.overwrite:
|
||||
self._delete_hosts()
|
||||
self._delete_groups()
|
||||
self._delete_group_children_and_hosts()
|
||||
self._update_inventory()
|
||||
self._create_update_groups()
|
||||
self._create_update_hosts()
|
||||
self._create_update_group_children()
|
||||
self._create_update_group_hosts()
|
||||
# FIXME: Attribute changes to superuser?
|
||||
# Perform __in queries in batches (mainly for unit tests using SQLite).
|
||||
self._batch_size = 500
|
||||
self._build_db_instance_id_map()
|
||||
self._build_mem_instance_id_map()
|
||||
if self.overwrite:
|
||||
self._delete_hosts()
|
||||
self._delete_groups()
|
||||
self._delete_group_children_and_hosts()
|
||||
self._update_inventory()
|
||||
self._create_update_groups()
|
||||
self._create_update_hosts()
|
||||
self._create_update_group_children()
|
||||
self._create_update_group_hosts()
|
||||
|
||||
def remote_tower_license_compare(self, local_license_type):
|
||||
# this requires https://github.com/ansible/ansible/pull/52747
|
||||
@@ -998,143 +997,144 @@ class Command(BaseCommand):
|
||||
raise CommandError('invalid regular expression for --host-filter')
|
||||
|
||||
begin = time.time()
|
||||
self.load_inventory_from_database()
|
||||
with advisory_lock('inventory_{}_update'.format(self.inventory_id)):
|
||||
self.load_inventory_from_database()
|
||||
|
||||
try:
|
||||
self.check_license()
|
||||
except CommandError as e:
|
||||
self.mark_license_failure(save=True)
|
||||
raise e
|
||||
try:
|
||||
self.check_license()
|
||||
except CommandError as e:
|
||||
self.mark_license_failure(save=True)
|
||||
raise e
|
||||
|
||||
try:
|
||||
# Check the per-org host limits
|
||||
self.check_org_host_limit()
|
||||
except CommandError as e:
|
||||
self.mark_org_limits_failure(save=True)
|
||||
raise e
|
||||
|
||||
status, tb, exc = 'error', '', None
|
||||
try:
|
||||
if settings.SQL_DEBUG:
|
||||
queries_before = len(connection.queries)
|
||||
|
||||
# Update inventory update for this command line invocation.
|
||||
with ignore_inventory_computed_fields():
|
||||
iu = self.inventory_update
|
||||
if iu.status != 'running':
|
||||
with transaction.atomic():
|
||||
self.inventory_update.status = 'running'
|
||||
self.inventory_update.save()
|
||||
|
||||
source = self.get_source_absolute_path(self.source)
|
||||
|
||||
data = AnsibleInventoryLoader(source=source, is_custom=self.is_custom,
|
||||
venv_path=venv_path, verbosity=self.verbosity).load()
|
||||
|
||||
logger.debug('Finished loading from source: %s', source)
|
||||
logger.info('Processing JSON output...')
|
||||
inventory = MemInventory(
|
||||
group_filter_re=self.group_filter_re, host_filter_re=self.host_filter_re)
|
||||
inventory = dict_to_mem_data(data, inventory=inventory)
|
||||
|
||||
del data # forget dict from import, could be large
|
||||
|
||||
logger.info('Loaded %d groups, %d hosts', len(inventory.all_group.all_groups),
|
||||
len(inventory.all_group.all_hosts))
|
||||
|
||||
if self.exclude_empty_groups:
|
||||
inventory.delete_empty_groups()
|
||||
|
||||
self.all_group = inventory.all_group
|
||||
|
||||
if settings.DEBUG:
|
||||
# depending on inventory source, this output can be
|
||||
# *exceedingly* verbose - crawling a deeply nested
|
||||
# inventory/group data structure and printing metadata about
|
||||
# each host and its memberships
|
||||
#
|
||||
# it's easy for this scale of data to overwhelm pexpect,
|
||||
# (and it's likely only useful for purposes of debugging the
|
||||
# actual inventory import code), so only print it if we have to:
|
||||
# https://github.com/ansible/ansible-tower/issues/7414#issuecomment-321615104
|
||||
self.all_group.debug_tree()
|
||||
|
||||
with batch_role_ancestor_rebuilding():
|
||||
# If using with transaction.atomic() with try ... catch,
|
||||
# with transaction.atomic() must be inside the try section of the code as per Django docs
|
||||
try:
|
||||
# Ensure that this is managed as an atomic SQL transaction,
|
||||
# and thus properly rolled back if there is an issue.
|
||||
with transaction.atomic():
|
||||
# Merge/overwrite inventory into database.
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning('loading into database...')
|
||||
with ignore_inventory_computed_fields():
|
||||
if getattr(settings, 'ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC', True):
|
||||
self.load_into_database()
|
||||
else:
|
||||
with disable_activity_stream():
|
||||
self.load_into_database()
|
||||
if settings.SQL_DEBUG:
|
||||
queries_before2 = len(connection.queries)
|
||||
self.inventory.update_computed_fields()
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning('update computed fields took %d queries',
|
||||
len(connection.queries) - queries_before2)
|
||||
# Check if the license is valid.
|
||||
# If the license is not valid, a CommandError will be thrown,
|
||||
# and inventory update will be marked as invalid.
|
||||
# with transaction.atomic() will roll back the changes.
|
||||
license_fail = True
|
||||
self.check_license()
|
||||
|
||||
# Check the per-org host limits
|
||||
license_fail = False
|
||||
self.check_org_host_limit()
|
||||
except CommandError as e:
|
||||
if license_fail:
|
||||
self.mark_license_failure()
|
||||
else:
|
||||
self.mark_org_limits_failure()
|
||||
raise e
|
||||
try:
|
||||
# Check the per-org host limits
|
||||
self.check_org_host_limit()
|
||||
except CommandError as e:
|
||||
self.mark_org_limits_failure(save=True)
|
||||
raise e
|
||||
|
||||
status, tb, exc = 'error', '', None
|
||||
try:
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning('Inventory import completed for %s in %0.1fs',
|
||||
self.inventory_source.name, time.time() - begin)
|
||||
queries_before = len(connection.queries)
|
||||
|
||||
# Update inventory update for this command line invocation.
|
||||
with ignore_inventory_computed_fields():
|
||||
iu = self.inventory_update
|
||||
if iu.status != 'running':
|
||||
with transaction.atomic():
|
||||
self.inventory_update.status = 'running'
|
||||
self.inventory_update.save()
|
||||
|
||||
source = self.get_source_absolute_path(self.source)
|
||||
|
||||
data = AnsibleInventoryLoader(source=source, is_custom=self.is_custom,
|
||||
venv_path=venv_path, verbosity=self.verbosity).load()
|
||||
|
||||
logger.debug('Finished loading from source: %s', source)
|
||||
logger.info('Processing JSON output...')
|
||||
inventory = MemInventory(
|
||||
group_filter_re=self.group_filter_re, host_filter_re=self.host_filter_re)
|
||||
inventory = dict_to_mem_data(data, inventory=inventory)
|
||||
|
||||
del data # forget dict from import, could be large
|
||||
|
||||
logger.info('Loaded %d groups, %d hosts', len(inventory.all_group.all_groups),
|
||||
len(inventory.all_group.all_hosts))
|
||||
|
||||
if self.exclude_empty_groups:
|
||||
inventory.delete_empty_groups()
|
||||
|
||||
self.all_group = inventory.all_group
|
||||
|
||||
if settings.DEBUG:
|
||||
# depending on inventory source, this output can be
|
||||
# *exceedingly* verbose - crawling a deeply nested
|
||||
# inventory/group data structure and printing metadata about
|
||||
# each host and its memberships
|
||||
#
|
||||
# it's easy for this scale of data to overwhelm pexpect,
|
||||
# (and it's likely only useful for purposes of debugging the
|
||||
# actual inventory import code), so only print it if we have to:
|
||||
# https://github.com/ansible/ansible-tower/issues/7414#issuecomment-321615104
|
||||
self.all_group.debug_tree()
|
||||
|
||||
with batch_role_ancestor_rebuilding():
|
||||
# If using with transaction.atomic() with try ... catch,
|
||||
# with transaction.atomic() must be inside the try section of the code as per Django docs
|
||||
try:
|
||||
# Ensure that this is managed as an atomic SQL transaction,
|
||||
# and thus properly rolled back if there is an issue.
|
||||
with transaction.atomic():
|
||||
# Merge/overwrite inventory into database.
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning('loading into database...')
|
||||
with ignore_inventory_computed_fields():
|
||||
if getattr(settings, 'ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC', True):
|
||||
self.load_into_database()
|
||||
else:
|
||||
with disable_activity_stream():
|
||||
self.load_into_database()
|
||||
if settings.SQL_DEBUG:
|
||||
queries_before2 = len(connection.queries)
|
||||
self.inventory.update_computed_fields()
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning('update computed fields took %d queries',
|
||||
len(connection.queries) - queries_before2)
|
||||
# Check if the license is valid.
|
||||
# If the license is not valid, a CommandError will be thrown,
|
||||
# and inventory update will be marked as invalid.
|
||||
# with transaction.atomic() will roll back the changes.
|
||||
license_fail = True
|
||||
self.check_license()
|
||||
|
||||
# Check the per-org host limits
|
||||
license_fail = False
|
||||
self.check_org_host_limit()
|
||||
except CommandError as e:
|
||||
if license_fail:
|
||||
self.mark_license_failure()
|
||||
else:
|
||||
self.mark_org_limits_failure()
|
||||
raise e
|
||||
|
||||
if settings.SQL_DEBUG:
|
||||
logger.warning('Inventory import completed for %s in %0.1fs',
|
||||
self.inventory_source.name, time.time() - begin)
|
||||
else:
|
||||
logger.info('Inventory import completed for %s in %0.1fs',
|
||||
self.inventory_source.name, time.time() - begin)
|
||||
status = 'successful'
|
||||
|
||||
# If we're in debug mode, then log the queries and time
|
||||
# used to do the operation.
|
||||
if settings.SQL_DEBUG:
|
||||
queries_this_import = connection.queries[queries_before:]
|
||||
sqltime = sum(float(x['time']) for x in queries_this_import)
|
||||
logger.warning('Inventory import required %d queries '
|
||||
'taking %0.3fs', len(queries_this_import),
|
||||
sqltime)
|
||||
except Exception as e:
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
status = 'canceled'
|
||||
exc = e
|
||||
elif isinstance(e, CommandError):
|
||||
exc = e
|
||||
else:
|
||||
logger.info('Inventory import completed for %s in %0.1fs',
|
||||
self.inventory_source.name, time.time() - begin)
|
||||
status = 'successful'
|
||||
tb = traceback.format_exc()
|
||||
exc = e
|
||||
|
||||
# If we're in debug mode, then log the queries and time
|
||||
# used to do the operation.
|
||||
if settings.SQL_DEBUG:
|
||||
queries_this_import = connection.queries[queries_before:]
|
||||
sqltime = sum(float(x['time']) for x in queries_this_import)
|
||||
logger.warning('Inventory import required %d queries '
|
||||
'taking %0.3fs', len(queries_this_import),
|
||||
sqltime)
|
||||
except Exception as e:
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
status = 'canceled'
|
||||
exc = e
|
||||
elif isinstance(e, CommandError):
|
||||
exc = e
|
||||
else:
|
||||
tb = traceback.format_exc()
|
||||
exc = e
|
||||
if not self.invoked_from_dispatcher:
|
||||
with ignore_inventory_computed_fields():
|
||||
self.inventory_update = InventoryUpdate.objects.get(pk=self.inventory_update.pk)
|
||||
self.inventory_update.result_traceback = tb
|
||||
self.inventory_update.status = status
|
||||
self.inventory_update.save(update_fields=['status', 'result_traceback'])
|
||||
self.inventory_source.status = status
|
||||
self.inventory_source.save(update_fields=['status'])
|
||||
|
||||
if not self.invoked_from_dispatcher:
|
||||
with ignore_inventory_computed_fields():
|
||||
self.inventory_update = InventoryUpdate.objects.get(pk=self.inventory_update.pk)
|
||||
self.inventory_update.result_traceback = tb
|
||||
self.inventory_update.status = status
|
||||
self.inventory_update.save(update_fields=['status', 'result_traceback'])
|
||||
self.inventory_source.status = status
|
||||
self.inventory_source.save(update_fields=['status'])
|
||||
|
||||
if exc:
|
||||
logger.error(str(exc))
|
||||
if exc:
|
||||
logger.error(str(exc))
|
||||
|
||||
if exc:
|
||||
if isinstance(exc, CommandError):
|
||||
|
||||
@@ -261,18 +261,20 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin):
|
||||
app_label = 'main'
|
||||
|
||||
|
||||
def fit_task_to_most_remaining_capacity_instance(self, task):
|
||||
@staticmethod
|
||||
def fit_task_to_most_remaining_capacity_instance(task, instances):
|
||||
instance_most_capacity = None
|
||||
for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
|
||||
for i in instances:
|
||||
if i.remaining_capacity >= task.task_impact and \
|
||||
(instance_most_capacity is None or
|
||||
i.remaining_capacity > instance_most_capacity.remaining_capacity):
|
||||
instance_most_capacity = i
|
||||
return instance_most_capacity
|
||||
|
||||
def find_largest_idle_instance(self):
|
||||
@staticmethod
|
||||
def find_largest_idle_instance(instances):
|
||||
largest_instance = None
|
||||
for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
|
||||
for i in instances:
|
||||
if i.jobs_running == 0:
|
||||
if largest_instance is None:
|
||||
largest_instance = i
|
||||
|
||||
@@ -393,7 +393,11 @@ class JobNotificationMixin(object):
|
||||
'job': job_context,
|
||||
'job_friendly_name': self.get_notification_friendly_name(),
|
||||
'url': self.get_ui_url(),
|
||||
'job_metadata': json.dumps(self.notification_data(), indent=4)
|
||||
'job_metadata': json.dumps(
|
||||
self.notification_data(),
|
||||
ensure_ascii=False,
|
||||
indent=4
|
||||
)
|
||||
}
|
||||
|
||||
def build_context(node, fields, allowed_fields):
|
||||
|
||||
@@ -873,7 +873,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
|
||||
# If status changed, update the parent instance.
|
||||
if self.status != status_before:
|
||||
self._update_parent_instance()
|
||||
# Update parent outside of the transaction for Job w/ allow_simultaneous=True
|
||||
# This dodges lock contention at the expense of the foreign key not being
|
||||
# completely correct.
|
||||
if getattr(self, 'allow_simultaneous', False):
|
||||
connection.on_commit(self._update_parent_instance)
|
||||
else:
|
||||
self._update_parent_instance()
|
||||
|
||||
# Done.
|
||||
return result
|
||||
|
||||
@@ -776,6 +776,10 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
|
||||
self.send_approval_notification('running')
|
||||
return can_start
|
||||
|
||||
@property
|
||||
def event_processing_finished(self):
|
||||
return True
|
||||
|
||||
def send_approval_notification(self, approval_status):
|
||||
from awx.main.tasks import send_notifications # avoid circular import
|
||||
if self.workflow_job_template is None:
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import uuid
|
||||
import json
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Django
|
||||
from django.db import transaction, connection
|
||||
@@ -45,6 +46,15 @@ logger = logging.getLogger('awx.main.scheduler')
|
||||
class TaskManager():
|
||||
|
||||
def __init__(self):
|
||||
'''
|
||||
Do NOT put database queries or other potentially expensive operations
|
||||
in the task manager init. The task manager object is created every time a
|
||||
job is created, transitions state, and every 30 seconds on each tower node.
|
||||
More often then not, the object is destroyed quickly because the NOOP case is hit.
|
||||
|
||||
The NOOP case is short-circuit logic. If the task manager realizes that another instance
|
||||
of the task manager is already running, then it short-circuits and decides not to run.
|
||||
'''
|
||||
self.graph = dict()
|
||||
# start task limit indicates how many pending jobs can be started on this
|
||||
# .schedule() run. Starting jobs is expensive, and there is code in place to reap
|
||||
@@ -52,10 +62,30 @@ class TaskManager():
|
||||
# 5 minutes to start pending jobs. If this limit is reached, pending jobs
|
||||
# will no longer be started and will be started on the next task manager cycle.
|
||||
self.start_task_limit = settings.START_TASK_LIMIT
|
||||
|
||||
def after_lock_init(self):
|
||||
'''
|
||||
Init AFTER we know this instance of the task manager will run because the lock is acquired.
|
||||
'''
|
||||
instances = Instance.objects.filter(capacity__gt=0, enabled=True)
|
||||
self.real_instances = {i.hostname: i for i in instances}
|
||||
|
||||
instances_partial = [SimpleNamespace(obj=instance,
|
||||
remaining_capacity=instance.remaining_capacity,
|
||||
capacity=instance.capacity,
|
||||
jobs_running=instance.jobs_running,
|
||||
hostname=instance.hostname) for instance in instances]
|
||||
|
||||
instances_by_hostname = {i.hostname: i for i in instances_partial}
|
||||
|
||||
for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
|
||||
self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name),
|
||||
capacity_total=rampart_group.capacity,
|
||||
consumed_capacity=0)
|
||||
consumed_capacity=0,
|
||||
instances=[])
|
||||
for instance in rampart_group.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
|
||||
if instance.hostname in instances_by_hostname:
|
||||
self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname])
|
||||
|
||||
def is_job_blocked(self, task):
|
||||
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
||||
@@ -466,7 +496,6 @@ class TaskManager():
|
||||
continue
|
||||
preferred_instance_groups = task.preferred_instance_groups
|
||||
found_acceptable_queue = False
|
||||
idle_instance_that_fits = None
|
||||
if isinstance(task, WorkflowJob):
|
||||
if task.unified_job_template_id in running_workflow_templates:
|
||||
if not task.allow_simultaneous:
|
||||
@@ -483,24 +512,23 @@ class TaskManager():
|
||||
found_acceptable_queue = True
|
||||
break
|
||||
|
||||
if idle_instance_that_fits is None:
|
||||
idle_instance_that_fits = rampart_group.find_largest_idle_instance()
|
||||
remaining_capacity = self.get_remaining_capacity(rampart_group.name)
|
||||
if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0:
|
||||
logger.debug("Skipping group {}, remaining_capacity {} <= 0".format(
|
||||
rampart_group.name, remaining_capacity))
|
||||
continue
|
||||
|
||||
execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task)
|
||||
if execution_instance:
|
||||
logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
|
||||
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity))
|
||||
elif not execution_instance and idle_instance_that_fits:
|
||||
execution_instance = InstanceGroup.fit_task_to_most_remaining_capacity_instance(task, self.graph[rampart_group.name]['instances']) or \
|
||||
InstanceGroup.find_largest_idle_instance(self.graph[rampart_group.name]['instances'])
|
||||
|
||||
if execution_instance or rampart_group.is_containerized:
|
||||
if not rampart_group.is_containerized:
|
||||
execution_instance = idle_instance_that_fits
|
||||
execution_instance.remaining_capacity = max(0, execution_instance.remaining_capacity - task.task_impact)
|
||||
execution_instance.jobs_running += 1
|
||||
logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
|
||||
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity))
|
||||
if execution_instance or rampart_group.is_containerized:
|
||||
|
||||
execution_instance = self.real_instances[execution_instance.hostname]
|
||||
self.graph[rampart_group.name]['graph'].add_job(task)
|
||||
self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance)
|
||||
found_acceptable_queue = True
|
||||
@@ -572,6 +600,9 @@ class TaskManager():
|
||||
def _schedule(self):
|
||||
finished_wfjs = []
|
||||
all_sorted_tasks = self.get_tasks()
|
||||
|
||||
self.after_lock_init()
|
||||
|
||||
if len(all_sorted_tasks) > 0:
|
||||
# TODO: Deal with
|
||||
# latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks)
|
||||
|
||||
@@ -89,7 +89,7 @@ class TestApprovalNodes():
|
||||
url = reverse('api:workflow_job_template_node_create_approval',
|
||||
kwargs={'pk': approval_node.pk, 'version': 'v2'})
|
||||
post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0},
|
||||
user=admin_user, expect=200)
|
||||
user=admin_user, expect=201)
|
||||
|
||||
approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk)
|
||||
assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate)
|
||||
@@ -108,9 +108,9 @@ class TestApprovalNodes():
|
||||
assert {'name': ['This field may not be blank.']} == json.loads(r.content)
|
||||
|
||||
@pytest.mark.parametrize("is_admin, is_org_admin, status", [
|
||||
[True, False, 200], # if they're a WFJT admin, they get a 200
|
||||
[True, False, 201], # if they're a WFJT admin, they get a 201
|
||||
[False, False, 403], # if they're not a WFJT *nor* org admin, they get a 403
|
||||
[False, True, 200], # if they're an organization admin, they get a 200
|
||||
[False, True, 201], # if they're an organization admin, they get a 201
|
||||
])
|
||||
def test_approval_node_creation_rbac(self, post, approval_node, alice, is_admin, is_org_admin, status):
|
||||
url = reverse('api:workflow_job_template_node_create_approval',
|
||||
@@ -165,7 +165,7 @@ class TestApprovalNodes():
|
||||
url = reverse('api:workflow_job_template_node_create_approval',
|
||||
kwargs={'pk': node.pk, 'version': 'v2'})
|
||||
post(url, {'name': 'Approve Test', 'description': '', 'timeout': 0},
|
||||
user=admin_user, expect=200)
|
||||
user=admin_user, expect=201)
|
||||
post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
|
||||
user=admin_user, expect=201)
|
||||
wf_job = WorkflowJob.objects.first()
|
||||
@@ -195,7 +195,7 @@ class TestApprovalNodes():
|
||||
url = reverse('api:workflow_job_template_node_create_approval',
|
||||
kwargs={'pk': node.pk, 'version': 'v2'})
|
||||
post(url, {'name': 'Deny Test', 'description': '', 'timeout': 0},
|
||||
user=admin_user, expect=200)
|
||||
user=admin_user, expect=201)
|
||||
post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
|
||||
user=admin_user, expect=201)
|
||||
wf_job = WorkflowJob.objects.first()
|
||||
|
||||
@@ -123,6 +123,15 @@ class TestJobNotificationMixin(object):
|
||||
context = job.context(job_serialization)
|
||||
check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_context_job_metadata_with_unicode(self):
|
||||
job = Job.objects.create(name='批量安装项目')
|
||||
job_serialization = UnifiedJobSerializer(job).to_representation(job)
|
||||
context = job.context(job_serialization)
|
||||
assert '批量安装项目' in context['job_metadata']
|
||||
|
||||
|
||||
def test_context_stub(self):
|
||||
"""The context stub is a fake context used to validate custom notification messages. Ensure that
|
||||
this also has the expected structure. Furthermore, ensure that the stub context contains
|
||||
|
||||
@@ -45,19 +45,14 @@ class TestInstanceGroup(object):
|
||||
(T(100), Is([50, 0, 20, 99, 11, 1, 5, 99]), None, "The task don't a fit, you must a quit!"),
|
||||
])
|
||||
def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason):
|
||||
with mock.patch.object(InstanceGroup,
|
||||
'instances',
|
||||
Mock(spec_set=['filter'],
|
||||
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
|
||||
order_by=lambda x: instances))):
|
||||
ig = InstanceGroup(id=10)
|
||||
ig = InstanceGroup(id=10)
|
||||
|
||||
if instance_fit_index is None:
|
||||
assert ig.fit_task_to_most_remaining_capacity_instance(task) is None, reason
|
||||
else:
|
||||
assert ig.fit_task_to_most_remaining_capacity_instance(task) == \
|
||||
instances[instance_fit_index], reason
|
||||
instance_picked = ig.fit_task_to_most_remaining_capacity_instance(task, instances)
|
||||
|
||||
if instance_fit_index is None:
|
||||
assert instance_picked is None, reason
|
||||
else:
|
||||
assert instance_picked == instances[instance_fit_index], reason
|
||||
|
||||
@pytest.mark.parametrize('instances,instance_fit_index,reason', [
|
||||
(Is([(0, 100)]), 0, "One idle instance, pick it"),
|
||||
@@ -70,16 +65,12 @@ class TestInstanceGroup(object):
|
||||
def filter_offline_instances(*args):
|
||||
return filter(lambda i: i.capacity > 0, instances)
|
||||
|
||||
with mock.patch.object(InstanceGroup,
|
||||
'instances',
|
||||
Mock(spec_set=['filter'],
|
||||
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
|
||||
order_by=filter_offline_instances))):
|
||||
ig = InstanceGroup(id=10)
|
||||
ig = InstanceGroup(id=10)
|
||||
instances_online_only = filter_offline_instances(instances)
|
||||
|
||||
if instance_fit_index is None:
|
||||
assert ig.find_largest_idle_instance() is None, reason
|
||||
else:
|
||||
assert ig.find_largest_idle_instance() == \
|
||||
instances[instance_fit_index], reason
|
||||
if instance_fit_index is None:
|
||||
assert ig.find_largest_idle_instance(instances_online_only) is None, reason
|
||||
else:
|
||||
assert ig.find_largest_idle_instance(instances_online_only) == \
|
||||
instances[instance_fit_index], reason
|
||||
|
||||
|
||||
@@ -159,23 +159,29 @@
|
||||
gather_facts: false
|
||||
connection: local
|
||||
name: Install content with ansible-galaxy command if necessary
|
||||
vars:
|
||||
yaml_exts:
|
||||
- {ext: .yml}
|
||||
- {ext: .yaml}
|
||||
tasks:
|
||||
|
||||
- block:
|
||||
- name: detect requirements.yml
|
||||
- name: detect roles/requirements.(yml/yaml)
|
||||
stat:
|
||||
path: '{{project_path|quote}}/roles/requirements.yml'
|
||||
path: "{{project_path|quote}}/roles/requirements{{ item.ext }}"
|
||||
with_items: "{{ yaml_exts }}"
|
||||
register: doesRequirementsExist
|
||||
|
||||
- name: fetch galaxy roles from requirements.yml
|
||||
- name: fetch galaxy roles from requirements.(yml/yaml)
|
||||
command: >
|
||||
ansible-galaxy role install -r roles/requirements.yml
|
||||
ansible-galaxy role install -r {{ item.stat.path }}
|
||||
--roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles
|
||||
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
|
||||
args:
|
||||
chdir: "{{project_path|quote}}"
|
||||
register: galaxy_result
|
||||
when: doesRequirementsExist.stat.exists
|
||||
with_items: "{{ doesRequirementsExist.results }}"
|
||||
when: item.stat.exists
|
||||
changed_when: "'was installed successfully' in galaxy_result.stdout"
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: false
|
||||
@@ -186,20 +192,22 @@
|
||||
- install_roles
|
||||
|
||||
- block:
|
||||
- name: detect collections/requirements.yml
|
||||
- name: detect collections/requirements.(yml/yaml)
|
||||
stat:
|
||||
path: '{{project_path|quote}}/collections/requirements.yml'
|
||||
path: "{{project_path|quote}}/collections/requirements{{ item.ext }}"
|
||||
with_items: "{{ yaml_exts }}"
|
||||
register: doesCollectionRequirementsExist
|
||||
|
||||
- name: fetch galaxy collections from collections/requirements.yml
|
||||
- name: fetch galaxy collections from collections/requirements.(yml/yaml)
|
||||
command: >
|
||||
ansible-galaxy collection install -r collections/requirements.yml
|
||||
ansible-galaxy collection install -r {{ item.stat.path }}
|
||||
--collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections
|
||||
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
|
||||
args:
|
||||
chdir: "{{project_path|quote}}"
|
||||
register: galaxy_collection_result
|
||||
when: doesCollectionRequirementsExist.stat.exists
|
||||
with_items: "{{ doesCollectionRequirementsExist.results }}"
|
||||
when: item.stat.exists
|
||||
changed_when: "'Installing ' in galaxy_collection_result.stdout"
|
||||
environment:
|
||||
ANSIBLE_FORCE_COLOR: false
|
||||
|
||||
@@ -515,6 +515,7 @@ register(
|
||||
help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'),
|
||||
category=_('TACACS+'),
|
||||
category_slug='tacacsplus',
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
|
||||
@@ -445,6 +445,7 @@ class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin):
|
||||
|
||||
default_error_messages = {
|
||||
'type_error': _('Expected an instance of LDAPGroupType but got {input_type} instead.'),
|
||||
'missing_parameters': _('Missing required parameters in {dependency}.')
|
||||
}
|
||||
|
||||
def __init__(self, choices=None, **kwargs):
|
||||
@@ -479,7 +480,10 @@ class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin):
|
||||
if attr in params:
|
||||
params_sanitized[attr] = params[attr]
|
||||
|
||||
return cls(**params_sanitized)
|
||||
try:
|
||||
return cls(**params_sanitized)
|
||||
except TypeError:
|
||||
self.fail('missing_parameters', dependency=list(self.depends_on)[0])
|
||||
|
||||
|
||||
class LDAPGroupTypeParamsField(fields.DictField, DependsOnMixin):
|
||||
|
||||
@@ -7,6 +7,7 @@ export default ['$rootScope', '$scope', '$location', 'ConfigService', 'lastPath'
|
||||
$scope.ansible_version = config.ansible_version;
|
||||
$scope.subscription = config.license_info.subscription_name;
|
||||
$scope.speechBubble = createSpeechBubble($rootScope.BRAND_NAME, config.version);
|
||||
$scope.currentYear = new Date().getFullYear();
|
||||
$('#about-modal').modal('show');
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
Ansible {{ ansible_version }}
|
||||
</span> <br>
|
||||
<span class="Copyright-text" translate>
|
||||
Copyright © 2019 Red Hat, Inc. <br>
|
||||
Copyright © {{ currentYear }} Red Hat, Inc. <br>
|
||||
Visit <a href="http://www.ansible.com/" target="_blank">Ansible.com</a> for more information.<br>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* Controller for handling third party supported login options.
|
||||
*/
|
||||
|
||||
export default ['$window', '$scope', 'thirdPartySignOnService',
|
||||
function ($window, $scope, thirdPartySignOnService) {
|
||||
export default ['$window', '$scope', 'thirdPartySignOnService', '$cookies', 'Authorization',
|
||||
function ($window, $scope, thirdPartySignOnService, $cookies, Authorization) {
|
||||
|
||||
thirdPartySignOnService(
|
||||
{scope: $scope, url: "api/v2/auth/"}).then(function (data) {
|
||||
@@ -29,8 +29,16 @@ export default ['$window', '$scope', 'thirdPartySignOnService',
|
||||
});
|
||||
|
||||
$scope.goTo = function(link) {
|
||||
// this is used because $location only lets you navigate inside
|
||||
// the "/#/" path, and these are API urls.
|
||||
$window.location.href = link;
|
||||
// clear out any prior auth state that might exist (e.g: from other
|
||||
// tabs, etc.) before redirecting to the auth service
|
||||
Authorization.logout().then(() => {
|
||||
angular.forEach($cookies.getAll(), (val, name) => {
|
||||
$cookies.remove(name);
|
||||
});
|
||||
$window.location.reload();
|
||||
// this is used because $location only lets you navigate inside
|
||||
// the "/#/" path, and these are API urls.
|
||||
$window.location.href = link;
|
||||
});
|
||||
};
|
||||
}];
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
selected-id="webhookCredential.modalSelectedId"
|
||||
on-ready="handleWebhookCredentialModalReady"
|
||||
on-item-select="handleWebhookCredentialModalItemSelect"
|
||||
/>
|
||||
></at-lookup-list>
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-action-button
|
||||
variant="tertiary"
|
||||
|
||||
@@ -171,7 +171,7 @@ export default
|
||||
selected-id="webhookCredential.modalSelectedId"
|
||||
on-ready="handleWebhookCredentialModalReady"
|
||||
on-item-select="handleWebhookCredentialModalItemSelect"
|
||||
/>
|
||||
></at-lookup-list>
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-action-button
|
||||
variant="tertiary"
|
||||
|
||||
@@ -140,7 +140,7 @@ export default [
|
||||
selected-id="webhookCredential.modalSelectedId"
|
||||
on-ready="handleWebhookCredentialModalReady"
|
||||
on-item-select="handleWebhookCredentialModalItemSelect"
|
||||
/>
|
||||
></at-lookup-list>
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-action-button
|
||||
variant="tertiary"
|
||||
|
||||
@@ -179,7 +179,7 @@ export default [
|
||||
selected-id="webhookCredential.modalSelectedId"
|
||||
on-ready="handleWebhookCredentialModalReady"
|
||||
on-item-select="handleWebhookCredentialModalItemSelect"
|
||||
/>
|
||||
></at-lookup-list>
|
||||
<at-action-group col="12" pos="right">
|
||||
<at-action-button
|
||||
variant="tertiary"
|
||||
|
||||
61
awx/ui/package-lock.json
generated
61
awx/ui/package-lock.json
generated
@@ -171,9 +171,9 @@
|
||||
"integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU="
|
||||
},
|
||||
"angular": {
|
||||
"version": "1.7.9",
|
||||
"resolved": "https://registry.npmjs.org/angular/-/angular-1.7.9.tgz",
|
||||
"integrity": "sha512-5se7ZpcOtu0MBFlzGv5dsM1quQDoDeUTwZrWjGtTNA7O88cD8TEk5IEKCTDa3uECV9XnvKREVUr7du1ACiWGFQ=="
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/angular/-/angular-1.8.1.tgz",
|
||||
"integrity": "sha512-eiasF4uFXsmKD8qYpkKEi9rKVxMv0nIDZXsYrwzSbPIbjmTV05bx+18VDbRmMx7p+gL84T9Qw2NCpVe8w1QKHQ=="
|
||||
},
|
||||
"angular-breadcrumb": {
|
||||
"version": "git+https://git@github.com/ansible/angular-breadcrumb.git#6c2b1ad45ad5fbe7adf39af1ef3b294ca8e207a9",
|
||||
@@ -2797,11 +2797,6 @@
|
||||
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
|
||||
"dev": true
|
||||
},
|
||||
"complex.js": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.0.4.tgz",
|
||||
"integrity": "sha512-Syl95HpxUTS0QjwNxencZsKukgh1zdS9uXeXX2Us0pHaqBR6kiZZi0AkZ9VpZFwHJyVIUVzI4EumjWdXP3fy6w=="
|
||||
},
|
||||
"component-bind": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
|
||||
@@ -3456,11 +3451,6 @@
|
||||
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
|
||||
"dev": true
|
||||
},
|
||||
"decimal.js": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-9.0.1.tgz",
|
||||
"integrity": "sha512-2h0iKbJwnImBk4TGk7CG1xadoA0g3LDPlQhQzbZ221zvG0p2YVUedbKIPsOZXKZGx6YmZMJKYOalpCMxSdDqTQ=="
|
||||
},
|
||||
"decode-uri-component": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
|
||||
@@ -4137,11 +4127,6 @@
|
||||
"integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=",
|
||||
"dev": true
|
||||
},
|
||||
"escape-latex": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.1.0.tgz",
|
||||
"integrity": "sha512-7k372jNDrL8uW7P/Sw8IkF+QcaeGoyjzrLx4pJj/CSIe02CvxL1wUJ+qMVVHsna/jNZ6PD6aCo7iEeRnXTzvdw=="
|
||||
},
|
||||
"escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
@@ -5493,11 +5478,6 @@
|
||||
"integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=",
|
||||
"dev": true
|
||||
},
|
||||
"fraction.js": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.4.tgz",
|
||||
"integrity": "sha512-aK/oGatyYLTtXRHjfEsytX5fieeR5H4s8sLorzcT12taFS+dbMZejnvm9gRa8mZAPwci24ucjq9epDyaq5u8Iw=="
|
||||
},
|
||||
"fragment-cache": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
|
||||
@@ -8070,11 +8050,6 @@
|
||||
"resolved": "https://registry.npmjs.org/javascript-detect-element-resize/-/javascript-detect-element-resize-0.5.3.tgz",
|
||||
"integrity": "sha1-GnHNUd/lZZB/KZAS/nOilBBAJd4="
|
||||
},
|
||||
"javascript-natural-sort": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
|
||||
"integrity": "sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k="
|
||||
},
|
||||
"jquery": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
|
||||
@@ -9186,21 +9161,6 @@
|
||||
"integrity": "sha1-izqsWIuKZuSXXjzepn97sylgH6w=",
|
||||
"dev": true
|
||||
},
|
||||
"mathjs": {
|
||||
"version": "3.20.2",
|
||||
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-3.20.2.tgz",
|
||||
"integrity": "sha512-3f6/+uf1cUtIz1rYFz775wekl/UEDSQ3mU6xdxW7qzpvvhc2v28i3UtLsGTRB+u8OqDWoSX6Dz8gehaGFs6tCA==",
|
||||
"requires": {
|
||||
"complex.js": "2.0.4",
|
||||
"decimal.js": "9.0.1",
|
||||
"escape-latex": "^1.0.0",
|
||||
"fraction.js": "4.0.4",
|
||||
"javascript-natural-sort": "0.7.1",
|
||||
"seed-random": "2.2.0",
|
||||
"tiny-emitter": "2.0.2",
|
||||
"typed-function": "0.10.7"
|
||||
}
|
||||
},
|
||||
"md5.js": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz",
|
||||
@@ -12266,11 +12226,6 @@
|
||||
"ajv": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"seed-random": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz",
|
||||
"integrity": "sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ="
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
@@ -13505,11 +13460,6 @@
|
||||
"version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f",
|
||||
"from": "github:ansible/timezone-js#0.4.14"
|
||||
},
|
||||
"tiny-emitter": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
|
||||
"integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
|
||||
},
|
||||
"titlecase": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/titlecase/-/titlecase-1.1.2.tgz",
|
||||
@@ -13654,11 +13604,6 @@
|
||||
"mime-types": "~2.1.18"
|
||||
}
|
||||
},
|
||||
"typed-function": {
|
||||
"version": "0.10.7",
|
||||
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-0.10.7.tgz",
|
||||
"integrity": "sha512-3mlZ5AwRMbLvUKkc8a1TI4RUJUS2H27pmD5q0lHRObgsoWzhDAX01yg82kwSP1FUw922/4Y9ZliIEh0qJZcz+g=="
|
||||
},
|
||||
"typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uirouter/angularjs": "1.0.18",
|
||||
"angular": "^1.7.9",
|
||||
"angular": "^1.8.1",
|
||||
"angular-breadcrumb": "git+https://git@github.com/ansible/angular-breadcrumb#0.4.1",
|
||||
"angular-codemirror": "git+https://git@github.com/ansible/angular-codemirror.git#v1.2.1",
|
||||
"angular-cookies": "^1.7.9",
|
||||
@@ -128,7 +128,6 @@
|
||||
"legacy-loader": "0.0.2",
|
||||
"lodash": "^4.17.15",
|
||||
"lr-infinite-scroll": "git+https://git@github.com/lorenzofox3/lrInfiniteScroll",
|
||||
"mathjs": "^3.15.0",
|
||||
"moment": "^2.19.4",
|
||||
"ng-toast": "git+https://git@github.com/ansible/ngToast.git#v2.2.1",
|
||||
"nvd3": "^1.8.6",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
640
awx/ui/po/ja.po
640
awx/ui/po/ja.po
File diff suppressed because it is too large
Load Diff
14
awx/ui_next/package-lock.json
generated
14
awx/ui_next/package-lock.json
generated
@@ -11672,9 +11672,9 @@
|
||||
}
|
||||
},
|
||||
"node-forge": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
|
||||
"integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
|
||||
"integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==",
|
||||
"dev": true
|
||||
},
|
||||
"node-int64": {
|
||||
@@ -15287,12 +15287,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"selfsigned": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz",
|
||||
"integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==",
|
||||
"version": "1.10.8",
|
||||
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.8.tgz",
|
||||
"integrity": "sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"node-forge": "0.9.0"
|
||||
"node-forge": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"semver": {
|
||||
|
||||
@@ -24,6 +24,7 @@ import Projects from './models/Projects';
|
||||
import Roles from './models/Roles';
|
||||
import Root from './models/Root';
|
||||
import Schedules from './models/Schedules';
|
||||
import Settings from './models/Settings';
|
||||
import SystemJobs from './models/SystemJobs';
|
||||
import Teams from './models/Teams';
|
||||
import Tokens from './models/Tokens';
|
||||
@@ -61,6 +62,7 @@ const ProjectsAPI = new Projects();
|
||||
const RolesAPI = new Roles();
|
||||
const RootAPI = new Root();
|
||||
const SchedulesAPI = new Schedules();
|
||||
const SettingsAPI = new Settings();
|
||||
const SystemJobsAPI = new SystemJobs();
|
||||
const TeamsAPI = new Teams();
|
||||
const TokensAPI = new Tokens();
|
||||
@@ -99,6 +101,7 @@ export {
|
||||
RolesAPI,
|
||||
RootAPI,
|
||||
SchedulesAPI,
|
||||
SettingsAPI,
|
||||
SystemJobsAPI,
|
||||
TeamsAPI,
|
||||
TokensAPI,
|
||||
|
||||
@@ -12,7 +12,9 @@ class Groups extends Base {
|
||||
}
|
||||
|
||||
associateHost(id, hostId) {
|
||||
return this.http.post(`${this.baseUrl}${id}/hosts/`, { id: hostId });
|
||||
return this.http.post(`${this.baseUrl}${id}/hosts/`, {
|
||||
id: hostId,
|
||||
});
|
||||
}
|
||||
|
||||
createHost(id, data) {
|
||||
@@ -20,7 +22,9 @@ class Groups extends Base {
|
||||
}
|
||||
|
||||
readAllHosts(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
|
||||
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
disassociateHost(id, host) {
|
||||
@@ -29,6 +33,10 @@ class Groups extends Base {
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
readChildren(id, params) {
|
||||
return this.http.get(`${this.baseUrl}${id}/children/`, params);
|
||||
}
|
||||
}
|
||||
|
||||
export default Groups;
|
||||
|
||||
26
awx/ui_next/src/api/models/Settings.js
Normal file
26
awx/ui_next/src/api/models/Settings.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import Base from '../Base';
|
||||
|
||||
class Settings extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/settings/';
|
||||
}
|
||||
|
||||
readAllOptions() {
|
||||
return this.http.options(`${this.baseUrl}all/`);
|
||||
}
|
||||
|
||||
updateAll(data) {
|
||||
return this.http.patch(`${this.baseUrl}all/`, data);
|
||||
}
|
||||
|
||||
readCategory(category) {
|
||||
return this.http.get(`${this.baseUrl}${category}/`);
|
||||
}
|
||||
|
||||
readCategoryOptions(category) {
|
||||
return this.http.options(`${this.baseUrl}${category}/`);
|
||||
}
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
@@ -1,19 +1,25 @@
|
||||
import React, { useState, Fragment, useCallback, useEffect } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import useRequest, { useDismissableError } from '../../util/useRequest';
|
||||
import { InventoriesAPI } from '../../api';
|
||||
|
||||
import AlertModal from '../AlertModal';
|
||||
import { CredentialTypesAPI } from '../../api';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import AdHocCommandsWizard from './AdHocCommandsWizard';
|
||||
import ContentLoading from '../ContentLoading';
|
||||
import ContentError from '../ContentError';
|
||||
|
||||
function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
function AdHocCommands({
|
||||
onClose,
|
||||
adHocItems,
|
||||
itemId,
|
||||
i18n,
|
||||
moduleOptions,
|
||||
credentialTypeId,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const verbosityOptions = [
|
||||
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
|
||||
@@ -22,59 +28,26 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
|
||||
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
|
||||
];
|
||||
const {
|
||||
error: fetchError,
|
||||
request: fetchModuleOptions,
|
||||
result: { moduleOptions, credentialTypeId },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const [choices, credId] = await Promise.all([
|
||||
apiModule.readAdHocOptions(itemId),
|
||||
CredentialTypesAPI.read({ namespace: 'ssh' }),
|
||||
]);
|
||||
const itemObject = (item, index) => {
|
||||
return {
|
||||
key: index,
|
||||
value: item,
|
||||
label: `${item}`,
|
||||
isDisabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
const options = choices.data.actions.GET.module_name.choices.map(
|
||||
(choice, index) => itemObject(choice[0], index)
|
||||
);
|
||||
|
||||
return {
|
||||
moduleOptions: [itemObject('', -1), ...options],
|
||||
credentialTypeId: credId.data.results[0].id,
|
||||
};
|
||||
}, [itemId, apiModule]),
|
||||
{ moduleOptions: [] }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModuleOptions();
|
||||
}, [fetchModuleOptions]);
|
||||
|
||||
const {
|
||||
isloading: isLaunchLoading,
|
||||
error: launchError,
|
||||
error,
|
||||
request: launchAdHocCommands,
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async values => {
|
||||
const { data } = await apiModule.launchAdHocCommands(itemId, values);
|
||||
const { data } = await InventoriesAPI.launchAdHocCommands(
|
||||
itemId,
|
||||
values
|
||||
);
|
||||
history.push(`/jobs/command/${data.id}/output`);
|
||||
},
|
||||
|
||||
[apiModule, itemId, history]
|
||||
[itemId, history]
|
||||
)
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(
|
||||
launchError || fetchError
|
||||
);
|
||||
const { dismissError } = useDismissableError(error);
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { credential, ...remainingValues } = values;
|
||||
@@ -85,14 +58,13 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
...remainingValues,
|
||||
};
|
||||
await launchAdHocCommands(manipulatedValues);
|
||||
setIsWizardOpen(false);
|
||||
};
|
||||
|
||||
if (isLaunchLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (error && isWizardOpen) {
|
||||
if (error) {
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
@@ -100,43 +72,29 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => {
|
||||
dismissError();
|
||||
setIsWizardOpen(false);
|
||||
}}
|
||||
>
|
||||
{launchError ? (
|
||||
<>
|
||||
{i18n._(t`Failed to launch job.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</>
|
||||
) : (
|
||||
<ContentError error={error} />
|
||||
)}
|
||||
<>
|
||||
{i18n._(t`Failed to launch job.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</>
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
{children({
|
||||
openAdHocCommands: () => setIsWizardOpen(true),
|
||||
})}
|
||||
|
||||
{isWizardOpen && (
|
||||
<AdHocCommandsWizard
|
||||
adHocItems={adHocItems}
|
||||
moduleOptions={moduleOptions}
|
||||
verbosityOptions={verbosityOptions}
|
||||
credentialTypeId={credentialTypeId}
|
||||
onCloseWizard={() => setIsWizardOpen(false)}
|
||||
onLaunch={handleSubmit}
|
||||
onDismissError={() => dismissError()}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
<AdHocCommandsWizard
|
||||
adHocItems={adHocItems}
|
||||
moduleOptions={moduleOptions}
|
||||
verbosityOptions={verbosityOptions}
|
||||
credentialTypeId={credentialTypeId}
|
||||
onCloseWizard={onClose}
|
||||
onLaunch={handleSubmit}
|
||||
onDismissError={() => dismissError()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
AdHocCommands.propTypes = {
|
||||
children: PropTypes.func.isRequired,
|
||||
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
itemId: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,10 @@ const credentials = [
|
||||
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
|
||||
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||
];
|
||||
const moduleOptions = [
|
||||
['command', 'command'],
|
||||
['shell', 'shell'],
|
||||
];
|
||||
const adHocItems = [
|
||||
{
|
||||
name: 'Inventory 1 Org 0',
|
||||
@@ -25,10 +29,6 @@ const adHocItems = [
|
||||
{ name: 'Inventory 2 Org 0' },
|
||||
];
|
||||
|
||||
const children = ({ openAdHocCommands }) => (
|
||||
<button type="submit" onClick={() => openAdHocCommands()} />
|
||||
);
|
||||
|
||||
describe('<AdHocCommands />', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
@@ -40,111 +40,38 @@ describe('<AdHocCommands />', () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
css="margin-right: 20px"
|
||||
onClose={() => {}}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
adHocItems={adHocItems}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('AdHocCommands').length).toBe(1);
|
||||
});
|
||||
test('calls api on Mount', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('AdHocCommands').length).toBe(1);
|
||||
expect(InventoriesAPI.readAdHocOptions).toBeCalledWith(1);
|
||||
expect(CredentialTypesAPI.read).toBeCalledWith({ namespace: 'ssh' });
|
||||
});
|
||||
test('should open the wizard', async () => {
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {
|
||||
module_name: {
|
||||
choices: [
|
||||
['command', 'command'],
|
||||
['foo', 'foo'],
|
||||
],
|
||||
},
|
||||
verbosity: { choices: [[1], [2]] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { results: [{ id: 1 }] },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should submit properly', async () => {
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {
|
||||
module_name: {
|
||||
choices: [
|
||||
['command', 'command'],
|
||||
['foo', 'foo'],
|
||||
],
|
||||
},
|
||||
verbosity: { choices: [[1], [2]] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { results: [{ id: 1 }] },
|
||||
});
|
||||
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
|
||||
CredentialsAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
results: credentials,
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
css="margin-right: 20px"
|
||||
onClose={() => {}}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
adHocItems={adHocItems}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
|
||||
wrapper.update();
|
||||
|
||||
@@ -177,6 +104,7 @@ describe('<AdHocCommands />', () => {
|
||||
);
|
||||
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
|
||||
// second step of wizard
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('input[aria-labelledby="check-action-item-4"]')
|
||||
@@ -205,10 +133,6 @@ describe('<AdHocCommands />', () => {
|
||||
module_name: 'command',
|
||||
verbosity: 1,
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should throw error on submission properly', async () => {
|
||||
@@ -251,16 +175,15 @@ describe('<AdHocCommands />', () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
css="margin-right: 20px"
|
||||
onClose={() => {}}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
itemId={1}
|
||||
adHocItems={adHocItems}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
|
||||
wrapper.update();
|
||||
|
||||
@@ -312,36 +235,6 @@ describe('<AdHocCommands />', () => {
|
||||
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||
);
|
||||
|
||||
waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
|
||||
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
|
||||
});
|
||||
test('should open alert modal when error on fetching data', async () => {
|
||||
InventoriesAPI.readAdHocOptions.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'options',
|
||||
url: '/api/v2/inventories/1/',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<AdHocCommands
|
||||
apiModule={InventoriesAPI}
|
||||
adHocItems={adHocItems}
|
||||
itemId={1}
|
||||
credentialTypeId={1}
|
||||
>
|
||||
{children}
|
||||
</AdHocCommands>
|
||||
);
|
||||
});
|
||||
await act(async () => wrapper.find('button').prop('onClick')());
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
|
||||
import { Tooltip } from '@patternfly/react-core';
|
||||
import { withFormik, useFormikContext } from 'formik';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import Wizard from '../Wizard';
|
||||
import AdHocCredentialStep from './AdHocCredentialStep';
|
||||
import AdHocDetailsStep from './AdHocDetailsStep';
|
||||
|
||||
const AlertText = styled.div`
|
||||
color: var(--pf-global--danger-color--200);
|
||||
font-weight: var(--pf-global--FontWeight--bold);
|
||||
`;
|
||||
|
||||
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
|
||||
margin-left: 10px;
|
||||
color: var(--pf-global--danger-color--100);
|
||||
`;
|
||||
|
||||
function AdHocCommandsWizard({
|
||||
onLaunch,
|
||||
i18n,
|
||||
@@ -19,7 +32,7 @@ function AdHocCommandsWizard({
|
||||
const [currentStepId, setCurrentStepId] = useState(1);
|
||||
const [enableLaunch, setEnableLaunch] = useState(false);
|
||||
|
||||
const { values } = useFormikContext();
|
||||
const { values, errors, touched } = useFormikContext();
|
||||
|
||||
const enabledNextOnDetailsStep = () => {
|
||||
if (!values.module_name) {
|
||||
@@ -36,11 +49,26 @@ function AdHocCommandsWizard({
|
||||
}
|
||||
return undefined; // makes the linter happy;
|
||||
};
|
||||
const hasDetailsStepError = errors.module_args && touched.module_args;
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
key: 1,
|
||||
name: i18n._(t`Details`),
|
||||
name: hasDetailsStepError ? (
|
||||
<AlertText>
|
||||
{i18n._(t`Details`)}
|
||||
<Tooltip
|
||||
position="right"
|
||||
content={i18n._(t`This step contains errors`)}
|
||||
trigger="click mouseenter focus"
|
||||
>
|
||||
<ExclamationCircleIcon />
|
||||
</Tooltip>
|
||||
</AlertText>
|
||||
) : (
|
||||
i18n._(t`Details`)
|
||||
),
|
||||
component: (
|
||||
<AdHocDetailsStep
|
||||
moduleOptions={moduleOptions}
|
||||
@@ -60,7 +88,7 @@ function AdHocCommandsWizard({
|
||||
onEnableLaunch={() => setEnableLaunch(true)}
|
||||
/>
|
||||
),
|
||||
enableNext: enableLaunch,
|
||||
enableNext: enableLaunch && Object.values(errors).length === 0,
|
||||
nextButtonText: i18n._(t`Launch`),
|
||||
canJumpTo: currentStepId >= 2,
|
||||
},
|
||||
|
||||
@@ -148,6 +148,20 @@ describe('<AdHocCommandsWizard/>', () => {
|
||||
|
||||
expect(onLaunch).toHaveBeenCalled();
|
||||
});
|
||||
test('should show error in navigation bar', async () => {
|
||||
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
|
||||
{},
|
||||
'command'
|
||||
);
|
||||
wrapper.find('input#module_args').simulate('change', {
|
||||
target: { value: '', name: 'module_args' },
|
||||
});
|
||||
});
|
||||
waitForElement(wrapper, 'ExclamationCircleIcon', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('expect credential step to throw error', async () => {
|
||||
CredentialsAPI.read.mockRejectedValue(
|
||||
|
||||
@@ -65,6 +65,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
|
||||
<FormGroup
|
||||
fieldId="credential"
|
||||
label={i18n._(t`Machine Credential`)}
|
||||
aria-label={i18n._(t`Machine Credential`)}
|
||||
isRequired
|
||||
validated={
|
||||
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'
|
||||
|
||||
@@ -27,32 +27,43 @@ const TooltipWrapper = styled.div`
|
||||
// in failing tests.
|
||||
const brandName = BrandName;
|
||||
|
||||
function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
const [module_nameField, module_nameMeta, module_nameHelpers] = useField({
|
||||
function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
|
||||
name: 'module_name',
|
||||
validate: required(null, i18n),
|
||||
});
|
||||
|
||||
const [variablesField] = useField('extra_vars');
|
||||
const [diff_modeField, , diff_modeHelpers] = useField('diff_mode');
|
||||
const [become_enabledField, , become_enabledHelpers] = useField(
|
||||
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
|
||||
const [becomeEnabledField, , becomeEnabledHelpers] = useField(
|
||||
'become_enabled'
|
||||
);
|
||||
const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
|
||||
name: 'verbosity',
|
||||
validate: required(null, i18n),
|
||||
});
|
||||
|
||||
const argumentsRequired =
|
||||
moduleNameField.value === 'command' || moduleNameField.value === 'shell';
|
||||
const [, argumentsMeta, argumentsHelpers] = useField({
|
||||
name: 'module_args',
|
||||
validate: argumentsRequired && required(null, i18n),
|
||||
});
|
||||
|
||||
const isValid = !argumentsMeta.error || !argumentsMeta.touched;
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormColumnLayout>
|
||||
<FormFullWidthLayout>
|
||||
<FormGroup
|
||||
fieldId="module_name"
|
||||
aria-label={i18n._(t`Module`)}
|
||||
label={i18n._(t`Module`)}
|
||||
isRequired
|
||||
helperTextInvalid={module_nameMeta.error}
|
||||
helperTextInvalid={moduleNameMeta.error}
|
||||
validated={
|
||||
!module_nameMeta.touched || !module_nameMeta.error
|
||||
!moduleNameMeta.touched || !moduleNameMeta.error
|
||||
? 'default'
|
||||
: 'error'
|
||||
}
|
||||
@@ -65,33 +76,52 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
}
|
||||
>
|
||||
<AnsibleSelect
|
||||
{...module_nameField}
|
||||
isValid={!module_nameMeta.touched || !module_nameMeta.error}
|
||||
{...moduleNameField}
|
||||
placeHolder={i18n._(t`Select a module`)}
|
||||
isValid={!moduleNameMeta.touched || !moduleNameMeta.error}
|
||||
id="module_name"
|
||||
data={moduleOptions || []}
|
||||
data={[
|
||||
{
|
||||
value: '',
|
||||
key: '',
|
||||
label: i18n._(t`Choose a module`),
|
||||
isDisabled: true,
|
||||
},
|
||||
...moduleOptions.map(value => ({
|
||||
value: value[0],
|
||||
label: value[0],
|
||||
key: value[0],
|
||||
})),
|
||||
]}
|
||||
onChange={(event, value) => {
|
||||
module_nameHelpers.setValue(value);
|
||||
if (value !== 'command' && value !== 'shell') {
|
||||
argumentsHelpers.setTouched(false);
|
||||
}
|
||||
moduleNameHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormField
|
||||
id="module_args"
|
||||
name="module_args"
|
||||
aria-label={i18n._(t`Arguments`)}
|
||||
type="text"
|
||||
label={i18n._(t`Arguments`)}
|
||||
validate={required(null, i18n)}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
onBlur={() => argumentsHelpers.setTouched(true)}
|
||||
placeholder={i18n._(t`Enter arguments`)}
|
||||
isRequired={
|
||||
module_nameField.value === 'command' ||
|
||||
module_nameField.value === 'shell'
|
||||
moduleNameField.value === 'command' ||
|
||||
moduleNameField.value === 'shell'
|
||||
}
|
||||
tooltip={
|
||||
module_nameField.value ? (
|
||||
moduleNameField.value ? (
|
||||
<>
|
||||
{i18n._(
|
||||
t`These arguments are used with the specified module. You can find information about ${module_nameField.value} by clicking `
|
||||
t`These arguments are used with the specified module. You can find information about ${moduleNameField.value} by clicking `
|
||||
)}
|
||||
<a
|
||||
href={`https://docs.ansible.com/ansible/latest/modules/${module_nameField.value}_module.html`}
|
||||
href={`https://docs.ansible.com/ansible/latest/modules/${moduleNameField.value}_module.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -106,6 +136,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
/>
|
||||
<FormGroup
|
||||
fieldId="verbosity"
|
||||
aria-label={i18n._(t`Verbosity`)}
|
||||
label={i18n._(t`Verbosity`)}
|
||||
isRequired
|
||||
validated={
|
||||
@@ -137,6 +168,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
name="limit"
|
||||
type="text"
|
||||
label={i18n._(t`Limit`)}
|
||||
aria-label={i18n._(t`Limit`)}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(
|
||||
@@ -147,7 +179,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{i18n._(`here`)}
|
||||
{i18n._(t`here`)}
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
@@ -158,6 +190,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
type="number"
|
||||
min="0"
|
||||
label={i18n._(t`Forks`)}
|
||||
aria-label={i18n._(t`Forks`)}
|
||||
tooltip={
|
||||
<span>
|
||||
{i18n._(
|
||||
@@ -176,6 +209,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
<FormColumnLayout>
|
||||
<FormGroup
|
||||
label={i18n._(t`Show changes`)}
|
||||
aria-label={i18n._(t`Show changes`)}
|
||||
labelIcon={
|
||||
<FieldTooltip
|
||||
content={i18n._(
|
||||
@@ -189,9 +223,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
id="diff_mode"
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
isChecked={diff_modeField.value}
|
||||
isChecked={diffModeField.value}
|
||||
onChange={() => {
|
||||
diff_modeHelpers.setValue(!diff_modeField.value);
|
||||
diffModeHelpers.setValue(!diffModeField.value);
|
||||
}}
|
||||
aria-label={i18n._(t`toggle changes`)}
|
||||
/>
|
||||
@@ -222,9 +256,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
</span>
|
||||
}
|
||||
id="become_enabled"
|
||||
isChecked={become_enabledField.value}
|
||||
isChecked={becomeEnabledField.value}
|
||||
onChange={checked => {
|
||||
become_enabledHelpers.setValue(checked);
|
||||
becomeEnabledHelpers.setValue(checked);
|
||||
}}
|
||||
/>
|
||||
</FormCheckboxLayout>
|
||||
@@ -273,6 +307,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
</TooltipWrapper>
|
||||
}
|
||||
label={i18n._(t`Extra variables`)}
|
||||
aria-label={i18n._(t`Extra variables`)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</FormColumnLayout>
|
||||
@@ -280,9 +315,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
|
||||
);
|
||||
}
|
||||
|
||||
CredentialStep.propTypes = {
|
||||
AdHocDetailsStep.propTypes = {
|
||||
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(CredentialStep);
|
||||
export default withI18n()(AdHocDetailsStep);
|
||||
|
||||
@@ -12,9 +12,8 @@ const verbosityOptions = [
|
||||
{ key: 1, value: 1, label: '1', isDisabled: false },
|
||||
];
|
||||
const moduleOptions = [
|
||||
{ key: -1, value: '', label: '', isDisabled: false },
|
||||
{ key: 0, value: 'command', label: 'command', isDisabled: false },
|
||||
{ key: 1, value: 'shell', label: 'shell', isDisabled: false },
|
||||
['command', 'command'],
|
||||
['shell', 'shell'],
|
||||
];
|
||||
const onLimitChange = jest.fn();
|
||||
const initialValues = {
|
||||
|
||||
@@ -48,7 +48,12 @@ function AddDropDownButton({ dropdownItems, i18n }) {
|
||||
isPlain
|
||||
isOpen={isOpen}
|
||||
position={DropdownPosition.right}
|
||||
toggle={<ToolbarAddButton onClick={() => setIsOpen(!isOpen)} />}
|
||||
toggle={
|
||||
<ToolbarAddButton
|
||||
showToggleIndicator
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
/>
|
||||
}
|
||||
dropdownItems={dropdownItems.map(item => (
|
||||
<Link
|
||||
className="pf-c-dropdown__menu-item"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { node, number, oneOfType, shape, string } from 'prop-types';
|
||||
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
|
||||
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import { DetailName, DetailValue } from '../DetailList';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
import DetailPopover from '../DetailPopover';
|
||||
import {
|
||||
yamlToJson,
|
||||
jsonToYaml,
|
||||
@@ -27,7 +28,7 @@ function getValueAsMode(value, mode) {
|
||||
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
|
||||
}
|
||||
|
||||
function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
|
||||
const [mode, setMode] = useState(
|
||||
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
|
||||
);
|
||||
@@ -46,9 +47,14 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [value]);
|
||||
|
||||
const labelCy = dataCy ? `${dataCy}-label` : null;
|
||||
const valueCy = dataCy ? `${dataCy}-value` : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailName
|
||||
data-cy={labelCy}
|
||||
id={dataCy}
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1"
|
||||
@@ -62,6 +68,9 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{helpText && (
|
||||
<DetailPopover header={label} content={helpText} id={dataCy} />
|
||||
)}
|
||||
</div>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
@@ -84,6 +93,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
</Split>
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
data-cy={valueCy}
|
||||
component={TextListItemVariants.dd}
|
||||
fullWidth
|
||||
css="grid-column: 1 / -1; margin-top: -20px"
|
||||
@@ -109,7 +119,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
|
||||
);
|
||||
}
|
||||
VariablesDetail.propTypes = {
|
||||
value: oneOfType([shape({}), string]).isRequired,
|
||||
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
|
||||
label: node.isRequired,
|
||||
rows: number,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { node, bool } from 'prop-types';
|
||||
import { node, bool, string } from 'prop-types';
|
||||
import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import DetailPopover from '../DetailPopover';
|
||||
|
||||
const DetailName = styled(({ fullWidth, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
@@ -14,9 +15,11 @@ const DetailName = styled(({ fullWidth, ...props }) => (
|
||||
`}
|
||||
`;
|
||||
|
||||
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
))`
|
||||
const DetailValue = styled(
|
||||
({ fullWidth, isEncrypted, isNotConfigured, ...props }) => (
|
||||
<TextListItem {...props} />
|
||||
)
|
||||
)`
|
||||
word-break: break-all;
|
||||
${props =>
|
||||
props.fullWidth &&
|
||||
@@ -24,9 +27,8 @@ const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
|
||||
grid-column: 2 / -1;
|
||||
`}
|
||||
${props =>
|
||||
props.isEncrypted &&
|
||||
(props.isEncrypted || props.isNotConfigured) &&
|
||||
`
|
||||
text-transform: uppercase
|
||||
color: var(--pf-global--Color--400);
|
||||
`}
|
||||
`;
|
||||
@@ -38,7 +40,9 @@ const Detail = ({
|
||||
className,
|
||||
dataCy,
|
||||
alwaysVisible,
|
||||
helpText,
|
||||
isEncrypted,
|
||||
isNotConfigured,
|
||||
}) => {
|
||||
if (!value && typeof value !== 'number' && !alwaysVisible) {
|
||||
return null;
|
||||
@@ -54,8 +58,12 @@ const Detail = ({
|
||||
component={TextListItemVariants.dt}
|
||||
fullWidth={fullWidth}
|
||||
data-cy={labelCy}
|
||||
id={dataCy}
|
||||
>
|
||||
{label}
|
||||
{helpText && (
|
||||
<DetailPopover header={label} content={helpText} id={dataCy} />
|
||||
)}
|
||||
</DetailName>
|
||||
<DetailValue
|
||||
className={className}
|
||||
@@ -63,6 +71,7 @@ const Detail = ({
|
||||
fullWidth={fullWidth}
|
||||
data-cy={valueCy}
|
||||
isEncrypted={isEncrypted}
|
||||
isNotConfigured={isNotConfigured}
|
||||
>
|
||||
{value}
|
||||
</DetailValue>
|
||||
@@ -74,11 +83,13 @@ Detail.propTypes = {
|
||||
value: node,
|
||||
fullWidth: bool,
|
||||
alwaysVisible: bool,
|
||||
helpText: string,
|
||||
};
|
||||
Detail.defaultProps = {
|
||||
value: null,
|
||||
fullWidth: false,
|
||||
alwaysVisible: false,
|
||||
helpText: null,
|
||||
};
|
||||
|
||||
export default Detail;
|
||||
|
||||
51
awx/ui_next/src/components/DetailPopover/DetailPopover.jsx
Normal file
51
awx/ui_next/src/components/DetailPopover/DetailPopover.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react';
|
||||
import { node, string } from 'prop-types';
|
||||
import { Button as _Button, Popover } from '@patternfly/react-core';
|
||||
import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Button = styled(_Button)`
|
||||
--pf-c-button--PaddingTop: 0;
|
||||
--pf-c-button--PaddingBottom: 0;
|
||||
`;
|
||||
|
||||
function DetailPopover({ header, content, id }) {
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
bodyContent={content}
|
||||
headerContent={header}
|
||||
hideOnOutsideClick
|
||||
id={id}
|
||||
isVisible={showPopover}
|
||||
shouldClose={() => setShowPopover(false)}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowPopover(!showPopover)}
|
||||
variant="plain"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={showPopover}
|
||||
>
|
||||
<OutlinedQuestionCircleIcon
|
||||
onClick={() => setShowPopover(!showPopover)}
|
||||
/>
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
DetailPopover.propTypes = {
|
||||
content: node,
|
||||
header: node,
|
||||
id: string,
|
||||
};
|
||||
DetailPopover.defaultProps = {
|
||||
content: null,
|
||||
header: null,
|
||||
id: 'detail-popover',
|
||||
};
|
||||
|
||||
export default DetailPopover;
|
||||
1
awx/ui_next/src/components/DetailPopover/index.js
Normal file
1
awx/ui_next/src/components/DetailPopover/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './DetailPopover';
|
||||
@@ -5,37 +5,57 @@ import { t } from '@lingui/macro';
|
||||
import { ActionGroup, Button } from '@patternfly/react-core';
|
||||
import { FormFullWidthLayout } from '../FormLayout';
|
||||
|
||||
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
aria-label={i18n._(t`Save`)}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
isDisabled={submitDisabled}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
);
|
||||
const FormActionGroup = ({
|
||||
onCancel,
|
||||
onRevert,
|
||||
onSubmit,
|
||||
submitDisabled,
|
||||
i18n,
|
||||
}) => {
|
||||
return (
|
||||
<FormFullWidthLayout>
|
||||
<ActionGroup>
|
||||
<Button
|
||||
aria-label={i18n._(t`Save`)}
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
isDisabled={submitDisabled}
|
||||
>
|
||||
{i18n._(t`Save`)}
|
||||
</Button>
|
||||
{onRevert && (
|
||||
<Button
|
||||
aria-label={i18n._(t`Revert`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onRevert}
|
||||
>
|
||||
{i18n._(t`Revert`)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormFullWidthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
FormActionGroup.propTypes = {
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onRevert: PropTypes.func,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
submitDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
FormActionGroup.defaultProps = {
|
||||
onRevert: null,
|
||||
submitDisabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,32 +2,27 @@ import React from 'react';
|
||||
import { Wizard } from '@patternfly/react-core';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Formik } from 'formik';
|
||||
import { Formik, useFormikContext } from 'formik';
|
||||
import ContentError from '../ContentError';
|
||||
import ContentLoading from '../ContentLoading';
|
||||
import { useDismissableError } from '../../util/useRequest';
|
||||
import mergeExtraVars from './mergeExtraVars';
|
||||
import useSteps from './useSteps';
|
||||
import useLaunchSteps from './useLaunchSteps';
|
||||
import AlertModal from '../AlertModal';
|
||||
import getSurveyValues from './getSurveyValues';
|
||||
|
||||
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||
function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
|
||||
const { values, setTouched, validateForm } = useFormikContext();
|
||||
|
||||
const {
|
||||
steps,
|
||||
initialValues,
|
||||
isReady,
|
||||
validate,
|
||||
visitStep,
|
||||
visitAllSteps,
|
||||
contentError,
|
||||
} = useSteps(config, resource, i18n);
|
||||
} = useLaunchSteps(config, resource, i18n);
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
if (!isReady) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
const submit = values => {
|
||||
const handleSave = () => {
|
||||
const postValues = {};
|
||||
const setValue = (key, value) => {
|
||||
if (typeof value !== 'undefined' && value !== null) {
|
||||
@@ -49,39 +44,89 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
||||
: resource.extra_vars;
|
||||
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
|
||||
setValue('scm_branch', values.scm_branch);
|
||||
onLaunch(postValues);
|
||||
|
||||
onSubmit(postValues);
|
||||
};
|
||||
const { error, dismissError } = useDismissableError(contentError);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => {
|
||||
dismissError();
|
||||
}}
|
||||
>
|
||||
<ContentError error={error} />
|
||||
</AlertModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
|
||||
{({ validateForm, setTouched, handleSubmit }) => (
|
||||
<Wizard
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
onSave={handleSubmit}
|
||||
onNext={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
onGoToStep={async (newStep, prevStep) => {
|
||||
if (newStep.id === 'preview') {
|
||||
visitAllSteps(setTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
title={i18n._(t`Prompts`)}
|
||||
steps={steps}
|
||||
backButtonText={i18n._(t`Back`)}
|
||||
cancelButtonText={i18n._(t`Cancel`)}
|
||||
nextButtonText={i18n._(t`Next`)}
|
||||
/>
|
||||
)}
|
||||
<Wizard
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
onSave={handleSave}
|
||||
onNext={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
onGoToStep={async (nextStep, prevStep) => {
|
||||
if (nextStep.id === 'preview') {
|
||||
visitAllSteps(setTouched);
|
||||
} else {
|
||||
visitStep(prevStep.prevId);
|
||||
}
|
||||
await validateForm();
|
||||
}}
|
||||
title={i18n._(t`Prompts`)}
|
||||
steps={
|
||||
isReady
|
||||
? steps
|
||||
: [
|
||||
{
|
||||
name: i18n._(t`Content Loading`),
|
||||
component: <ContentLoading />,
|
||||
},
|
||||
]
|
||||
}
|
||||
backButtonText={i18n._(t`Back`)}
|
||||
cancelButtonText={i18n._(t`Cancel`)}
|
||||
nextButtonText={i18n._(t`Next`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchPrompt({ config, resource = {}, onLaunch, onCancel, i18n }) {
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
verbosity: resource.verbosity || 0,
|
||||
inventory: resource.summary_fields?.inventory || null,
|
||||
credentials: resource.summary_fields?.credentials || null,
|
||||
diff_mode: resource.diff_mode || false,
|
||||
extra_vars: resource.extra_vars || '---',
|
||||
job_type: resource.job_type || '',
|
||||
job_tags: resource.job_tags || '',
|
||||
skip_tags: resource.skip_tags || '',
|
||||
scm_branch: resource.scm_branch || '',
|
||||
limit: resource.limit || '',
|
||||
}}
|
||||
onSubmit={values => onLaunch(values)}
|
||||
>
|
||||
<PromptModalForm
|
||||
onSubmit={values => onLaunch(values)}
|
||||
onCancel={onCancel}
|
||||
i18n={i18n}
|
||||
config={config}
|
||||
resource={resource}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('LaunchPrompt', () => {
|
||||
expect(steps).toHaveLength(5);
|
||||
expect(steps[0].name.props.children).toEqual('Inventory');
|
||||
expect(steps[1].name).toEqual('Credentials');
|
||||
expect(steps[2].name.props.children).toEqual('Other Prompts');
|
||||
expect(steps[2].name).toEqual('Other Prompts');
|
||||
expect(steps[3].name.props.children).toEqual('Survey');
|
||||
expect(steps[4].name).toEqual('Preview');
|
||||
});
|
||||
@@ -167,7 +167,7 @@ describe('LaunchPrompt', () => {
|
||||
const steps = wizard.prop('steps');
|
||||
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].name.props.children).toEqual('Other Prompts');
|
||||
expect(steps[0].name).toEqual('Other Prompts');
|
||||
expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true);
|
||||
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export default function getSurveyValues(values) {
|
||||
const surveyValues = {};
|
||||
Object.keys(values).forEach(key => {
|
||||
if (key.startsWith('survey_')) {
|
||||
if (key.startsWith('survey_') && values[key] !== []) {
|
||||
if (Array.isArray(values[key]) && values[key].length === 0) {
|
||||
return;
|
||||
}
|
||||
surveyValues[key.substr(7)] = values[key];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ function OtherPromptsStep({ config, i18n }) {
|
||||
id="prompt-job-tags"
|
||||
name="job_tags"
|
||||
label={i18n._(t`Job Tags`)}
|
||||
aria-label={i18n._(t`Job Tags`)}
|
||||
tooltip={i18n._(t`Tags are useful when you have a large
|
||||
playbook, and you want to run a specific part of a play or task.
|
||||
Use commas to separate multiple tags. Refer to Ansible Tower
|
||||
@@ -62,6 +63,7 @@ function OtherPromptsStep({ config, i18n }) {
|
||||
id="prompt-skip-tags"
|
||||
name="skip_tags"
|
||||
label={i18n._(t`Skip Tags`)}
|
||||
aria-label={i18n._(t`Skip Tags`)}
|
||||
tooltip={i18n._(t`Skip tags are useful when you have a large
|
||||
playbook, and you want to skip specific parts of a play or task.
|
||||
Use commas to separate multiple tags. Refer to Ansible Tower
|
||||
@@ -108,6 +110,7 @@ function JobTypeField({ i18n }) {
|
||||
and report problems without executing the playbook.`)}
|
||||
/>
|
||||
}
|
||||
isRequired
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<AnsibleSelect
|
||||
@@ -129,6 +132,7 @@ function VerbosityField({ i18n }) {
|
||||
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
|
||||
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
|
||||
];
|
||||
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
|
||||
return (
|
||||
@@ -171,6 +175,7 @@ function ShowChangesToggle({ i18n }) {
|
||||
</label>
|
||||
</FieldHeader>
|
||||
<Switch
|
||||
aria-label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||
id="prompt-show-changes"
|
||||
label={i18n._(t`On`)}
|
||||
labelOff={i18n._(t`Off`)}
|
||||
|
||||
@@ -48,7 +48,7 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{formErrors.length > 0 && (
|
||||
{formErrors && (
|
||||
<ErrorMessageWrapper>
|
||||
{i18n._(t`Some of the previous step(s) have errors`)}
|
||||
<Tooltip
|
||||
|
||||
@@ -104,4 +104,31 @@ describe('PreviewStep', () => {
|
||||
extra_vars: 'one: 1',
|
||||
});
|
||||
});
|
||||
|
||||
test('should remove survey with empty array value', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik
|
||||
initialValues={{ extra_vars: 'one: 1' }}
|
||||
values={{ extra_vars: 'one: 1', survey_foo: [] }}
|
||||
>
|
||||
<PreviewStep
|
||||
resource={resource}
|
||||
config={{
|
||||
ask_variables_on_launch: true,
|
||||
}}
|
||||
formErrors={formErrors}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
|
||||
const detail = wrapper.find('PromptDetail');
|
||||
expect(detail).toHaveLength(1);
|
||||
expect(detail.prop('resource')).toEqual(resource);
|
||||
expect(detail.prop('overrides')).toEqual({
|
||||
extra_vars: 'one: 1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import {
|
||||
Form,
|
||||
@@ -114,15 +115,22 @@ function MultipleChoiceField({ question }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectField({ question }) {
|
||||
function MultiSelectField({ question, i18n }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [field, meta, helpers] = useField(`survey_${question.variable}`);
|
||||
const [field, meta, helpers] = useField({
|
||||
name: `survey_${question.variable}`,
|
||||
validate: question.isrequired ? required(null, i18n) : null,
|
||||
});
|
||||
const id = `survey-question-${question.variable}`;
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
const hasActualValue = !question.required || meta.value.length > 0;
|
||||
const isValid = !meta.touched || (!meta.error && hasActualValue);
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId={id}
|
||||
helperTextInvalid={meta.error}
|
||||
helperTextInvalid={
|
||||
meta.error || i18n._(t`Must select a value for this field.`)
|
||||
}
|
||||
isRequired={question.required}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
label={question.question_name}
|
||||
@@ -133,14 +141,19 @@ function MultiSelectField({ question }) {
|
||||
id={id}
|
||||
onToggle={setIsOpen}
|
||||
onSelect={(event, option) => {
|
||||
if (field.value.includes(option)) {
|
||||
if (field?.value?.includes(option)) {
|
||||
helpers.setValue(field.value.filter(o => o !== option));
|
||||
} else {
|
||||
helpers.setValue(field.value.concat(option));
|
||||
}
|
||||
helpers.setTouched(true);
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
selections={field.value}
|
||||
onClear={() => {
|
||||
helpers.setTouched(true);
|
||||
helpers.setValue([]);
|
||||
}}
|
||||
>
|
||||
{question.choices.split('\n').map(opt => (
|
||||
<SelectOption key={opt} value={opt} />
|
||||
|
||||
@@ -4,20 +4,9 @@ import CredentialsStep from './CredentialsStep';
|
||||
|
||||
const STEP_ID = 'credentials';
|
||||
|
||||
export default function useCredentialsStep(
|
||||
config,
|
||||
resource,
|
||||
visitedSteps,
|
||||
i18n
|
||||
) {
|
||||
const validate = () => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export default function useCredentialsStep(config, i18n) {
|
||||
return {
|
||||
step: getStep(config, i18n),
|
||||
initialValues: getInitialValues(config, resource),
|
||||
validate,
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError: null,
|
||||
@@ -39,12 +28,3 @@ function getStep(config, i18n) {
|
||||
component: <CredentialsStep i18n={i18n} />,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialValues(config, resource) {
|
||||
if (!config.ask_credential_on_launch) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
credentials: resource?.summary_fields?.credentials || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,38 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
import InventoryStep from './InventoryStep';
|
||||
import StepName from './StepName';
|
||||
|
||||
const STEP_ID = 'inventory';
|
||||
|
||||
export default function useInventoryStep(config, resource, visitedSteps, i18n) {
|
||||
const [stepErrors, setStepErrors] = useState({});
|
||||
|
||||
const validate = values => {
|
||||
if (
|
||||
!config.ask_inventory_on_launch ||
|
||||
(['workflow_job', 'workflow_job_template'].includes(resource.type) &&
|
||||
!resource.inventory)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const errors = {};
|
||||
if (!values.inventory) {
|
||||
errors.inventory = i18n._(t`An inventory must be selected`);
|
||||
}
|
||||
setStepErrors(errors);
|
||||
return errors;
|
||||
};
|
||||
|
||||
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
|
||||
export default function useInventoryStep(config, visitedSteps, i18n) {
|
||||
const [, meta] = useField('inventory');
|
||||
|
||||
return {
|
||||
step: getStep(config, hasErrors, i18n),
|
||||
initialValues: getInitialValues(config, resource),
|
||||
validate,
|
||||
step: getStep(config, meta, i18n, visitedSteps),
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError: stepErrors,
|
||||
formError: !meta.value,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
inventory: true,
|
||||
@@ -40,23 +21,24 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getStep(config, hasErrors, i18n) {
|
||||
function getStep(config, meta, i18n, visitedSteps) {
|
||||
if (!config.ask_inventory_on_launch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: STEP_ID,
|
||||
name: <StepName hasErrors={hasErrors}>{i18n._(t`Inventory`)}</StepName>,
|
||||
key: 3,
|
||||
name: (
|
||||
<StepName
|
||||
hasErrors={
|
||||
Object.keys(visitedSteps).includes(STEP_ID) &&
|
||||
(!meta.value || meta.error)
|
||||
}
|
||||
>
|
||||
{i18n._(t`Inventory`)}
|
||||
</StepName>
|
||||
),
|
||||
component: <InventoryStep i18n={i18n} />,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialValues(config, resource) {
|
||||
if (!config.ask_inventory_on_launch) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
inventory: resource?.summary_fields?.inventory || null,
|
||||
enableNext: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,31 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import OtherPromptsStep from './OtherPromptsStep';
|
||||
import StepName from './StepName';
|
||||
|
||||
const STEP_ID = 'other';
|
||||
|
||||
export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
|
||||
const [stepErrors, setStepErrors] = useState({});
|
||||
|
||||
const validate = values => {
|
||||
const errors = {};
|
||||
if (config.ask_job_type_on_launch && !values.job_type) {
|
||||
errors.job_type = i18n._(t`This field must not be blank`);
|
||||
}
|
||||
setStepErrors(errors);
|
||||
return errors;
|
||||
};
|
||||
|
||||
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
|
||||
|
||||
export default function useOtherPrompt(config, i18n) {
|
||||
return {
|
||||
step: getStep(config, hasErrors, i18n),
|
||||
initialValues: getInitialValues(config, resource),
|
||||
validate,
|
||||
step: getStep(config, i18n),
|
||||
isReady: true,
|
||||
contentError: null,
|
||||
formError: stepErrors,
|
||||
formError: null,
|
||||
setTouched: setFieldsTouched => {
|
||||
setFieldsTouched({
|
||||
job_type: true,
|
||||
@@ -40,13 +24,13 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
|
||||
};
|
||||
}
|
||||
|
||||
function getStep(config, hasErrors, i18n) {
|
||||
function getStep(config, i18n) {
|
||||
if (!shouldShowPrompt(config)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: STEP_ID,
|
||||
name: <StepName hasErrors={hasErrors}>{i18n._(t`Other Prompts`)}</StepName>,
|
||||
name: i18n._(t`Other Prompts`),
|
||||
component: <OtherPromptsStep config={config} i18n={i18n} />,
|
||||
};
|
||||
}
|
||||
@@ -63,32 +47,3 @@ function shouldShowPrompt(config) {
|
||||
config.ask_diff_mode_on_launch
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialValues(config, resource) {
|
||||
const initialValues = {};
|
||||
if (config.ask_job_type_on_launch) {
|
||||
initialValues.job_type = resource.job_type || '';
|
||||
}
|
||||
if (config.ask_limit_on_launch) {
|
||||
initialValues.limit = resource.limit || '';
|
||||
}
|
||||
if (config.ask_verbosity_on_launch) {
|
||||
initialValues.verbosity = resource.verbosity || 0;
|
||||
}
|
||||
if (config.ask_tags_on_launch) {
|
||||
initialValues.job_tags = resource.job_tags || '';
|
||||
}
|
||||
if (config.ask_skip_tags_on_launch) {
|
||||
initialValues.skip_tags = resource.skip_tags || '';
|
||||
}
|
||||
if (config.ask_variables_on_launch) {
|
||||
initialValues.extra_vars = resource.extra_vars || '---';
|
||||
}
|
||||
if (config.ask_scm_branch_on_launch) {
|
||||
initialValues.scm_branch = resource.scm_branch || '';
|
||||
}
|
||||
if (config.ask_diff_mode_on_launch) {
|
||||
initialValues.diff_mode = resource.diff_mode || false;
|
||||
}
|
||||
return initialValues;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { t } from '@lingui/macro';
|
||||
import PreviewStep from './PreviewStep';
|
||||
|
||||
@@ -8,9 +9,29 @@ export default function usePreviewStep(
|
||||
config,
|
||||
resource,
|
||||
survey,
|
||||
formErrors,
|
||||
hasErrors,
|
||||
i18n
|
||||
) {
|
||||
const { values: formikValues, errors } = useFormikContext();
|
||||
|
||||
const formErrorsContent = [];
|
||||
if (config.ask_inventory_on_launch && !formikValues.inventory) {
|
||||
formErrorsContent.push({
|
||||
inventory: true,
|
||||
});
|
||||
}
|
||||
const hasSurveyError = Object.keys(errors).find(e => e.includes('survey'));
|
||||
if (
|
||||
config.survey_enabled &&
|
||||
(config.variables_needed_to_start ||
|
||||
config.variables_needed_to_start.length === 0) &&
|
||||
hasSurveyError
|
||||
) {
|
||||
formErrorsContent.push({
|
||||
survey: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
step: {
|
||||
id: STEP_ID,
|
||||
@@ -20,14 +41,13 @@ export default function usePreviewStep(
|
||||
config={config}
|
||||
resource={resource}
|
||||
survey={survey}
|
||||
formErrors={formErrors}
|
||||
formErrors={hasErrors}
|
||||
/>
|
||||
),
|
||||
enableNext: Object.keys(formErrors).length === 0,
|
||||
enableNext: !hasErrors,
|
||||
nextButtonText: i18n._(t`Launch`),
|
||||
},
|
||||
initialValues: {},
|
||||
validate: () => ({}),
|
||||
isReady: true,
|
||||
error: null,
|
||||
setTouched: () => {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useFormikContext } from 'formik';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
|
||||
import SurveyStep from './SurveyStep';
|
||||
@@ -7,27 +8,27 @@ import StepName from './StepName';
|
||||
|
||||
const STEP_ID = 'survey';
|
||||
|
||||
export default function useSurveyStep(config, resource, visitedSteps, i18n) {
|
||||
const [stepErrors, setStepErrors] = useState({});
|
||||
|
||||
export default function useSurveyStep(config, visitedSteps, i18n) {
|
||||
const { values } = useFormikContext();
|
||||
const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
|
||||
useCallback(async () => {
|
||||
if (!config.survey_enabled) {
|
||||
return {};
|
||||
}
|
||||
const { data } =
|
||||
resource.type === 'workflow_job_template'
|
||||
? await WorkflowJobTemplatesAPI.readSurvey(resource.id)
|
||||
: await JobTemplatesAPI.readSurvey(resource.id);
|
||||
const { data } = config?.workflow_job_template_data
|
||||
? await WorkflowJobTemplatesAPI.readSurvey(
|
||||
config?.workflow_job_template_data?.id
|
||||
)
|
||||
: await JobTemplatesAPI.readSurvey(config?.job_template_data?.id);
|
||||
return data;
|
||||
}, [config.survey_enabled, resource])
|
||||
}, [config])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSurvey();
|
||||
}, [fetchSurvey]);
|
||||
|
||||
const validate = values => {
|
||||
const validate = () => {
|
||||
if (!config.survey_enabled || !survey || !survey.spec) {
|
||||
return {};
|
||||
}
|
||||
@@ -42,20 +43,16 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) {
|
||||
errors[`survey_${question.variable}`] = errMessage;
|
||||
}
|
||||
});
|
||||
setStepErrors(errors);
|
||||
return errors;
|
||||
};
|
||||
|
||||
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
|
||||
|
||||
const formError = Object.keys(validate()).length > 0;
|
||||
return {
|
||||
step: getStep(config, survey, hasErrors, i18n),
|
||||
step: getStep(config, survey, formError, i18n, visitedSteps),
|
||||
formError,
|
||||
initialValues: getInitialValues(config, survey),
|
||||
validate,
|
||||
survey,
|
||||
isReady: !isLoading && !!survey,
|
||||
contentError: error,
|
||||
formError: stepErrors,
|
||||
setTouched: setFieldsTouched => {
|
||||
if (!survey || !survey.spec) {
|
||||
return;
|
||||
@@ -87,34 +84,49 @@ function validateField(question, value, i18n) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (question.required && !value && value !== 0) {
|
||||
if (
|
||||
question.required &&
|
||||
((!value && value !== 0) || (Array.isArray(value) && value.length === 0))
|
||||
) {
|
||||
return i18n._(t`This field must not be blank`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStep(config, survey, hasErrors, i18n) {
|
||||
function getStep(config, survey, hasErrors, i18n, visitedSteps) {
|
||||
if (!config.survey_enabled) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: STEP_ID,
|
||||
name: <StepName hasErrors={hasErrors}>{i18n._(t`Survey`)}</StepName>,
|
||||
key: 6,
|
||||
name: (
|
||||
<StepName
|
||||
hasErrors={Object.keys(visitedSteps).includes(STEP_ID) && hasErrors}
|
||||
>
|
||||
{i18n._(t`Survey`)}
|
||||
</StepName>
|
||||
),
|
||||
component: <SurveyStep survey={survey} i18n={i18n} />,
|
||||
enableNext: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialValues(config, survey) {
|
||||
if (!config.survey_enabled || !survey) {
|
||||
return {};
|
||||
}
|
||||
const values = {};
|
||||
const surveyValues = {};
|
||||
survey.spec.forEach(question => {
|
||||
if (question.type === 'multiselect') {
|
||||
values[`survey_${question.variable}`] = question.default.split('\n');
|
||||
if (question.default === '') {
|
||||
surveyValues[`survey_${question.variable}`] = [];
|
||||
} else {
|
||||
surveyValues[`survey_${question.variable}`] = question.default.split(
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
values[`survey_${question.variable}`] = question.default;
|
||||
surveyValues[`survey_${question.variable}`] = question.default;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
return surveyValues;
|
||||
}
|
||||
|
||||
69
awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js
Normal file
69
awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useFormikContext } from 'formik';
|
||||
import useInventoryStep from './steps/useInventoryStep';
|
||||
import useCredentialsStep from './steps/useCredentialsStep';
|
||||
import useOtherPromptsStep from './steps/useOtherPromptsStep';
|
||||
import useSurveyStep from './steps/useSurveyStep';
|
||||
import usePreviewStep from './steps/usePreviewStep';
|
||||
|
||||
export default function useLaunchSteps(config, resource, i18n) {
|
||||
const [visited, setVisited] = useState({});
|
||||
const steps = [
|
||||
useInventoryStep(config, visited, i18n),
|
||||
useCredentialsStep(config, i18n),
|
||||
useOtherPromptsStep(config, i18n),
|
||||
useSurveyStep(config, visited, i18n),
|
||||
];
|
||||
const { resetForm, values: formikValues } = useFormikContext();
|
||||
const hasErrors = steps.some(step => step.formError);
|
||||
|
||||
const surveyStepIndex = steps.findIndex(step => step.survey);
|
||||
steps.push(
|
||||
usePreviewStep(
|
||||
config,
|
||||
resource,
|
||||
steps[surveyStepIndex]?.survey,
|
||||
hasErrors,
|
||||
i18n
|
||||
)
|
||||
);
|
||||
|
||||
const pfSteps = steps.map(s => s.step).filter(s => s != null);
|
||||
const isReady = !steps.some(s => !s.isReady);
|
||||
|
||||
useEffect(() => {
|
||||
if (surveyStepIndex > -1 && isReady) {
|
||||
resetForm({
|
||||
values: {
|
||||
...formikValues,
|
||||
...steps[surveyStepIndex].initialValues,
|
||||
},
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isReady]);
|
||||
|
||||
const stepWithError = steps.find(s => s.contentError);
|
||||
const contentError = stepWithError ? stepWithError.contentError : null;
|
||||
|
||||
return {
|
||||
steps: pfSteps,
|
||||
isReady,
|
||||
visitStep: stepId =>
|
||||
setVisited({
|
||||
...visited,
|
||||
[stepId]: true,
|
||||
}),
|
||||
visitAllSteps: setFieldsTouched => {
|
||||
setVisited({
|
||||
inventory: true,
|
||||
credentials: true,
|
||||
other: true,
|
||||
survey: true,
|
||||
preview: true,
|
||||
});
|
||||
steps.forEach(s => s.setTouched(setFieldsTouched));
|
||||
},
|
||||
contentError,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import useInventoryStep from './steps/useInventoryStep';
|
||||
import useCredentialsStep from './steps/useCredentialsStep';
|
||||
import useOtherPromptsStep from './steps/useOtherPromptsStep';
|
||||
import useSurveyStep from './steps/useSurveyStep';
|
||||
import usePreviewStep from './steps/usePreviewStep';
|
||||
|
||||
export default function useSteps(config, resource, i18n) {
|
||||
const [visited, setVisited] = useState({});
|
||||
const steps = [
|
||||
useInventoryStep(config, resource, visited, i18n),
|
||||
useCredentialsStep(config, resource, visited, i18n),
|
||||
useOtherPromptsStep(config, resource, visited, i18n),
|
||||
useSurveyStep(config, resource, visited, i18n),
|
||||
];
|
||||
|
||||
const formErrorsContent = steps
|
||||
.filter(s => s?.formError && Object.keys(s.formError).length > 0)
|
||||
.map(({ formError }) => formError);
|
||||
|
||||
steps.push(
|
||||
usePreviewStep(config, resource, steps[3].survey, formErrorsContent, i18n)
|
||||
);
|
||||
|
||||
const pfSteps = steps.map(s => s.step).filter(s => s != null);
|
||||
const initialValues = steps.reduce((acc, cur) => {
|
||||
return {
|
||||
...acc,
|
||||
...cur.initialValues,
|
||||
};
|
||||
}, {});
|
||||
const isReady = !steps.some(s => !s.isReady);
|
||||
|
||||
const stepWithError = steps.find(s => s.contentError);
|
||||
const contentError = stepWithError ? stepWithError.contentError : null;
|
||||
|
||||
const validate = values => {
|
||||
const errors = steps.reduce((acc, cur) => {
|
||||
return {
|
||||
...acc,
|
||||
...cur.validate(values),
|
||||
};
|
||||
}, {});
|
||||
if (Object.keys(errors).length) {
|
||||
return errors;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
steps: pfSteps,
|
||||
initialValues,
|
||||
isReady,
|
||||
validate,
|
||||
visitStep: stepId => setVisited({ ...visited, [stepId]: true }),
|
||||
visitAllSteps: setFieldsTouched => {
|
||||
setVisited({
|
||||
inventory: true,
|
||||
credentials: true,
|
||||
other: true,
|
||||
survey: true,
|
||||
preview: true,
|
||||
});
|
||||
steps.forEach(s => s.setTouched(setFieldsTouched));
|
||||
},
|
||||
contentError,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { string, func } from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||
import CaretDownIcon from '@patternfly/react-icons/dist/js/icons/caret-down-icon';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
||||
@@ -12,6 +13,7 @@ function ToolbarAddButton({
|
||||
i18n,
|
||||
isDisabled,
|
||||
defaultLabel = i18n._(t`Add`),
|
||||
showToggleIndicator,
|
||||
}) {
|
||||
const { isKebabified } = useKebabifiedMenu();
|
||||
|
||||
@@ -50,7 +52,13 @@ function ToolbarAddButton({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button variant="primary" aria-label={defaultLabel} onClick={onClick}>
|
||||
<Button
|
||||
icon={showToggleIndicator ? <CaretDownIcon /> : null}
|
||||
iconPosition={showToggleIndicator ? 'right' : null}
|
||||
variant="primary"
|
||||
aria-label={defaultLabel}
|
||||
onClick={onClick}
|
||||
>
|
||||
{defaultLabel}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -18,4 +18,13 @@ describe('<ToolbarAddButton />', () => {
|
||||
expect(link).toHaveLength(1);
|
||||
expect(link.prop('to')).toBe('/foo');
|
||||
});
|
||||
|
||||
test('should render link with toggle icon', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ToolbarAddButton showToggleIndicator linkTo="/foo" />
|
||||
);
|
||||
const link = wrapper.find('Link');
|
||||
expect(link).toHaveLength(1);
|
||||
expect(link.prop('to')).toBe('/foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ function ToolbarDeleteButton({
|
||||
const renderTooltip = () => {
|
||||
const itemsUnableToDelete = itemsToDelete
|
||||
.filter(cannotDelete)
|
||||
.map(item => item.name)
|
||||
.map(item => item.name || item.username)
|
||||
.join(', ');
|
||||
if (itemsToDelete.some(cannotDelete)) {
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,11 @@ const itemB = {
|
||||
name: 'Foo',
|
||||
summary_fields: { user_capabilities: { delete: false } },
|
||||
};
|
||||
const itemC = {
|
||||
id: 1,
|
||||
username: 'Foo',
|
||||
summary_fields: { user_capabilities: { delete: false } },
|
||||
};
|
||||
|
||||
describe('<ToolbarDeleteButton />', () => {
|
||||
test('should render button', () => {
|
||||
@@ -61,4 +66,14 @@ describe('<ToolbarDeleteButton />', () => {
|
||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||
expect(wrapper.find('Tooltip').prop('content')).toEqual('Delete');
|
||||
});
|
||||
|
||||
test('should render tooltip for username', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemC]} />
|
||||
);
|
||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||
expect(wrapper.find('Tooltip').prop('content').props.children).toEqual(
|
||||
'You do not have permission to delete Items: Foo'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ function PromptJobTemplateDetail({ i18n, resource }) {
|
||||
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} />
|
||||
<Detail
|
||||
label={i18n._(t`Show Changes`)}
|
||||
value={diff_mode ? 'On' : 'Off'}
|
||||
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||
/>
|
||||
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
|
||||
<Detail label={i18n._(t`Host Config Key`)} value={host_config_key} />
|
||||
|
||||
@@ -74,6 +74,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
/>
|
||||
@@ -86,6 +87,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@@ -138,6 +140,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
/>
|
||||
@@ -150,6 +153,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@@ -225,6 +229,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
/>
|
||||
@@ -237,6 +242,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@@ -447,6 +453,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Name"
|
||||
value="jane brown"
|
||||
>
|
||||
@@ -463,9 +470,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-htpNat",
|
||||
"componentId": "sc-bxivhb",
|
||||
"isStatic": false,
|
||||
"lastClassName": "iYJcPm",
|
||||
"lastClassName": "gQwVdc",
|
||||
"rules": Array [
|
||||
"
|
||||
font-weight: var(--pf-global--FontWeight--bold);
|
||||
@@ -478,7 +485,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-htpNat",
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@@ -489,18 +496,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
>
|
||||
<dt
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@@ -523,9 +530,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bxivhb",
|
||||
"componentId": "sc-ifAKCX",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gxmPlV",
|
||||
"lastClassName": "boHWLt",
|
||||
"rules": Array [
|
||||
"
|
||||
word-break: break-all;
|
||||
@@ -541,7 +548,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"styledComponentId": "sc-ifAKCX",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@@ -552,18 +559,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
>
|
||||
<dd
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@@ -670,6 +677,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
<Detail
|
||||
alwaysVisible={false}
|
||||
fullWidth={false}
|
||||
helpText={null}
|
||||
label="Team Roles"
|
||||
value={
|
||||
<WithI18n
|
||||
@@ -703,9 +711,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-htpNat",
|
||||
"componentId": "sc-bxivhb",
|
||||
"isStatic": false,
|
||||
"lastClassName": "iYJcPm",
|
||||
"lastClassName": "gQwVdc",
|
||||
"rules": Array [
|
||||
"
|
||||
font-weight: var(--pf-global--FontWeight--bold);
|
||||
@@ -718,7 +726,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-htpNat",
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@@ -729,18 +737,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
component="dt"
|
||||
data-cy={null}
|
||||
>
|
||||
<dt
|
||||
className="sc-htpNat iYJcPm"
|
||||
className="sc-bxivhb gQwVdc"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
@@ -763,9 +771,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"$$typeof": Symbol(react.forward_ref),
|
||||
"attrs": Array [],
|
||||
"componentStyle": ComponentStyle {
|
||||
"componentId": "sc-bxivhb",
|
||||
"componentId": "sc-ifAKCX",
|
||||
"isStatic": false,
|
||||
"lastClassName": "gxmPlV",
|
||||
"lastClassName": "boHWLt",
|
||||
"rules": Array [
|
||||
"
|
||||
word-break: break-all;
|
||||
@@ -781,7 +789,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
"displayName": "Styled(Component)",
|
||||
"foldedComponentIds": Array [],
|
||||
"render": [Function],
|
||||
"styledComponentId": "sc-bxivhb",
|
||||
"styledComponentId": "sc-ifAKCX",
|
||||
"target": [Function],
|
||||
"toString": [Function],
|
||||
"warnTooManyClasses": [Function],
|
||||
@@ -792,18 +800,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
|
||||
fullWidth={false}
|
||||
>
|
||||
<Component
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
fullWidth={false}
|
||||
>
|
||||
<TextListItem
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
component="dd"
|
||||
data-cy={null}
|
||||
>
|
||||
<dd
|
||||
className="sc-bxivhb gxmPlV"
|
||||
className="sc-ifAKCX boHWLt"
|
||||
data-cy={null}
|
||||
data-pf-content={true}
|
||||
>
|
||||
|
||||
@@ -32,11 +32,12 @@ function RoutedTabs(props) {
|
||||
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
|
||||
{tabsArray.map(tab => (
|
||||
<Tab
|
||||
aria-label={`${tab.name}`}
|
||||
aria-label={typeof tab.name === 'string' ? tab.name : ''}
|
||||
eventKey={tab.id}
|
||||
key={tab.id}
|
||||
link={tab.link}
|
||||
title={<TabTitleText>{tab.name}</TabTitleText>}
|
||||
role="tab"
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
@@ -225,7 +225,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
{ask_diff_mode_on_launch && typeof diff_mode === 'boolean' && (
|
||||
<Detail
|
||||
label={i18n._(t`Show Changes`)}
|
||||
value={diff_mode ? 'On' : 'Off'}
|
||||
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||
/>
|
||||
)}
|
||||
{ask_credential_on_launch && (
|
||||
|
||||
@@ -93,42 +93,43 @@ SkippedBottom.displayName = 'SkippedBottom';
|
||||
|
||||
const StatusIcon = ({ status, ...props }) => {
|
||||
return (
|
||||
<div {...props} data-job-status={status}>
|
||||
{status === 'running' && <RunningJob />}
|
||||
<div {...props} data-job-status={status} aria-label={status}>
|
||||
{status === 'running' && <RunningJob aria-hidden="true" />}
|
||||
{(status === 'new' ||
|
||||
status === 'pending' ||
|
||||
status === 'waiting' ||
|
||||
status === 'never updated') && <WaitingJob />}
|
||||
status === 'never updated') && <WaitingJob aria-hidden="true" />}
|
||||
{(status === 'failed' || status === 'error' || status === 'canceled') && (
|
||||
<FinishedJob>
|
||||
<FinishedJob aria-hidden="true">
|
||||
<FailedTop />
|
||||
<FailedBottom />
|
||||
</FinishedJob>
|
||||
)}
|
||||
{(status === 'successful' || status === 'ok') && (
|
||||
<FinishedJob>
|
||||
<FinishedJob aria-hidden="true">
|
||||
<SuccessfulTop />
|
||||
<SuccessfulBottom />
|
||||
</FinishedJob>
|
||||
)}
|
||||
{status === 'changed' && (
|
||||
<FinishedJob>
|
||||
<FinishedJob aria-hidden="true">
|
||||
<ChangedTop />
|
||||
<ChangedBottom />
|
||||
</FinishedJob>
|
||||
)}
|
||||
{status === 'skipped' && (
|
||||
<FinishedJob>
|
||||
<FinishedJob aria-hidden="true">
|
||||
<SkippedTop />
|
||||
<SkippedBottom />
|
||||
</FinishedJob>
|
||||
)}
|
||||
{status === 'unreachable' && (
|
||||
<FinishedJob>
|
||||
<FinishedJob aria-hidden="true">
|
||||
<UnreachableTop />
|
||||
<UnreachableBottom />
|
||||
</FinishedJob>
|
||||
)}
|
||||
<span className="pf-screen-reader"> {status} </span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,13 +29,21 @@ export default function SyncStatusIndicator({ status, title }) {
|
||||
|
||||
if (status === 'syncing') {
|
||||
return (
|
||||
<PulseWrapper>
|
||||
<CloudIcon color={`var(${color})`} title={title} />
|
||||
</PulseWrapper>
|
||||
<>
|
||||
<PulseWrapper aria-hidden="true">
|
||||
<CloudIcon color={`var(${color})`} title={title} />
|
||||
</PulseWrapper>
|
||||
<span className="pf-screen-reader">{status}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <CloudIcon color={`var(${color})`} title={title} />;
|
||||
return (
|
||||
<>
|
||||
<CloudIcon color={`var(${color})`} title={title} aria-hidden="true" />
|
||||
<span className="pf-screen-reader">{status}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
SyncStatusIndicator.propTypes = {
|
||||
status: oneOf(['success', 'error', 'disabled', 'syncing']).isRequired,
|
||||
|
||||
6
awx/ui_next/src/contexts/Settings.jsx
Normal file
6
awx/ui_next/src/contexts/Settings.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
export const SettingsContext = React.createContext({});
|
||||
export const SettingsProvider = SettingsContext.Provider;
|
||||
|
||||
export const useSettings = () => useContext(SettingsContext);
|
||||
@@ -48,15 +48,17 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
|
||||
value={i18n._(t`Container group`)}
|
||||
dataCy="container-group-type"
|
||||
/>
|
||||
<Detail
|
||||
label={i18n._(t`Credential`)}
|
||||
value={
|
||||
<Label variant="outline" color="blue">
|
||||
{instanceGroup.summary_fields.credential.name}
|
||||
</Label>
|
||||
}
|
||||
dataCy="container-group-credential"
|
||||
/>
|
||||
{instanceGroup.summary_fields.credential && (
|
||||
<Detail
|
||||
label={i18n._(t`Credential`)}
|
||||
value={
|
||||
<Label variant="outline" color="blue">
|
||||
{instanceGroup.summary_fields.credential?.name}
|
||||
</Label>
|
||||
}
|
||||
dataCy="container-group-credential"
|
||||
/>
|
||||
)}
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
date={instanceGroup.created}
|
||||
|
||||
@@ -23,9 +23,11 @@ import { VariablesField } from '../../../components/CodeMirrorInput';
|
||||
|
||||
function ContainerGroupFormFields({ i18n, instanceGroup }) {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const [overrideField] = useField('override');
|
||||
|
||||
const onCredentialChange = useCallback(
|
||||
|
||||
@@ -28,12 +28,21 @@ const instanceGroup = {
|
||||
is_controller: false,
|
||||
is_isolated: false,
|
||||
is_containerized: false,
|
||||
credential: null,
|
||||
credential: 3,
|
||||
policy_instance_percentage: 46,
|
||||
policy_instance_minimum: 12,
|
||||
policy_instance_list: [],
|
||||
pod_spec_override: '',
|
||||
summary_fields: {
|
||||
credential: {
|
||||
id: 3,
|
||||
name: 'test',
|
||||
description: 'Simple one',
|
||||
kind: 'kubernetes_bearer_token',
|
||||
cloud: false,
|
||||
kubernetes: true,
|
||||
credential_type_id: 17,
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
delete: true,
|
||||
@@ -101,7 +110,7 @@ describe('<ContainerGroupForm/>', () => {
|
||||
.find('Checkbox[aria-label="Customize pod specification"]')
|
||||
.prop('isChecked')
|
||||
).toBeFalsy();
|
||||
expect(wrapper.find('CredentialLookup').prop('value')).toBeFalsy();
|
||||
expect(wrapper.find('CredentialLookup').prop('value').name).toBe('test');
|
||||
});
|
||||
|
||||
test('should update form values', () => {
|
||||
|
||||
@@ -2,8 +2,14 @@ import React, { useEffect, useCallback, useState } from 'react';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
DropdownItem,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs';
|
||||
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
||||
import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
|
||||
|
||||
import useRequest, {
|
||||
useDeleteItems,
|
||||
@@ -16,6 +22,8 @@ import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||
import AssociateModal from '../../../components/AssociateModal';
|
||||
import DisassociateButton from '../../../components/DisassociateButton';
|
||||
import { Kebabified } from '../../../contexts/Kebabified';
|
||||
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
||||
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
|
||||
import AddHostDropdown from './AddHostDropdown';
|
||||
|
||||
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('host', {
|
||||
|
||||
function InventoryGroupHostList({ i18n }) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
|
||||
const { id: inventoryId, groupId } = useParams();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
@@ -38,6 +47,9 @@ function InventoryGroupHostList({ i18n }) {
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
moduleOptions,
|
||||
credentialTypeId,
|
||||
isAdHocDisabled,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
@@ -45,9 +57,16 @@ function InventoryGroupHostList({ i18n }) {
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
const [
|
||||
response,
|
||||
actionsResponse,
|
||||
adHocOptions,
|
||||
cred,
|
||||
] = await Promise.all([
|
||||
GroupsAPI.readAllHosts(groupId, params),
|
||||
InventoriesAPI.readHostsOptions(inventoryId),
|
||||
InventoriesAPI.readAdHocOptions(inventoryId),
|
||||
CredentialTypesAPI.read({ namespace: 'ssh' }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -60,6 +79,9 @@ function InventoryGroupHostList({ i18n }) {
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
|
||||
credentialTypeId: cred.data.results[0].id,
|
||||
isAdHocDisabled: !adHocOptions.data.actions.POST,
|
||||
};
|
||||
}, [groupId, inventoryId, location.search]),
|
||||
{
|
||||
@@ -68,6 +90,8 @@ function InventoryGroupHostList({ i18n }) {
|
||||
actions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
moduleOptions: [],
|
||||
isAdHocDisabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -195,6 +219,40 @@ function InventoryGroupHostList({ i18n }) {
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<Kebabified>
|
||||
{({ isKebabified }) =>
|
||||
isKebabified ? (
|
||||
<DropdownItem
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={hostCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<ToolbarItem>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Select an inventory source by clicking the check box beside it.
|
||||
The inventory source can be a single host or a selection of multiple hosts.`
|
||||
)}
|
||||
position="top"
|
||||
key="adhoc"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={hostCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
)
|
||||
}
|
||||
</Kebabified>,
|
||||
<DisassociateButton
|
||||
key="disassociate"
|
||||
onDisassociate={handleDisassociate}
|
||||
@@ -222,6 +280,7 @@ function InventoryGroupHostList({ i18n }) {
|
||||
emptyStateControls={
|
||||
canAdd && (
|
||||
<AddHostDropdown
|
||||
key="associate"
|
||||
onAddExisting={() => setIsModalOpen(true)}
|
||||
onAddNew={() => history.push(addFormUrl)}
|
||||
/>
|
||||
@@ -239,6 +298,16 @@ function InventoryGroupHostList({ i18n }) {
|
||||
title={i18n._(t`Select Hosts`)}
|
||||
/>
|
||||
)}
|
||||
{isAdHocCommandsOpen && (
|
||||
<AdHocCommands
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
itemId={parseInt(inventoryId, 10)}
|
||||
onClose={() => setIsAdHocCommandsOpen(false)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
)}
|
||||
{associateError && (
|
||||
<AlertModal
|
||||
isOpen={associateError}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { GroupsAPI, InventoriesAPI } from '../../../api';
|
||||
import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@@ -11,6 +11,7 @@ import mockHosts from '../shared/data.hosts.json';
|
||||
|
||||
jest.mock('../../../api/models/Groups');
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/CredentialTypes');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
@@ -34,6 +35,17 @@ describe('<InventoryGroupHostList />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupHostList />);
|
||||
});
|
||||
@@ -95,6 +107,29 @@ describe('<InventoryGroupHostList />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should render enabled ad hoc commands button', async () => {
|
||||
GroupsAPI.readAllHosts.mockResolvedValue({
|
||||
data: { ...mockHosts },
|
||||
});
|
||||
InventoriesAPI.readHostsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupHostList />);
|
||||
});
|
||||
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'button[aria-label="Run command"]',
|
||||
el => el.prop('disabled') === false
|
||||
);
|
||||
});
|
||||
|
||||
test('should show add dropdown button according to permissions', async () => {
|
||||
expect(wrapper.find('AddHostDropdown').length).toBe(1);
|
||||
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { InventoriesAPI, GroupsAPI } from '../../../api';
|
||||
import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
@@ -22,7 +22,8 @@ import PaginatedDataList, {
|
||||
|
||||
import InventoryGroupItem from './InventoryGroupItem';
|
||||
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
|
||||
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
|
||||
|
||||
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
||||
import { Kebabified } from '../../../contexts/Kebabified';
|
||||
|
||||
const QS_CONFIG = getQSConfig('group', {
|
||||
@@ -51,6 +52,7 @@ const useModal = () => {
|
||||
function InventoryGroupsList({ i18n }) {
|
||||
const [deletionError, setDeletionError] = useState(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { isModalOpen, toggleModal } = useModal();
|
||||
const { id: inventoryId } = useParams();
|
||||
@@ -62,27 +64,36 @@ function InventoryGroupsList({ i18n }) {
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
moduleOptions,
|
||||
credentialTypeId,
|
||||
isAdHocDisabled,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchGroups,
|
||||
request: fetchData,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
const [response, groupOptions, adHocOptions, cred] = await Promise.all([
|
||||
InventoriesAPI.readGroups(inventoryId, params),
|
||||
InventoriesAPI.readGroupsOptions(inventoryId),
|
||||
InventoriesAPI.readAdHocOptions(inventoryId),
|
||||
CredentialTypesAPI.read({ namespace: 'ssh' }),
|
||||
]);
|
||||
|
||||
return {
|
||||
groups: response.data.results,
|
||||
groupCount: response.data.count,
|
||||
actions: actionsResponse.data.actions,
|
||||
actions: groupOptions.data.actions,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
groupOptions?.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),
|
||||
groupOptions.data.actions?.GET || {}
|
||||
).filter(key => groupOptions.data.actions?.GET[key].filterable),
|
||||
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
|
||||
credentialTypeId: cred.data.results[0].id,
|
||||
isAdHocDisabled: !adHocOptions.data.actions.POST,
|
||||
};
|
||||
}, [inventoryId, location]),
|
||||
{
|
||||
@@ -95,8 +106,8 @@ function InventoryGroupsList({ i18n }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroups();
|
||||
}, [fetchGroups]);
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
|
||||
groups
|
||||
@@ -144,7 +155,7 @@ function InventoryGroupsList({ i18n }) {
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
fetchGroups();
|
||||
fetchData();
|
||||
setSelected([]);
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
@@ -153,21 +164,14 @@ function InventoryGroupsList({ i18n }) {
|
||||
const kebabedAdditionalControls = () => {
|
||||
return (
|
||||
<>
|
||||
<AdHocCommandsButton
|
||||
adHocItems={selected}
|
||||
apiModule={InventoriesAPI}
|
||||
itemId={parseInt(inventoryId, 10)}
|
||||
<DropdownItem
|
||||
key="run command"
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={groupCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{({ openAdHocCommands }) => (
|
||||
<DropdownItem
|
||||
key="run command"
|
||||
onClick={openAdHocCommands}
|
||||
isDisabled={groupCount === 0}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</AdHocCommandsButton>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
aria-label={i18n._(t`Delete`)}
|
||||
@@ -264,23 +268,14 @@ function InventoryGroupsList({ i18n }) {
|
||||
position="top"
|
||||
key="adhoc"
|
||||
>
|
||||
<AdHocCommandsButton
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
apiModule={InventoriesAPI}
|
||||
itemId={parseInt(inventoryId, 10)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={groupCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{({ openAdHocCommands }) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={openAdHocCommands}
|
||||
isDisabled={groupCount === 0}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
)}
|
||||
</AdHocCommandsButton>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem>
|
||||
@@ -321,6 +316,16 @@ function InventoryGroupsList({ i18n }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{isAdHocCommandsOpen && (
|
||||
<AdHocCommands
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
itemId={parseInt(inventoryId, 10)}
|
||||
onClose={() => setIsAdHocCommandsOpen(false)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
)}
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { InventoriesAPI, GroupsAPI } from '../../../api';
|
||||
import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
|
||||
import InventoryGroupsList from './InventoryGroupsList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
@@ -71,6 +71,17 @@ describe('<InventoryGroupsList />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
|
||||
});
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/3/groups'],
|
||||
});
|
||||
@@ -147,31 +158,17 @@ describe('<InventoryGroupsList />', () => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
test('should render enabled ad hoc commands button', async () => {
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'button[aria-label="Run command"]',
|
||||
el => el.prop('disabled') === false
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('<InventoryGroupsList/> error handling', () => {
|
||||
let wrapper;
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should show content error if groups are not successfully fetched from api', async () => {
|
||||
InventoriesAPI.readGroups.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should show error modal when group is not successfully deleted from api', async () => {
|
||||
beforeEach(() => {
|
||||
InventoriesAPI.readGroups.mockResolvedValue({
|
||||
data: {
|
||||
count: mockGroups.length,
|
||||
@@ -197,7 +194,42 @@ describe('<InventoryGroupsList/> error handling', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should show content error if groups are not successfully fetched from api', async () => {
|
||||
InventoriesAPI.readGroups.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryGroupsList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
|
||||
});
|
||||
|
||||
test('should show error modal when group is not successfully deleted from api', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/3/groups'],
|
||||
});
|
||||
@@ -249,4 +281,27 @@ describe('<InventoryGroupsList/> error handling', () => {
|
||||
.invoke('onClose')();
|
||||
});
|
||||
});
|
||||
test('should render disabled ad hoc button', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/3/groups'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/inventories/inventory/:id/groups">
|
||||
<InventoryGroupsList />
|
||||
</Route>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(
|
||||
wrapper.find('button[aria-label="Run command"]').prop('disabled')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,19 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
DropdownItem,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
|
||||
import useRequest, {
|
||||
useDismissableError,
|
||||
useDeleteItems,
|
||||
} from '../../../util/useRequest';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import { HostsAPI, InventoriesAPI } from '../../../api';
|
||||
import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
@@ -17,6 +23,8 @@ import PaginatedDataList, {
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import AssociateModal from '../../../components/AssociateModal';
|
||||
import DisassociateButton from '../../../components/DisassociateButton';
|
||||
import { Kebabified } from '../../../contexts/Kebabified';
|
||||
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
||||
import InventoryHostGroupItem from './InventoryHostGroupItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('group', {
|
||||
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('group', {
|
||||
|
||||
function InventoryHostGroupsList({ i18n }) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
|
||||
const { hostId, id: invId } = useParams();
|
||||
const { search } = useLocation();
|
||||
|
||||
@@ -37,6 +46,9 @@ function InventoryHostGroupsList({ i18n }) {
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
moduleOptions,
|
||||
isAdHocDisabled,
|
||||
credentialTypeId,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
@@ -49,22 +61,29 @@ function InventoryHostGroupsList({ i18n }) {
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
actionsResponse,
|
||||
hostGroupOptions,
|
||||
adHocOptions,
|
||||
cred,
|
||||
] = await Promise.all([
|
||||
HostsAPI.readAllGroups(hostId, params),
|
||||
HostsAPI.readGroupsOptions(hostId),
|
||||
InventoriesAPI.readAdHocOptions(invId),
|
||||
CredentialTypesAPI.read({ namespace: 'ssh' }),
|
||||
]);
|
||||
|
||||
return {
|
||||
groups: results,
|
||||
itemCount: count,
|
||||
actions: actionsResponse.data.actions,
|
||||
actions: hostGroupOptions.data.actions,
|
||||
relatedSearchableKeys: (
|
||||
actionsResponse?.data?.related_search_fields || []
|
||||
hostGroupOptions?.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),
|
||||
hostGroupOptions.data.actions?.GET || {}
|
||||
).filter(key => hostGroupOptions.data.actions?.GET[key].filterable),
|
||||
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
|
||||
credentialTypeId: cred.data.results[0].id,
|
||||
isAdHocDisabled: !adHocOptions.data.actions.POST,
|
||||
};
|
||||
}, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps
|
||||
{
|
||||
@@ -73,6 +92,8 @@ function InventoryHostGroupsList({ i18n }) {
|
||||
actions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
moduleOptions: [],
|
||||
isAdHocDisabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -201,6 +222,40 @@ function InventoryHostGroupsList({ i18n }) {
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<Kebabified>
|
||||
{({ isKebabified }) =>
|
||||
isKebabified ? (
|
||||
<DropdownItem
|
||||
key="run command"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={itemCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<ToolbarItem>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single group or host, a selection of multiple hosts, or a selection of multiple groups.`
|
||||
)}
|
||||
position="top"
|
||||
key="adhoc"
|
||||
>
|
||||
<Button
|
||||
key="run command"
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={itemCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
)
|
||||
}
|
||||
</Kebabified>,
|
||||
<DisassociateButton
|
||||
key="disassociate"
|
||||
onDisassociate={handleDisassociate}
|
||||
@@ -208,8 +263,8 @@ function InventoryHostGroupsList({ i18n }) {
|
||||
modalTitle={i18n._(t`Disassociate group from host?`)}
|
||||
modalNote={i18n._(t`
|
||||
Note that you may still see the group in the list after
|
||||
disassociating if the host is also a member of that group’s
|
||||
children. This list shows all groups the host is associated
|
||||
disassociating if the host is also a member of that group’s
|
||||
children. This list shows all groups the host is associated
|
||||
with directly and indirectly.
|
||||
`)}
|
||||
/>,
|
||||
@@ -233,6 +288,16 @@ function InventoryHostGroupsList({ i18n }) {
|
||||
title={i18n._(t`Select Groups`)}
|
||||
/>
|
||||
)}
|
||||
{isAdHocCommandsOpen && (
|
||||
<AdHocCommands
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
itemId={parseInt(invId, 10)}
|
||||
onClose={() => setIsAdHocCommandsOpen(false)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import { HostsAPI, InventoriesAPI } from '../../../api';
|
||||
import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
|
||||
import InventoryHostGroupsList from './InventoryHostGroupsList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
@@ -80,6 +80,17 @@ describe('<InventoryHostGroupsList />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
|
||||
});
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
|
||||
});
|
||||
@@ -272,4 +283,11 @@ describe('<InventoryHostGroupsList />', () => {
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
|
||||
});
|
||||
test('should render enabled ad hoc commands button', async () => {
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'button[aria-label="Run command"]',
|
||||
el => el.prop('disabled') === false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
DropdownItem,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import { InventoriesAPI, HostsAPI } from '../../../api';
|
||||
|
||||
import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
@@ -12,6 +18,8 @@ import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import { Kebabified } from '../../../contexts/Kebabified';
|
||||
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
||||
import InventoryHostItem from './InventoryHostItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('host', {
|
||||
@@ -21,48 +29,64 @@ const QS_CONFIG = getQSConfig('host', {
|
||||
});
|
||||
|
||||
function InventoryHostList({ i18n }) {
|
||||
const [actions, setActions] = useState(null);
|
||||
const [contentError, setContentError] = useState(null);
|
||||
const [deletionError, setDeletionError] = useState(null);
|
||||
const [hostCount, setHostCount] = useState(0);
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const { id } = useParams();
|
||||
const { search } = useLocation();
|
||||
|
||||
const fetchHosts = (hostId, queryString) => {
|
||||
const params = parseQueryString(QS_CONFIG, queryString);
|
||||
return InventoriesAPI.readHosts(hostId, params);
|
||||
};
|
||||
const {
|
||||
result: {
|
||||
hosts,
|
||||
hostCount,
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
moduleOptions,
|
||||
credentialTypeId,
|
||||
isAdHocDisabled,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchData,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, search);
|
||||
const [response, hostOptions, adHocOptions, cred] = await Promise.all([
|
||||
InventoriesAPI.readHosts(id, params),
|
||||
InventoriesAPI.readHostsOptions(id),
|
||||
InventoriesAPI.readAdHocOptions(id),
|
||||
CredentialTypesAPI.read({ namespace: 'ssh' }),
|
||||
]);
|
||||
|
||||
return {
|
||||
hosts: response.data.results,
|
||||
hostCount: response.data.count,
|
||||
actions: hostOptions.data.actions,
|
||||
relatedSearchableKeys: (
|
||||
hostOptions?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter(
|
||||
key => hostOptions.data.actions?.GET[key].filterable
|
||||
),
|
||||
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
|
||||
credentialTypeId: cred.data.results[0].id,
|
||||
isAdHocDisabled: !adHocOptions.data.actions.POST,
|
||||
};
|
||||
}, [id, search]),
|
||||
{
|
||||
hosts: [],
|
||||
hostCount: 0,
|
||||
actions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
moduleOptions: [],
|
||||
isAdHocDisabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [
|
||||
{
|
||||
data: { count, results },
|
||||
},
|
||||
{
|
||||
data: { actions: optionActions },
|
||||
},
|
||||
] = await Promise.all([
|
||||
fetchHosts(id, search),
|
||||
InventoriesAPI.readOptions(),
|
||||
]);
|
||||
|
||||
setHosts(results);
|
||||
setHostCount(count);
|
||||
setActions(optionActions);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [id, search]);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSelectAll = isSelected => {
|
||||
setSelected(isSelected ? [...hosts] : []);
|
||||
@@ -75,30 +99,17 @@ function InventoryHostList({ i18n }) {
|
||||
setSelected(selected.concat(row));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const {
|
||||
isLoading: isDeleteLoading,
|
||||
deleteItems: deleteHosts,
|
||||
deletionError,
|
||||
clearDeletionError,
|
||||
} = useDeleteItems(
|
||||
useCallback(async () => {
|
||||
await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
|
||||
} catch (error) {
|
||||
setDeletionError(error);
|
||||
} finally {
|
||||
setSelected([]);
|
||||
try {
|
||||
const {
|
||||
data: { count, results },
|
||||
} = await fetchHosts(id, search);
|
||||
|
||||
setHosts(results);
|
||||
setHostCount(count);
|
||||
} catch (error) {
|
||||
setContentError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [selected]),
|
||||
{ qsConfig: QS_CONFIG, fetchItems: fetchData }
|
||||
);
|
||||
|
||||
const canAdd =
|
||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||
@@ -108,7 +119,7 @@ function InventoryHostList({ i18n }) {
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={hosts}
|
||||
itemCount={hostCount}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
@@ -133,6 +144,8 @@ function InventoryHostList({ i18n }) {
|
||||
isNumeric: true,
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
@@ -149,9 +162,43 @@ function InventoryHostList({ i18n }) {
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<Kebabified>
|
||||
{({ isKebabified }) =>
|
||||
isKebabified ? (
|
||||
<DropdownItem
|
||||
key="run command"
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={hostCount === 0 || isAdHocDisabled}
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<ToolbarItem>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
|
||||
)}
|
||||
position="top"
|
||||
key="adhoc"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
key="run command"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={hostCount === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
)
|
||||
}
|
||||
</Kebabified>,
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
onDelete={deleteHosts}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
/>,
|
||||
@@ -177,12 +224,22 @@ function InventoryHostList({ i18n }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
{isAdHocCommandsOpen && (
|
||||
<AdHocCommands
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
onClose={() => setIsAdHocCommandsOpen(false)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
moduleOptions={moduleOptions}
|
||||
itemId={id}
|
||||
/>
|
||||
)}
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={() => setDeletionError(null)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more hosts.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { InventoriesAPI, HostsAPI } from '../../../api';
|
||||
import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@@ -85,7 +85,7 @@ describe('<InventoryHostList />', () => {
|
||||
results: mockHosts,
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
InventoriesAPI.readHostsOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
@@ -93,6 +93,17 @@ describe('<InventoryHostList />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryHostList />);
|
||||
});
|
||||
@@ -265,8 +276,15 @@ describe('<InventoryHostList />', () => {
|
||||
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should render enabled ad hoc commands button', async () => {
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'button[aria-label="Run command"]',
|
||||
el => el.prop('disabled') === false
|
||||
);
|
||||
});
|
||||
test('should hide Add button for users without ability to POST', async () => {
|
||||
InventoriesAPI.readOptions.mockResolvedValueOnce({
|
||||
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
@@ -283,7 +301,7 @@ describe('<InventoryHostList />', () => {
|
||||
});
|
||||
|
||||
test('should show content error when api throws error on initial render', async () => {
|
||||
InventoriesAPI.readOptions.mockImplementation(() =>
|
||||
InventoriesAPI.readHostsOptions.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DataListItemRow,
|
||||
Label,
|
||||
Tooltip,
|
||||
Badge as PFBadge,
|
||||
} from '@patternfly/react-core';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import { t } from '@lingui/macro';
|
||||
@@ -29,6 +30,15 @@ const DataListAction = styled(_DataListAction)`
|
||||
grid-template-columns: repeat(2, 40px);
|
||||
`;
|
||||
|
||||
const Badge = styled(PFBadge)`
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const ListGroup = styled.div`
|
||||
margin-left: 8px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
function InventoryListItem({
|
||||
inventory,
|
||||
isSelected,
|
||||
@@ -102,6 +112,20 @@ function InventoryListItem({
|
||||
? i18n._(t`Smart Inventory`)
|
||||
: i18n._(t`Inventory`)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="groups-hosts-sources-counts">
|
||||
<ListGroup>
|
||||
{i18n._(t`Groups`)}
|
||||
<Badge isRead>{inventory.total_groups}</Badge>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
{i18n._(t`Hosts`)}
|
||||
<Badge isRead>{inventory.total_hosts}</Badge>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
{i18n._(t`Sources`)}
|
||||
<Badge isRead>{inventory.total_inventory_sources}</Badge>
|
||||
</ListGroup>
|
||||
</DataListCell>,
|
||||
inventory.pending_deletion && (
|
||||
<DataListCell alignRight isFilled={false} key="pending-delete">
|
||||
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
DropdownItem,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import PaginatedDataList from '../../../components/PaginatedDataList';
|
||||
import SmartInventoryHostListItem from './SmartInventoryHostListItem';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
|
||||
import { Inventory } from '../../../types';
|
||||
import { Kebabified } from '../../../contexts/Kebabified';
|
||||
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
|
||||
|
||||
const QS_CONFIG = getQSConfig('host', {
|
||||
page: 1,
|
||||
@@ -20,24 +27,35 @@ const QS_CONFIG = getQSConfig('host', {
|
||||
|
||||
function SmartInventoryHostList({ i18n, inventory }) {
|
||||
const location = useLocation();
|
||||
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
result: { hosts, count },
|
||||
result: { hosts, count, moduleOptions, credentialTypeId, isAdHocDisabled },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchHosts,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const { data } = await InventoriesAPI.readHosts(inventory.id, params);
|
||||
const [hostResponse, adHocOptions, cred] = await Promise.all([
|
||||
InventoriesAPI.readHosts(inventory.id, params),
|
||||
InventoriesAPI.readAdHocOptions(inventory.id),
|
||||
CredentialTypesAPI.read({ namespace: 'ssh' }),
|
||||
]);
|
||||
|
||||
return {
|
||||
hosts: data.results,
|
||||
count: data.count,
|
||||
hosts: hostResponse.data.results,
|
||||
count: hostResponse.data.count,
|
||||
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
|
||||
credentialTypeId: cred.data.results[0].id,
|
||||
isAdHocDisabled: !adHocOptions.data.actions.POST,
|
||||
};
|
||||
}, [location.search, inventory.id]),
|
||||
{
|
||||
hosts: [],
|
||||
count: 0,
|
||||
moduleOptions: [],
|
||||
isAdHocDisabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -50,66 +68,106 @@ function SmartInventoryHostList({ i18n, inventory }) {
|
||||
}, [fetchHosts]);
|
||||
|
||||
return (
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={hosts}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created by (username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified by (username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={isSelected => setSelected(isSelected ? [...hosts] : [])}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={
|
||||
inventory?.summary_fields?.user_capabilities?.adhoc
|
||||
? [
|
||||
<Button
|
||||
aria-label={i18n._(t`Run commands`)}
|
||||
isDisabled={selected.length === 0}
|
||||
>
|
||||
{i18n._(t`Run commands`)}
|
||||
</Button>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
<>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={hosts}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Hosts`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created by (username)`),
|
||||
key: 'created_by__username',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified by (username)`),
|
||||
key: 'modified_by__username',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
renderToolbar={props => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={isSelected =>
|
||||
setSelected(isSelected ? [...hosts] : [])
|
||||
}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={
|
||||
inventory?.summary_fields?.user_capabilities?.adhoc
|
||||
? [
|
||||
<Kebabified>
|
||||
{({ isKebabified }) =>
|
||||
isKebabified ? (
|
||||
<DropdownItem
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={count === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<ToolbarItem>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
|
||||
)}
|
||||
position="top"
|
||||
key="adhoc"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={i18n._(t`Run command`)}
|
||||
onClick={() => setIsAdHocCommandsOpen(true)}
|
||||
isDisabled={count === 0 || isAdHocDisabled}
|
||||
>
|
||||
{i18n._(t`Run command`)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ToolbarItem>
|
||||
)
|
||||
}
|
||||
</Kebabified>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderItem={host => (
|
||||
<SmartInventoryHostListItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
|
||||
isSelected={selected.some(row => row.id === host.id)}
|
||||
onSelect={() => handleSelect(host)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isAdHocCommandsOpen && (
|
||||
<AdHocCommands
|
||||
css="margin-right: 20px"
|
||||
adHocItems={selected}
|
||||
itemId={parseInt(inventory.id, 10)}
|
||||
onClose={() => setIsAdHocCommandsOpen(false)}
|
||||
credentialTypeId={credentialTypeId}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
)}
|
||||
renderItem={host => (
|
||||
<SmartInventoryHostListItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
|
||||
isSelected={selected.some(row => row.id === host.id)}
|
||||
onSelect={() => handleSelect(host)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@@ -12,125 +12,109 @@ import mockHosts from '../shared/data.hosts.json';
|
||||
jest.mock('../../../api');
|
||||
|
||||
describe('<SmartInventoryHostList />', () => {
|
||||
describe('User has adhoc permissions', () => {
|
||||
let wrapper;
|
||||
const clonedInventory = {
|
||||
...mockInventory,
|
||||
summary_fields: {
|
||||
...mockInventory.summary_fields,
|
||||
user_capabilities: {
|
||||
...mockInventory.summary_fields.user_capabilities,
|
||||
let wrapper;
|
||||
const clonedInventory = {
|
||||
...mockInventory,
|
||||
summary_fields: {
|
||||
...mockInventory.summary_fields,
|
||||
user_capabilities: {
|
||||
...mockInventory.summary_fields.user_capabilities,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
InventoriesAPI.readHosts.mockResolvedValue({
|
||||
data: mockHosts,
|
||||
});
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
POST: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
InventoriesAPI.readHosts.mockResolvedValue({
|
||||
data: mockHosts,
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryHostList inventory={clonedInventory} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
CredentialTypesAPI.read.mockResolvedValue({
|
||||
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should fetch hosts from api and render them in the list', () => {
|
||||
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
|
||||
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
|
||||
});
|
||||
|
||||
test('should disable run commands button when no hosts are selected', () => {
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
const runCommandsButton = wrapper.find(
|
||||
'button[aria-label="Run commands"]'
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryHostList inventory={clonedInventory} />
|
||||
);
|
||||
expect(runCommandsButton.length).toBe(1);
|
||||
expect(runCommandsButton.prop('disabled')).toEqual(true);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
test('should enable run commands button when at least one host is selected', () => {
|
||||
act(() => {
|
||||
wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(
|
||||
true
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
const runCommandsButton = wrapper.find(
|
||||
'button[aria-label="Run commands"]'
|
||||
);
|
||||
expect(runCommandsButton.prop('disabled')).toEqual(false);
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders successfully', () => {
|
||||
expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should fetch hosts from api and render them in the list', () => {
|
||||
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
|
||||
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
|
||||
});
|
||||
|
||||
test('should have run command button', () => {
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
|
||||
expect(runCommandsButton.length).toBe(1);
|
||||
expect(runCommandsButton.prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
test('should select and deselect all items', async () => {
|
||||
act(() => {
|
||||
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toEqual(true);
|
||||
});
|
||||
act(() => {
|
||||
wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toEqual(false);
|
||||
});
|
||||
test('should select and deselect all items', async () => {
|
||||
act(() => {
|
||||
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
|
||||
});
|
||||
|
||||
test('should show content error when api throws an error', async () => {
|
||||
InventoriesAPI.readHosts.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryHostList inventory={mockInventory} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
wrapper.update();
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toEqual(true);
|
||||
});
|
||||
act(() => {
|
||||
wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
expect(el.props().checked).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User does not have adhoc permissions', () => {
|
||||
let wrapper;
|
||||
const clonedInventory = {
|
||||
...mockInventory,
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
adhoc: false,
|
||||
test('should show content error when api throws an error', async () => {
|
||||
InventoriesAPI.readHosts.mockImplementation(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryHostList inventory={mockInventory} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
test('should disable run commands button', async () => {
|
||||
InventoriesAPI.readHosts.mockResolvedValue({
|
||||
data: { results: [], count: 0 },
|
||||
});
|
||||
InventoriesAPI.readAdHocOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: { module_name: { choices: [['module']] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should hide run commands button', async () => {
|
||||
InventoriesAPI.readHosts.mockResolvedValue({
|
||||
data: { results: [], count: 0 },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryHostList inventory={clonedInventory} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const runCommandsButton = wrapper.find(
|
||||
'button[aria-label="Run commands"]'
|
||||
);
|
||||
expect(runCommandsButton.length).toBe(0);
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryHostList inventory={clonedInventory} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
|
||||
expect(runCommandsButton.prop('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
|
||||
values,
|
||||
initialValues,
|
||||
resetForm,
|
||||
setFieldTouched,
|
||||
setFieldValue,
|
||||
} = useFormikContext();
|
||||
const [sourceField, sourceMeta] = useField({
|
||||
@@ -92,6 +93,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
|
||||
};
|
||||
Object.keys(defaults).forEach(label => {
|
||||
setFieldValue(label, defaults[label]);
|
||||
setFieldTouched(label, false);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -255,7 +257,7 @@ const InventorySourceForm = ({
|
||||
overwrite: source?.overwrite || false,
|
||||
overwrite_vars: source?.overwrite_vars || false,
|
||||
source: source?.source || '',
|
||||
source_path: source?.source_path === '' ? '/ (project root)' : '',
|
||||
source_path: source?.source_path || '',
|
||||
source_project: source?.summary_fields?.source_project || null,
|
||||
source_script: source?.summary_fields?.source_script || null,
|
||||
source_vars: source?.source_vars || '---\n',
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
EnabledValueField,
|
||||
HostFilterField,
|
||||
} from './SharedFields';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
const AzureSubForm = ({ autoPopulateCredential, i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
value => {
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
EnabledValueField,
|
||||
HostFilterField,
|
||||
} from './SharedFields';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
const CloudFormsSubForm = ({ i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
value => {
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
EnabledValueField,
|
||||
HostFilterField,
|
||||
} from './SharedFields';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
const GCESubForm = ({ autoPopulateCredential, i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
value => {
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
EnabledValueField,
|
||||
HostFilterField,
|
||||
} from './SharedFields';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
const OpenStackSubForm = ({ autoPopulateCredential, i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
value => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './SharedFields';
|
||||
|
||||
const SCMSubForm = ({ autoPopulateProject, i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const { setFieldValue, setFieldTouched } = useFormikContext();
|
||||
const [credentialField] = useField('credential');
|
||||
const [projectField, projectMeta, projectHelpers] = useField({
|
||||
name: 'source_project',
|
||||
@@ -47,16 +47,20 @@ const SCMSubForm = ({ autoPopulateProject, i18n }) => {
|
||||
useEffect(() => {
|
||||
if (projectMeta.initialValue) {
|
||||
fetchSourcePath(projectMeta.initialValue.id);
|
||||
}
|
||||
if (sourcePathField.value === '') {
|
||||
sourcePathHelpers.setValue('/ (project root)');
|
||||
}
|
||||
} // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchSourcePath, projectMeta.initialValue]);
|
||||
|
||||
const handleProjectUpdate = useCallback(
|
||||
value => {
|
||||
setFieldValue('source_path', '');
|
||||
setFieldValue('source_project', value);
|
||||
setFieldValue('source_path', '');
|
||||
setFieldTouched('source_path', false);
|
||||
fetchSourcePath(value.id);
|
||||
},
|
||||
[fetchSourcePath, setFieldValue]
|
||||
[fetchSourcePath, setFieldValue, setFieldTouched]
|
||||
);
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
EnabledValueField,
|
||||
HostFilterField,
|
||||
} from './SharedFields';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
const SatelliteSubForm = ({ autoPopulateCredential, i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
value => {
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
EnabledValueField,
|
||||
HostFilterField,
|
||||
} from './SharedFields';
|
||||
import { required } from '../../../../util/validators';
|
||||
|
||||
const TowerSubForm = ({ autoPopulateCredential, i18n }) => {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField(
|
||||
'credential'
|
||||
);
|
||||
const [credentialField, credentialMeta, credentialHelpers] = useField({
|
||||
name: 'credential',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
value => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user