From 31650bb0bd99ecf689f08497faa935ffd47ab869 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Fri, 22 Nov 2019 11:39:08 -0500 Subject: [PATCH 001/109] Remove usage of idle_timeout when checking status of isolated / containerized jobs --- awx/main/isolated/manager.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/main/isolated/manager.py b/awx/main/isolated/manager.py index 642ba373a4..ae09176cbc 100644 --- a/awx/main/isolated/manager.py +++ b/awx/main/isolated/manager.py @@ -40,7 +40,6 @@ class IsolatedManager(object): """ self.cancelled_callback = cancelled_callback self.check_callback = check_callback - self.idle_timeout = max(60, 2 * settings.AWX_ISOLATED_CONNECTION_TIMEOUT) self.started_at = None self.captured_command_artifact = False self.instance = None @@ -108,7 +107,6 @@ class IsolatedManager(object): 'verbosity': verbosity, 'cancel_callback': self.cancelled_callback, 'settings': { - 'idle_timeout': self.idle_timeout, 'job_timeout': settings.AWX_ISOLATED_LAUNCH_TIMEOUT, 'pexpect_timeout': getattr(settings, 'PEXPECT_TIMEOUT', 5), 'suppress_ansible_output': True, @@ -118,7 +116,7 @@ class IsolatedManager(object): def path_to(self, *args): return os.path.join(self.private_data_dir, *args) - def run_management_playbook(self, playbook, private_data_dir, **kw): + def run_management_playbook(self, playbook, private_data_dir, idle_timeout=None, **kw): iso_dir = tempfile.mkdtemp( prefix=playbook, dir=private_data_dir @@ -126,6 +124,8 @@ class IsolatedManager(object): params = self.runner_params.copy() params['playbook'] = playbook params['private_data_dir'] = iso_dir + if idle_timeout: + params['settings']['idle_timeout'] = idle_timeout params.update(**kw) if all([ getattr(settings, 'AWX_ISOLATED_KEY_GENERATION', False) is True, @@ -177,6 +177,7 @@ class IsolatedManager(object): logger.debug('Starting job {} on isolated host with `run_isolated.yml` playbook.'.format(self.instance.id)) runner_obj = self.run_management_playbook('run_isolated.yml', self.private_data_dir, + idle_timeout=max(60, 2 * settings.AWX_ISOLATED_CONNECTION_TIMEOUT), extravars=extravars) if runner_obj.status == 'failed': From ee8775a08d96081c875d41f62daaa155b54957bf Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 21 Nov 2019 11:05:06 -0500 Subject: [PATCH 002/109] Adds link to docs on container groups add/edit forms to match instance groups. Updates instance groups link. --- .../add-edit/add-instance-group.controller.js | 4 ++-- .../add-edit/edit-instance-group.controller.js | 4 ++-- .../container-groups/add-container-group.controller.js | 5 +++++ .../container-groups/add-container-group.view.html | 6 +++++- .../container-groups/edit-container-group.controller.js | 5 +++++ .../client/src/instance-groups/instance-groups.strings.js | 3 ++- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js index 537096996b..d556c6dbbe 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js @@ -7,8 +7,8 @@ function AddController ($state, models, strings) { vm.panelTitle = strings.get('state.ADD_BREADCRUMB_LABEL'); vm.docs = { - url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/clustering.html', - help_text: vm.strings.get('tooltips.DOCS_HELP_TEXT') + url: 'https://docs.ansible.com/ansible-tower/latest/html/userguide/instance_groups.html', + help_text: vm.strings.get('tooltips.IG_DOCS_HELP_TEXT') }; vm.tab = { diff --git a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js index 18401ca08e..55972e36fe 100644 --- a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js @@ -17,8 +17,8 @@ function EditController ($rootScope, $state, models, strings) { vm.panelTitle = instanceGroup.get('name'); vm.docs = { - url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/clustering.html', - help_text: vm.strings.get('tooltips.DOCS_HELP_TEXT') + url: 'https://docs.ansible.com/ansible-tower/latest/html/userguide/instance_groups.html', + help_text: vm.strings.get('tooltips.IG_DOCS_HELP_TEXT') }; vm.tab = { diff --git a/awx/ui/client/src/instance-groups/container-groups/add-container-group.controller.js b/awx/ui/client/src/instance-groups/container-groups/add-container-group.controller.js index fc6bfaf846..cdab0e89fc 100644 --- a/awx/ui/client/src/instance-groups/container-groups/add-container-group.controller.js +++ b/awx/ui/client/src/instance-groups/container-groups/add-container-group.controller.js @@ -10,6 +10,11 @@ function AddContainerGroupController(ToJSON, $scope, $state, models, strings, i1 vm.panelTitle = strings.get('state.ADD_CONTAINER_GROUP_BREADCRUMB_LABEL'); vm.lookUpTitle = strings.get('container.LOOK_UP_TITLE'); + vm.docs = { + url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/external_execution_envs.html#ag-container-groups', + help_text: vm.strings.get('tooltips.CG_DOCS_HELP_TEXT') + }; + vm.form = instanceGroup.createFormSchema('post'); vm.form.name.required = true; delete vm.form.name.help_text; diff --git a/awx/ui/client/src/instance-groups/container-groups/add-container-group.view.html b/awx/ui/client/src/instance-groups/container-groups/add-container-group.view.html index a7c3dcb446..3e27ee18bf 100644 --- a/awx/ui/client/src/instance-groups/container-groups/add-container-group.view.html +++ b/awx/ui/client/src/instance-groups/container-groups/add-container-group.view.html @@ -6,7 +6,11 @@ - + + + + + {{:: vm.strings.get('tab.DETAILS') }} {{:: vm.strings.get('tab.JOBS') }} diff --git a/awx/ui/client/src/instance-groups/container-groups/edit-container-group.controller.js b/awx/ui/client/src/instance-groups/container-groups/edit-container-group.controller.js index 62740a559d..45b6f9a3b1 100644 --- a/awx/ui/client/src/instance-groups/container-groups/edit-container-group.controller.js +++ b/awx/ui/client/src/instance-groups/container-groups/edit-container-group.controller.js @@ -23,6 +23,11 @@ function EditContainerGroupController($rootScope, $scope, $state, models, string vm.panelTitle = EditContainerGroupDataset.data.name; vm.lookUpTitle = strings.get('container.LOOK_UP_TITLE'); + vm.docs = { + url: 'https://docs.ansible.com/ansible-tower/latest/html/administration/external_execution_envs.html#ag-container-groups', + help_text: vm.strings.get('tooltips.CG_DOCS_HELP_TEXT') + }; + vm.form = instanceGroup.createFormSchema('post'); vm.switchDisabled = false; vm.form.disabled = !instanceGroup.has('options', 'actions.PUT'); diff --git a/awx/ui/client/src/instance-groups/instance-groups.strings.js b/awx/ui/client/src/instance-groups/instance-groups.strings.js index 6e1e118f08..74fd210350 100644 --- a/awx/ui/client/src/instance-groups/instance-groups.strings.js +++ b/awx/ui/client/src/instance-groups/instance-groups.strings.js @@ -32,7 +32,8 @@ function InstanceGroupsStrings(BaseString) { ns.tooltips = { ADD_INSTANCE_GROUP: t.s('Create a new Instance Group'), ASSOCIATE_INSTANCES: t.s('Associate an existing Instance'), - DOCS_HELP_TEXT: t.s('Instance Groups Help') + IG_DOCS_HELP_TEXT: t.s('Instance Groups Help'), + CG_DOCS_HELP_TEXT: t.s('Container Groups Help') }; ns.instance = { From 12363ae1753eddf33e00a7d9526daa2896a6bbeb Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Mon, 25 Nov 2019 11:32:41 -0500 Subject: [PATCH 003/109] Fix Docker build caching The flow will need to be: - Pre-pull image you want to use - Re-tag as image:$(COMPOSE_TAG) - COMPOSE_TAG=mytag make docker-compose-build --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 98caaba7df..03f6f21941 100644 --- a/Makefile +++ b/Makefile @@ -654,7 +654,6 @@ docker-compose-build: awx-devel-build # Base development image build awx-devel-build: docker build -t ansible/awx_devel -f tools/docker-compose/Dockerfile \ - --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:devel \ --cache-from=$(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) . docker tag ansible/awx_devel $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) #docker push $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG) From ce8c0066d048bf4d91a53a51ebbbd6dc9a241b64 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Mon, 25 Nov 2019 12:51:49 -0500 Subject: [PATCH 004/109] Fix downstream tests I backported how we do the VERSION detection in 3.5.something. This should already be fixed upstream. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 03f6f21941..8f9716e64b 100644 --- a/Makefile +++ b/Makefile @@ -364,7 +364,7 @@ check: flake8 pep8 # pyflakes pylint awx-link: cp -R /tmp/awx.egg-info /awx_devel/ || true - sed -i "s/placeholder/$(shell git describe --long | sed 's/\./\\./g')/" /awx_devel/awx.egg-info/PKG-INFO + sed -i "s/placeholder/$(shell cat VERSION)/" /awx_devel/awx.egg-info/PKG-INFO cp -f /tmp/awx.egg-link /venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests From 695eab1fdd5856c74d71a075ef97b78d0fd6984f Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 26 Nov 2019 09:39:05 -0500 Subject: [PATCH 005/109] fix duplicate exception sanity error --- awx_collection/plugins/modules/tower_credential.py | 2 +- awx_collection/plugins/modules/tower_group.py | 2 +- awx_collection/plugins/modules/tower_label.py | 2 +- awx_collection/plugins/modules/tower_team.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/awx_collection/plugins/modules/tower_credential.py b/awx_collection/plugins/modules/tower_credential.py index a87d76fecb..d2f2d9bd5f 100644 --- a/awx_collection/plugins/modules/tower_credential.py +++ b/awx_collection/plugins/modules/tower_credential.py @@ -404,7 +404,7 @@ def main(): result = credential.delete(**params) except (exc.NotFound) as excinfo: module.fail_json(msg='Failed to update credential, organization not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.NotFound, exc.AuthError) as excinfo: + except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: module.fail_json(msg='Failed to update credential: {0}'.format(excinfo), changed=False) json_output['changed'] = result['changed'] diff --git a/awx_collection/plugins/modules/tower_group.py b/awx_collection/plugins/modules/tower_group.py index bd9feaf91f..0b4269b5f7 100644 --- a/awx_collection/plugins/modules/tower_group.py +++ b/awx_collection/plugins/modules/tower_group.py @@ -180,7 +180,7 @@ def main(): result = group.delete(**params) except (exc.NotFound) as excinfo: module.fail_json(msg='Failed to update the group, inventory not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.NotFound, exc.AuthError) as excinfo: + except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: module.fail_json(msg='Failed to update the group: {0}'.format(excinfo), changed=False) json_output['changed'] = result['changed'] diff --git a/awx_collection/plugins/modules/tower_label.py b/awx_collection/plugins/modules/tower_label.py index 254d9d4986..e5085cfd27 100644 --- a/awx_collection/plugins/modules/tower_label.py +++ b/awx_collection/plugins/modules/tower_label.py @@ -94,7 +94,7 @@ def main(): result = label.delete(name=name, organization=org['id']) except (exc.NotFound) as excinfo: module.fail_json(msg='Failed to update label, organization not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.NotFound, exc.AuthError) as excinfo: + except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: module.fail_json(msg='Failed to update label: {0}'.format(excinfo), changed=False) json_output['changed'] = result['changed'] diff --git a/awx_collection/plugins/modules/tower_team.py b/awx_collection/plugins/modules/tower_team.py index 80aff36f04..dc34f4d706 100644 --- a/awx_collection/plugins/modules/tower_team.py +++ b/awx_collection/plugins/modules/tower_team.py @@ -103,7 +103,7 @@ def main(): result = team.delete(name=name, organization=org['id']) except (exc.NotFound) as excinfo: module.fail_json(msg='Failed to update team, organization not found: {0}'.format(excinfo), changed=False) - except (exc.ConnectionError, exc.BadRequest, exc.NotFound, exc.AuthError) as excinfo: + except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo: module.fail_json(msg='Failed to update team: {0}'.format(excinfo), changed=False) json_output['changed'] = result['changed'] From 68f17eb370b114f837d627793f7cde2c43e15d09 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 26 Nov 2019 23:29:50 -0500 Subject: [PATCH 006/109] bump asgi-amqp dependency --- requirements/requirements.in | 2 +- requirements/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/requirements.in b/requirements/requirements.in index e26d228bd4..eb61733474 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -1,6 +1,6 @@ ansible-runner==1.4.4 appdirs==1.4.2 -asgi-amqp==1.1.3 +asgi-amqp==1.1.4 azure-keyvault==1.1.0 boto==2.47.0 channels==1.1.8 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 89d1ef12ac..e446994ee9 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -3,7 +3,7 @@ amqp==2.4.2 # via kombu ansible-runner==1.4.4 appdirs==1.4.2 argparse==1.4.0 # via uwsgitop -asgi-amqp==1.1.3 +asgi-amqp==1.1.4 asgiref==1.1.2 # via asgi-amqp, channels, daphne asn1crypto==0.24.0 # via cryptography attrs==19.1.0 # via automat, service-identity, twisted From ffdcb2f8eb7e2dc43aa0d05dd8a5d817d28da759 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 19 Nov 2019 09:21:04 -0500 Subject: [PATCH 007/109] fix busted tests --- awx/main/tests/functional/models/test_notifications.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index 1b671efdcb..00cc217a82 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -88,6 +88,9 @@ class TestJobNotificationMixin(object): 'verbosity': int}, 'job_friendly_name': str, 'job_metadata': str, + 'approval_status': str, + 'approval_node_name': str, + 'workflow_url': str, 'url': str} From ea5d4293995b4eadc517ee75d3b85eed46c3ed29 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 25 Nov 2019 11:50:09 -0500 Subject: [PATCH 008/109] fix a few bugs with the session and oauth2 cleanup scheduled jobs see: https://github.com/ansible/tower/issues/3940 --- awx/api/serializers.py | 4 +++ awx/main/models/jobs.py | 25 ++++++++++--------- awx/main/tasks.py | 9 ++++--- .../tests/unit/models/test_system_jobs.py | 12 ++++++--- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a3d8d43306..d95a754e71 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4658,6 +4658,10 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria def get_summary_fields(self, obj): summary_fields = super(ScheduleSerializer, self).get_summary_fields(obj) + + if isinstance(obj.unified_job_template, SystemJobTemplate): + summary_fields['unified_job_template']['job_type'] = obj.unified_job_template.job_type + if 'inventory' in summary_fields: return summary_fields diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index d573d1ed96..ae2f772526 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -1179,18 +1179,19 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): for key in unallowed_vars: rejected[key] = data.pop(key) - if 'days' in data: - try: - if type(data['days']) is bool: - raise ValueError - if float(data['days']) != int(data['days']): - raise ValueError - days = int(data['days']) - if days < 0: - raise ValueError - except ValueError: - errors_list.append(_("days must be a positive integer.")) - rejected['days'] = data.pop('days') + if self.job_type in ('cleanup_jobs', 'cleanup_activitystream'): + if 'days' in data: + try: + if isinstance(data['days'], (bool, type(None))): + raise ValueError + if float(data['days']) != int(data['days']): + raise ValueError + days = int(data['days']) + if days < 0: + raise ValueError + except ValueError: + errors_list.append(_("days must be a positive integer.")) + rejected['days'] = data.pop('days') if errors_list: errors['extra_vars'] = errors_list diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 7429f8f458..37ef703a29 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2761,10 +2761,11 @@ class RunSystemJob(BaseTask): json_vars = {} else: json_vars = json.loads(system_job.extra_vars) - if 'days' in json_vars: - args.extend(['--days', str(json_vars.get('days', 60))]) - if 'dry_run' in json_vars and json_vars['dry_run']: - args.extend(['--dry-run']) + if system_job.job_type in ('cleanup_jobs', 'cleanup_activitystream'): + if 'days' in json_vars: + args.extend(['--days', str(json_vars.get('days', 60))]) + if 'dry_run' in json_vars and json_vars['dry_run']: + args.extend(['--dry-run']) if system_job.job_type == 'cleanup_jobs': args.extend(['--jobs', '--project-updates', '--inventory-updates', '--management-jobs', '--ad-hoc-commands', '--workflow-jobs', diff --git a/awx/main/tests/unit/models/test_system_jobs.py b/awx/main/tests/unit/models/test_system_jobs.py index 045928be07..2ed9204adb 100644 --- a/awx/main/tests/unit/models/test_system_jobs.py +++ b/awx/main/tests/unit/models/test_system_jobs.py @@ -12,7 +12,9 @@ from awx.main.models import SystemJobTemplate {"days": 13435}, ]) def test_valid__clean_extra_data_system_jobs(extra_data): - accepted, rejected, errors = SystemJobTemplate().accept_or_ignore_variables(extra_data) + accepted, rejected, errors = SystemJobTemplate( + job_type='cleanup_jobs' + ).accept_or_ignore_variables(extra_data) assert not rejected assert not errors @@ -32,12 +34,14 @@ def test_valid__clean_extra_data_system_jobs(extra_data): {"days": "foobar"}, ]) def test_invalid__extra_data_system_jobs(extra_data): - accepted, rejected, errors = SystemJobTemplate().accept_or_ignore_variables(extra_data) + accepted, rejected, errors = SystemJobTemplate( + job_type='cleanup_jobs' + ).accept_or_ignore_variables(extra_data) assert str(errors['extra_vars'][0]) == u'days must be a positive integer.' def test_unallowed_system_job_data(): - sjt = SystemJobTemplate() + sjt = SystemJobTemplate(job_type='cleanup_jobs') accepted, ignored, errors = sjt.accept_or_ignore_variables({ 'days': 34, 'foobar': 'baz' @@ -54,7 +58,7 @@ def test_reject_other_prommpts(): def test_reject_some_accept_some(): - sjt = SystemJobTemplate() + sjt = SystemJobTemplate(job_type='cleanup_jobs') accepted, ignored, errors = sjt._accept_or_ignore_job_kwargs(limit="", extra_vars={ 'days': 34, 'foobar': 'baz' From ee6e28e0661f1e3a6d55cf02dc6e6ef76543c4e1 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 26 Nov 2019 15:05:45 -0500 Subject: [PATCH 009/109] Only show the days to keep input on the scheduler for system jobs that require it. Hides this field for cleaning up tokens and sessions. --- awx/ui/client/src/management-jobs/card/card.controller.js | 1 - .../management-jobs/scheduler/schedulerForm.partial.html | 2 +- .../src/scheduler/factories/schedule-post.factory.js | 2 +- awx/ui/client/src/scheduler/schedulerAdd.controller.js | 5 ++++- awx/ui/client/src/scheduler/schedulerEdit.controller.js | 7 +++++-- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/src/management-jobs/card/card.controller.js b/awx/ui/client/src/management-jobs/card/card.controller.js index 930f04cd51..d266900ea5 100644 --- a/awx/ui/client/src/management-jobs/card/card.controller.js +++ b/awx/ui/client/src/management-jobs/card/card.controller.js @@ -31,7 +31,6 @@ export default }; getManagementJobs(); - $scope.cleanupJob = true; // This handles the case where the user refreshes the management job notifications page. if($state.current.name === 'managementJobsList.notifications') { $scope.activeCard = parseInt($state.params.management_id); diff --git a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html index 065a78d4e3..58dfbff4c9 100644 --- a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html @@ -164,7 +164,7 @@ ng-show="sheduler_frequency_error"> -
+
diff --git a/awx/ui_next/src/components/AddRole/SelectableCard.jsx b/awx/ui_next/src/components/AddRole/SelectableCard.jsx index cebc795a0e..475af3d2ce 100644 --- a/awx/ui_next/src/components/AddRole/SelectableCard.jsx +++ b/awx/ui_next/src/components/AddRole/SelectableCard.jsx @@ -33,7 +33,7 @@ const Label = styled.div` class SelectableCard extends Component { render() { - const { label, onClick, isSelected } = this.props; + const { label, onClick, isSelected, dataCy } = this.props; return ( From 12a8793ddbcabf9c5f4e538471c0cf2bb73559fa Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 10 Dec 2019 08:56:41 -0500 Subject: [PATCH 039/109] provide a better error for OAuth2 logins for external accounts attempting to use an OAuth2 token as an externally authenticated user throws an HTTP 500 error when external oauth is disabled - this change improves that so it's a 401 Unauthorized instead. --- awx/main/models/oauth.py | 20 ++++++++- awx/main/tests/functional/api/test_oauth.py | 49 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/awx/main/models/oauth.py b/awx/main/models/oauth.py index 3a89790e80..5f719f894e 100644 --- a/awx/main/models/oauth.py +++ b/awx/main/models/oauth.py @@ -1,4 +1,5 @@ # Python +import logging import re # Django @@ -22,6 +23,9 @@ DATA_URI_RE = re.compile(r'.*') # FIXME __all__ = ['OAuth2AccessToken', 'OAuth2Application'] +logger = logging.getLogger('awx.main.models.oauth') + + class OAuth2Application(AbstractApplication): class Meta: @@ -120,15 +124,27 @@ class OAuth2AccessToken(AbstractAccessToken): def is_valid(self, scopes=None): valid = super(OAuth2AccessToken, self).is_valid(scopes) if valid: + try: + self.validate_external_users() + except oauth2.AccessDeniedError: + logger.exception(f'Failed to authenticate {self.user.username}') + return False self.last_used = now() - connection.on_commit(lambda: self.save(update_fields=['last_used'])) + + def _update_last_used(): + if OAuth2AccessToken.objects.filter(pk=self.pk).exists(): + self.save(update_fields=['last_used']) + connection.on_commit(_update_last_used) return valid - def save(self, *args, **kwargs): + def validate_external_users(self): if self.user and settings.ALLOW_OAUTH2_FOR_EXTERNAL_USERS is False: external_account = get_external_account(self.user) if external_account is not None: raise oauth2.AccessDeniedError(_( 'OAuth2 Tokens cannot be created by users associated with an external authentication provider ({})' ).format(external_account)) + + def save(self, *args, **kwargs): + self.validate_external_users() super(OAuth2AccessToken, self).save(*args, **kwargs) diff --git a/awx/main/tests/functional/api/test_oauth.py b/awx/main/tests/functional/api/test_oauth.py index 22ae98b710..7fc0d65977 100644 --- a/awx/main/tests/functional/api/test_oauth.py +++ b/awx/main/tests/functional/api/test_oauth.py @@ -1,6 +1,8 @@ import pytest import base64 +import contextlib import json +from unittest import mock from django.db import connection from django.test.utils import override_settings @@ -14,6 +16,18 @@ from awx.sso.models import UserEnterpriseAuth from oauth2_provider.models import RefreshToken +@contextlib.contextmanager +def immediate_on_commit(): + """ + Context manager executing transaction.on_commit() hooks immediately as + if the connection was in auto-commit mode. + """ + def on_commit(func): + func() + with mock.patch('django.db.connection.on_commit', side_effect=on_commit) as patch: + yield patch + + @pytest.mark.django_db def test_personal_access_token_creation(oauth_application, post, alice): url = drf_reverse('api:oauth_authorization_root_view') + 'token/' @@ -54,6 +68,41 @@ def test_token_creation_disabled_for_external_accounts(oauth_application, post, assert AccessToken.objects.count() == 0 +@pytest.mark.django_db +def test_existing_token_disabled_for_external_accounts(oauth_application, get, post, admin): + UserEnterpriseAuth(user=admin, provider='radius').save() + url = drf_reverse('api:oauth_authorization_root_view') + 'token/' + with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USERS=True): + resp = post( + url, + data='grant_type=password&username=admin&password=admin&scope=read', + content_type='application/x-www-form-urlencoded', + HTTP_AUTHORIZATION='Basic ' + smart_str(base64.b64encode(smart_bytes(':'.join([ + oauth_application.client_id, oauth_application.client_secret + ])))), + status=201 + ) + token = json.loads(resp.content)['access_token'] + assert AccessToken.objects.count() == 1 + + with immediate_on_commit(): + resp = get( + drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), + HTTP_AUTHORIZATION='Bearer ' + token, + status=200 + ) + assert json.loads(resp.content)['results'][0]['username'] == 'admin' + + with override_settings(RADIUS_SERVER='example.org', ALLOW_OAUTH2_FOR_EXTERNAL_USER=False): + with immediate_on_commit(): + resp = get( + drf_reverse('api:user_me_list', kwargs={'version': 'v2'}), + HTTP_AUTHORIZATION='Bearer ' + token, + status=401 + ) + assert b'To establish a login session' in resp.content + + @pytest.mark.django_db def test_pat_creation_no_default_scope(oauth_application, post, admin): # tests that the default scope is overriden From 4c89568d7186276f2ffef6dd59c56346cae057f4 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 5 Dec 2019 11:34:18 -0500 Subject: [PATCH 040/109] Apply radio selection to ALL selected groups in modal * Use semantic html to describe modal list * Move nested try/catch block * Remove deprecated type fields * If delete fails, keep selected list checked --- .../InventoryGroups/InventoryGroups.jsx | 59 ++++---- .../InventoryGroups/InventoryGroups.test.jsx | 26 ++-- .../shared/InventoryGroupsDeleteModal.jsx | 133 ++++++------------ awx/ui_next/src/types.js | 8 -- 4 files changed, 86 insertions(+), 140 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx index 53f1f75774..49f55cd4d8 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -131,46 +131,37 @@ function InventoryGroups({ i18n, location, match }) { return i18n._(t`Select a row to delete`); }; - const promoteGroups = list => { - const promotePromises = Object.keys(list) - .filter(groupId => list[groupId] === 'promote') - .map(groupId => InventoriesAPI.promoteGroup(inventoryId, +groupId)); - - return Promise.all(promotePromises); - }; - - const deleteGroups = list => { - const deletePromises = Object.keys(list) - .filter(groupId => list[groupId] === 'delete') - .map(groupId => GroupsAPI.destroy(+groupId)); - - return Promise.all(deletePromises); - }; - - const handleDelete = async list => { + const handleDelete = async option => { setIsLoading(true); try { - await Promise.all([promoteGroups(list), deleteGroups(list)]); + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + /* Delete groups sequentially to avoid api integrity errors */ + for (const group of selected) { + if (option === 'delete') { + await GroupsAPI.destroy(+group.id); + } else if (option === 'promote') { + await InventoriesAPI.promoteGroup(inventoryId, +group.id); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ } catch (error) { setDeletionError(error); - } finally { - toggleModal(); - setSelected([]); - - try { - const { - data: { count, results }, - } = await fetchGroups(inventoryId, location.search); - - setGroups(results); - setGroupCount(count); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } } + + toggleModal(); + + try { + const { + data: { count, results }, + } = await fetchGroups(inventoryId, location.search); + setGroups(results); + setGroupCount(count); + } catch (error) { + setContentError(error); + } + + setIsLoading(false); }; const canAdd = diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx index abf12234fe..2b5a7340c0 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { MemoryRouter, Route } from 'react-router-dom'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { InventoriesAPI, GroupsAPI } from '@api'; import InventoryGroups from './InventoryGroups'; @@ -67,15 +68,20 @@ describe('', () => { }, }, }); - + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/3/groups'], + }); await act(async () => { wrapper = mountWithContexts( - - } - /> - + } + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } ); }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); @@ -194,6 +200,10 @@ describe('', () => { 'InventoryGroupsDeleteModal', el => el.props().isModalOpen === true ); + await act(async () => { + wrapper.find('Radio[id="radio-delete"]').invoke('onChange')(); + }); + wrapper.update(); await act(async () => { wrapper .find('ModalBoxFooter Button[aria-label="Delete"]') diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx index d0ac798872..ca86d722b5 100644 --- a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupsDeleteModal.jsx @@ -1,38 +1,18 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import ReactDOM from 'react-dom'; +import { func, bool, arrayOf, object } from 'prop-types'; import AlertModal from '@components/AlertModal'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { Button, Radio } from '@patternfly/react-core'; import styled from 'styled-components'; -const ListItem = styled.div` - padding: 24px 1px; - - dl { - display: flex; - font-weight: 600; - } - dt { - color: var(--pf-global--danger-color--100); - margin-right: 10px; - } - .pf-c-radio { - margin-top: 10px; - } +const ListItem = styled.li` + display: flex; + font-weight: 600; + color: var(--pf-global--danger-color--100); `; -const ContentWrapper = styled.div` - ${ListItem} + ${ListItem} { - border-top-width: 1px; - border-top-style: solid; - border-top-color: #d7d7d7; - } - ${ListItem}:last-child { - padding-bottom: 0; - } - `; - const InventoryGroupsDeleteModal = ({ onClose, onDelete, @@ -40,65 +20,7 @@ const InventoryGroupsDeleteModal = ({ groups, i18n, }) => { - const [deleteList, setDeleteList] = useState([]); - - useEffect(() => { - const groupIds = groups.reduce((obj, group) => { - if (group.total_groups > 0 || group.total_hosts > 0) { - return { ...obj, [group.id]: null }; - } - return { ...obj, [group.id]: 'delete' }; - }, {}); - - setDeleteList(groupIds); - }, [groups]); - - const handleChange = (groupId, radioOption) => { - setDeleteList({ ...deleteList, [groupId]: radioOption }); - }; - - const content = groups - .map(group => { - if (group.total_groups > 0 || group.total_hosts > 0) { - return ( - -
-
{group.name}
-
- {i18n._( - t`(${group.total_groups} Groups and ${group.total_hosts} Hosts)` - )} -
-
- handleChange(group.id, 'delete')} - /> - handleChange(group.id, 'promote')} - /> -
- ); - } - return ( - -
-
{group.name}
-
{i18n._(t`(No Child Groups or Hosts)`)}
-
-
- ); - }) - .reduce((array, el) => { - return array.concat(el); - }, []); + const [radioOption, setRadioOption] = useState(null); return ReactDOM.createPortal( onDelete(deleteList)} + onClick={() => onDelete(radioOption)} variant="danger" key="delete" - isDisabled={Object.keys(deleteList).some( - group => deleteList[group] === null - )} + isDisabled={radioOption === null} > {i18n._(t`Delete`)} , @@ -135,10 +55,43 @@ const InventoryGroupsDeleteModal = ({ groups.length > 1 ? i18n._(t`groups`) : i18n._(t`group`) } below?` )} - {content} +
+ {groups.map(group => { + return {group.name}; + })} +
+
+ setRadioOption('delete')} + /> + setRadioOption('promote')} + /> +
, document.body ); }; +InventoryGroupsDeleteModal.propTypes = { + onClose: func.isRequired, + onDelete: func.isRequired, + isModalOpen: bool, + groups: arrayOf(object), +}; + +InventoryGroupsDeleteModal.defaultProps = { + isModalOpen: false, + groups: [], +}; + export default withI18n()(InventoryGroupsDeleteModal); diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 4713e0dcfd..5fee265305 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -199,8 +199,6 @@ export const Host = shape({ enabled: bool, instance_id: string, variables: string, - has_active_failures: bool, - has_inventory_sources: bool, last_job: number, last_job_host_summary: number, }); @@ -242,10 +240,4 @@ export const Group = shape({ description: string, inventory: number, variables: string, - has_active_failures: bool, - total_hosts: number, - hosts_with_active_failures: number, - total_groups: number, - groups_with_active_failures: number, - has_inventory_sources: bool, }); From 2a722ba8d028c64aafbc9ac9b093e867e3b25e37 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 6 Nov 2019 12:03:13 -0500 Subject: [PATCH 041/109] Refactors Lookup --- .../components/Lookup/CredentialLookup.jsx | 2 +- .../Lookup/InstanceGroupsLookup.jsx | 2 +- .../src/components/Lookup/InventoryLookup.jsx | 2 +- awx/ui_next/src/components/Lookup/Lookup.jsx | 197 +++++++++++------- .../Lookup/MultiCredentialsLookup.jsx | 45 ++-- .../components/Lookup/OrganizationLookup.jsx | 2 +- .../src/components/Lookup/ProjectLookup.jsx | 2 +- .../shared/ProjectSubForms/SharedFields.jsx | 2 +- 8 files changed, 143 insertions(+), 111 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 6872e09784..aae1871e42 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -36,7 +36,7 @@ function CredentialLookup({ name="credential" value={value} onBlur={onBlur} - onLookupSave={onChange} + onChange={onChange} getItems={getCredentials} required={required} sortedColumnKey="name" diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 1e58f3eafa..c720ae2363 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -39,7 +39,7 @@ class InstanceGroupsLookup extends React.Component { lookupHeader={i18n._(t`Instance Groups`)} name="instanceGroups" value={value} - onLookupSave={onChange} + onChange={onChange} getItems={getInstanceGroups} qsNamespace="instance-group" multiple diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index d570e79128..494b4d3069 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -37,7 +37,7 @@ class InventoryLookup extends React.Component { lookupHeader={i18n._(t`Inventory`)} name="inventory" value={value} - onLookupSave={onChange} + onChange={onChange} onBlur={onBlur} getItems={getInventories} required={required} diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index ce2f85f24b..b1913e0100 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -59,13 +59,13 @@ class Lookup extends React.Component { super(props); this.assertCorrectValueType(); - let lookupSelectedItems = []; + let selectedItems = []; if (props.value) { - lookupSelectedItems = props.multiple ? [...props.value] : [props.value]; + selectedItems = props.multiple ? [...props.value] : [props.value]; } this.state = { isModalOpen: false, - lookupSelectedItems, + selectedItems, results: [], count: 0, error: null, @@ -76,7 +76,8 @@ class Lookup extends React.Component { order_by: props.sortedColumnKey, }); this.handleModalToggle = this.handleModalToggle.bind(this); - this.toggleSelected = this.toggleSelected.bind(this); + this.addItem = this.addItem.bind(this); + this.removeItem = this.removeItem.bind(this); this.saveModal = this.saveModal.bind(this); this.getData = this.getData.bind(this); this.clearQSParams = this.clearQSParams.bind(this); @@ -132,46 +133,82 @@ class Lookup extends React.Component { } } - toggleSelected(row) { - const { - name, - onLookupSave, - multiple, - onToggleItem, - selectCategoryOptions, - } = this.props; - const { - lookupSelectedItems: updatedSelectedItems, - isModalOpen, - } = this.state; - - const selectedIndex = updatedSelectedItems.findIndex( - selectedRow => selectedRow.id === row.id - ); - if (multiple) { - if (selectCategoryOptions) { - onToggleItem(row, isModalOpen); - } - if (selectedIndex > -1) { - updatedSelectedItems.splice(selectedIndex, 1); - this.setState({ lookupSelectedItems: updatedSelectedItems }); - } else { - this.setState(prevState => ({ - lookupSelectedItems: [...prevState.lookupSelectedItems, row], - })); - } - } else { - this.setState({ lookupSelectedItems: [row] }); - } - - // Updates the selected items from parent state - // This handles the case where the user removes chips from the lookup input - // while the modal is closed - if (!isModalOpen) { - onLookupSave(updatedSelectedItems, name); + removeItem(row) { + const { selectedItems } = this.state; + const { onToggleItem } = this.props; + if (onToggleItem) { + this.setState({ selectedItems: onToggleItem(selectedItems, row) }); + return; } + this.setState({ + selectedItems: selectedItems.filter(item => item.id !== row.id), + }); } + addItem(row) { + const { selectedItems } = this.state; + const { multiple, onToggleItem } = this.props; + if (onToggleItem) { + this.setState({ selectedItems: onToggleItem(selectedItems, row) }); + return; + } + const index = selectedItems.findIndex(item => item.id === row.id); + + if (!multiple) { + this.setState({ selectedItems: [row] }); + return; + } + if (index > -1) { + return; + } + this.setState({ selectedItems: [...selectedItems, row] }); + } + // toggleSelected(row) { + // const { + // name, + // onChange, + // multiple, + // onToggleItem, + // selectCategoryOptions, + // onChange, + // value + // } = this.props; + // const { + // selectedItems: updatedSelectedItems, + // isModalOpen, + // } = this.state; + + // const selectedIndex = updatedSelectedItems.findIndex( + // selectedRow => selectedRow.id === row.id + // ); + // + // if (multiple) { + // + // if (selectCategoryOptions) { + // + // onToggleItem(row, isModalOpen); + // } + // if (selectedIndex > -1) { + // + // const valueToUpdate = value.filter(itemValue => itemValue.id !==row.id ); + // onChange(valueToUpdate) + // } else { + // + // onChange([...value, row]) + // } + // } else { + // + // onChange(row) + // } + + // Updates the selected items from parent state + // This handles the case where the user removes chips from the lookup input + // while the modal is closed + // if (!isModalOpen) { + // onChange(updatedSelectedItems, name); + // } + // } + handleModalToggle() { const { isModalOpen } = this.state; const { value, multiple, selectCategory } = this.props; @@ -179,11 +216,11 @@ class Lookup extends React.Component { // This handles the case where the user closes/cancels the modal and // opens it again if (!isModalOpen) { - let lookupSelectedItems = []; + let selectedItems = []; if (value) { - lookupSelectedItems = multiple ? [...value] : [value]; + selectedItems = multiple ? [...value] : [value]; } - this.setState({ lookupSelectedItems }); + this.setState({ selectedItems }); } else { this.clearQSParams(); if (selectCategory) { @@ -195,15 +232,22 @@ class Lookup extends React.Component { })); } + removeItemAndSave(row) { + const { value, onChange, multiple } = this.props; + if (multiple) { + onChange(value.filter(item => item.id !== row.id)); + } else if (value.id === row.id) { + onChange(null); + } + } + saveModal() { - const { onLookupSave, name, multiple } = this.props; - const { lookupSelectedItems } = this.state; - const value = multiple - ? lookupSelectedItems - : lookupSelectedItems[0] || null; + const { onChange, multiple } = this.props; + const { selectedItems } = this.state; + const value = multiple ? selectedItems : selectedItems[0] || null; this.handleModalToggle(); - onLookupSave(value, name); + onChange(value); } clearQSParams() { @@ -215,13 +259,7 @@ class Lookup extends React.Component { } render() { - const { - isModalOpen, - lookupSelectedItems, - error, - results, - count, - } = this.state; + const { isModalOpen, selectedItems, error, results, count } = this.state; const { form, id, @@ -245,7 +283,7 @@ class Lookup extends React.Component { {(multiple ? value : [value]).map(chip => ( this.toggleSelected(chip)} + onClick={() => this.removeItemAndSave(chip)} isReadOnly={!canDelete} credential={chip} /> @@ -256,7 +294,7 @@ class Lookup extends React.Component { {(multiple ? value : [value]).map(chip => ( this.toggleSelected(chip)} + onClick={() => this.removeItemAndSave(chip)} isReadOnly={!canDelete} > {chip.name} @@ -287,19 +325,19 @@ class Lookup extends React.Component { onClose={this.handleModalToggle} actions={[ , , ]} > @@ -318,6 +356,18 @@ class Lookup extends React.Component { /> )} + {selectedItems.length > 0 && ( + 0 + } + /> + )} i.id === item.id) - : lookupSelectedItems.some(i => i.id === item.id) - } - onSelect={() => this.toggleSelected(item)} + isSelected={selectedItems.some(i => i.id === item.id)} + onSelect={() => this.addItem(item)} isRadio={ !multiple || (selectCategoryOptions && @@ -347,17 +393,6 @@ class Lookup extends React.Component { renderToolbar={props => } showPageSizeOptions={false} /> - {lookupSelectedItems.length > 0 && ( - 0 - } - /> - )} {error ?
error
: ''} @@ -374,7 +409,7 @@ Lookup.propTypes = { getItems: func.isRequired, lookupHeader: string, name: string, - onLookupSave: func.isRequired, + onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), sortedColumnKey: string.isRequired, multiple: bool, diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 5b978b2c66..9e40ebccec 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -12,7 +12,25 @@ import Lookup from '@components/Lookup'; const QuestionCircleIcon = styled(PFQuestionCircleIcon)` margin-left: 10px; `; +function toggleCredentialSelection(credentialsToUpdate, newCredential) { + let newCredentialsList; + const isSelectedCredentialInState = + credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > + 0; + if (isSelectedCredentialInState) { + newCredentialsList = credentialsToUpdate.filter( + cred => cred.id !== newCredential.id + ); + } else { + newCredentialsList = credentialsToUpdate.filter( + credential => + credential.kind === 'vault' || credential.kind !== newCredential.kind + ); + newCredentialsList = [...newCredentialsList, newCredential]; + } + return newCredentialsList; +} class MultiCredentialsLookup extends React.Component { constructor(props) { super(props); @@ -26,7 +44,6 @@ class MultiCredentialsLookup extends React.Component { this ); this.loadCredentials = this.loadCredentials.bind(this); - this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this); } componentDidMount() { @@ -69,27 +86,7 @@ class MultiCredentialsLookup extends React.Component { return CredentialsAPI.read(params); } - toggleCredentialSelection(newCredential) { - const { onChange, credentials: credentialsToUpdate } = this.props; - let newCredentialsList; - const isSelectedCredentialInState = - credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > - 0; - - if (isSelectedCredentialInState) { - newCredentialsList = credentialsToUpdate.filter( - cred => cred.id !== newCredential.id - ); - } else { - newCredentialsList = credentialsToUpdate.filter( - credential => - credential.kind === 'vault' || credential.kind !== newCredential.kind - ); - newCredentialsList = [...newCredentialsList, newCredential]; - } - onChange(newCredentialsList); - } handleCredentialTypeSelect(value, type) { const { credentialTypes } = this.state; @@ -99,7 +96,7 @@ class MultiCredentialsLookup extends React.Component { render() { const { selectedCredentialType, credentialTypes } = this.state; - const { tooltip, i18n, credentials } = this.props; + const { tooltip, i18n, credentials, onChange } = this.props; return ( {tooltip && ( @@ -112,14 +109,14 @@ class MultiCredentialsLookup extends React.Component { selectCategoryOptions={credentialTypes} selectCategory={this.handleCredentialTypeSelect} selectedCategory={selectedCredentialType} - onToggleItem={this.toggleCredentialSelection} + onToggleItem={toggleCredentialSelection} onloadCategories={this.loadCredentialTypes} id="multiCredential" lookupHeader={i18n._(t`Credentials`)} name="credentials" value={credentials} multiple - onLookupSave={() => {}} + onChange={onChange} getItems={this.loadCredentials} qsNamespace="credentials" columns={[ diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 8efb43b091..32c93f2588 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -32,7 +32,7 @@ function OrganizationLookup({ name="organization" value={value} onBlur={onBlur} - onLookupSave={onChange} + onChange={onChange} getItems={getOrganizations} required={required} sortedColumnKey="name" diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index 90d36a64a7..f83d30eb02 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -45,7 +45,7 @@ class ProjectLookup extends React.Component { name="project" value={value} onBlur={onBlur} - onLookupSave={onChange} + onChange={onChange} getItems={loadProjects} required={required} sortedColumnKey="name" diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx index 127ba54936..5a7e1434c1 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSubForms/SharedFields.jsx @@ -51,7 +51,7 @@ export const ScmCredentialFormField = withI18n()( value={credential.value} onChange={value => { onCredentialSelection('scm', value); - form.setFieldValue('credential', value.id); + form.setFieldValue('credential', value ? value.id : ''); }} /> )} From 5a207f155edb1d8e1536a36a88aa29c9fe859f61 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 14 Nov 2019 15:35:08 -0800 Subject: [PATCH 042/109] WIP split Lookup into Lookup & CategoryLookup --- .../src/components/Lookup/CategoryLookup.jsx | 348 ++++++++++++++++++ .../Lookup/InstanceGroupsLookup.jsx | 146 +++++--- awx/ui_next/src/components/Lookup/Lookup.jsx | 202 ++-------- .../Lookup/MultiCredentialsLookup.jsx | 62 +++- awx/ui_next/src/components/Lookup/index.js | 1 + .../components/SelectedList/SelectedList.jsx | 3 + .../Template/shared/JobTemplateForm.jsx | 2 +- 7 files changed, 516 insertions(+), 248 deletions(-) create mode 100644 awx/ui_next/src/components/Lookup/CategoryLookup.jsx diff --git a/awx/ui_next/src/components/Lookup/CategoryLookup.jsx b/awx/ui_next/src/components/Lookup/CategoryLookup.jsx new file mode 100644 index 0000000000..f3e8935bdf --- /dev/null +++ b/awx/ui_next/src/components/Lookup/CategoryLookup.jsx @@ -0,0 +1,348 @@ +import React, { Fragment } from 'react'; +import { + string, + bool, + arrayOf, + func, + number, + oneOfType, + shape, +} from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { SearchIcon } from '@patternfly/react-icons'; +import { + Button, + ButtonVariant, + InputGroup as PFInputGroup, + Modal, + ToolbarItem, +} from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; + +import AnsibleSelect from '../AnsibleSelect'; +import PaginatedDataList from '../PaginatedDataList'; +import VerticalSeperator from '../VerticalSeparator'; +import DataListToolbar from '../DataListToolbar'; +import CheckboxListItem from '../CheckboxListItem'; +import SelectedList from '../SelectedList'; +import { ChipGroup, Chip, CredentialChip } from '../Chip'; +import { QSConfig } from '@types'; + +const SearchButton = styled(Button)` + ::after { + border: var(--pf-c-button--BorderWidth) solid + var(--pf-global--BorderColor--200); + } +`; + +const InputGroup = styled(PFInputGroup)` + ${props => + props.multiple && + ` + --pf-c-form-control--Height: 90px; + overflow-y: auto; + `} +`; + +const ChipHolder = styled.div` + --pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200); + --pf-c-form-control--BorderRightColor: var(--pf-global--BorderColor--200); + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +`; + +class CategoryLookup extends React.Component { + constructor(props) { + super(props); + + this.assertCorrectValueType(); + let selectedItems = []; + if (props.value) { + selectedItems = props.multiple ? [...props.value] : [props.value]; + } + this.state = { + isModalOpen: false, + selectedItems, + error: null, + }; + this.handleModalToggle = this.handleModalToggle.bind(this); + this.addItem = this.addItem.bind(this); + this.removeItem = this.removeItem.bind(this); + this.saveModal = this.saveModal.bind(this); + this.clearQSParams = this.clearQSParams.bind(this); + } + + assertCorrectValueType() { + const { multiple, value, selectCategoryOptions } = this.props; + if (selectCategoryOptions) { + return; + } + if (!multiple && Array.isArray(value)) { + throw new Error( + 'CategoryLookup value must not be an array unless `multiple` is set' + ); + } + if (multiple && !Array.isArray(value)) { + throw new Error( + 'CategoryLookup value must be an array if `multiple` is set' + ); + } + } + + removeItem(row) { + const { selectedItems } = this.state; + const { onToggleItem } = this.props; + if (onToggleItem) { + this.setState({ selectedItems: onToggleItem(selectedItems, row) }); + return; + } + this.setState({ + selectedItems: selectedItems.filter(item => item.id !== row.id), + }); + } + + addItem(row) { + const { selectedItems } = this.state; + const { multiple, onToggleItem } = this.props; + if (onToggleItem) { + this.setState({ selectedItems: onToggleItem(selectedItems, row) }); + return; + } + const index = selectedItems.findIndex(item => item.id === row.id); + + if (!multiple) { + this.setState({ selectedItems: [row] }); + return; + } + if (index > -1) { + return; + } + this.setState({ selectedItems: [...selectedItems, row] }); + } + + // TODO: clean up + handleModalToggle() { + const { isModalOpen } = this.state; + const { value, multiple, selectCategory } = this.props; + // Resets the selected items from parent state whenever modal is opened + // This handles the case where the user closes/cancels the modal and + // opens it again + if (!isModalOpen) { + let selectedItems = []; + if (value) { + selectedItems = multiple ? [...value] : [value]; + } + this.setState({ selectedItems }); + } else { + this.clearQSParams(); + if (selectCategory) { + selectCategory(null, 'Machine'); + } + } + this.setState(prevState => ({ + isModalOpen: !prevState.isModalOpen, + })); + } + + removeItemAndSave(row) { + const { value, onChange, multiple } = this.props; + if (multiple) { + onChange(value.filter(item => item.id !== row.id)); + } else if (value.id === row.id) { + onChange(null); + } + } + + saveModal() { + const { onChange, multiple } = this.props; + const { selectedItems } = this.state; + const value = multiple ? selectedItems : selectedItems[0] || null; + + this.handleModalToggle(); + onChange(value); + } + + clearQSParams() { + const { qsConfig, history } = this.props; + const parts = history.location.search.replace(/^\?/, '').split('&'); + const ns = qsConfig.namespace; + const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); + history.push(`${history.location.pathname}?${otherParts.join('&')}`); + } + + render() { + const { isModalOpen, selectedItems, error } = this.state; + const { + id, + items, + count, + lookupHeader, + value, + columns, + multiple, + name, + onBlur, + qsConfig, + required, + selectCategory, + selectCategoryOptions, + selectedCategory, + i18n, + } = this.props; + const header = lookupHeader || i18n._(t`Items`); + const canDelete = !required || (multiple && value.length > 1); + const chips = () => { + return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + credential={chip} + /> + ))} + + ) : ( + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + > + {chip.name} + + ))} + + ); + }; + return ( + + + + + + + {value ? chips(value) : null} + + + + {i18n._(t`Select`)} + , + , + ]} + > + {selectCategoryOptions && selectCategoryOptions.length > 0 && ( + + Selected Category + + + + )} + {selectedItems.length > 0 && ( + 0 + } + /> + )} + ( + i.id === item.id)} + onSelect={() => this.addItem(item)} + isRadio={ + !multiple || + (selectCategoryOptions && + selectCategoryOptions.length && + selectedCategory.value !== 'Vault') + } + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + {error ?
error: {error.message}
: ''} +
+
+ ); + } +} + +const Item = shape({ + id: number.isRequired, +}); + +CategoryLookup.propTypes = { + id: string, + items: arrayOf(shape({})).isRequired, + // TODO: change to `header` + lookupHeader: string, + name: string, + onChange: func.isRequired, + value: oneOfType([Item, arrayOf(Item)]), + multiple: bool, + required: bool, + qsConfig: QSConfig.isRequired, + selectCategory: func.isRequired, + selectCategoryOptions: oneOfType(shape({})).isRequired, + selectedCategory: shape({}).isRequired, +}; + +CategoryLookup.defaultProps = { + id: 'lookup-search', + lookupHeader: null, + name: null, + value: null, + multiple: false, + required: false, +}; + +export { CategoryLookup as _CategoryLookup }; +export default withI18n()(withRouter(CategoryLookup)); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index c720ae2363..6641294235 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -1,84 +1,114 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useState, useEffect } from 'react'; +import { arrayOf, string, func, object } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup, Tooltip } from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; - import { InstanceGroupsAPI } from '@api'; import Lookup from '@components/Lookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; const QuestionCircleIcon = styled(PFQuestionCircleIcon)` margin-left: 10px; `; -const getInstanceGroups = async params => InstanceGroupsAPI.read(params); +const QS_CONFIG = getQSConfig('instance-groups', { + page: 1, + page_size: 5, + order_by: 'name', +}); +// const getInstanceGroups = async params => InstanceGroupsAPI.read(params); -class InstanceGroupsLookup extends React.Component { - render() { - const { value, tooltip, onChange, className, i18n } = this.props; +function InstanceGroupsLookup({ + value, + onChange, + tooltip, + className, + history, + i18n, +}) { + const [instanceGroups, setInstanceGroups] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); - /* + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await InstanceGroupsAPI.read(params); + setInstanceGroups(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + + /* Wrapping
added to workaround PF bug: https://github.com/patternfly/patternfly-react/issues/2855 */ - return ( -
- - {tooltip && ( - - - - )} - - -
- ); - } + return ( +
+ + {tooltip && ( + + + + )} + + {error ?
error {error.message}
: ''} +
+
+ ); } InstanceGroupsLookup.propTypes = { - value: PropTypes.arrayOf(PropTypes.object).isRequired, - tooltip: PropTypes.string, - onChange: PropTypes.func.isRequired, + value: arrayOf(object).isRequired, + tooltip: string, + onChange: func.isRequired, + className: string, }; InstanceGroupsLookup.defaultProps = { tooltip: '', + className: '', }; -export default withI18n()(InstanceGroupsLookup); +export default withI18n()(withRouter(InstanceGroupsLookup)); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index b1913e0100..7446d8f09f 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -15,20 +15,17 @@ import { ButtonVariant, InputGroup as PFInputGroup, Modal, - ToolbarItem, } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import AnsibleSelect from '../AnsibleSelect'; import PaginatedDataList from '../PaginatedDataList'; -import VerticalSeperator from '../VerticalSeparator'; import DataListToolbar from '../DataListToolbar'; import CheckboxListItem from '../CheckboxListItem'; import SelectedList from '../SelectedList'; -import { ChipGroup, Chip, CredentialChip } from '../Chip'; -import { getQSConfig, parseQueryString } from '../../util/qs'; +import { ChipGroup, Chip } from '../Chip'; +import { QSConfig } from '@types'; const SearchButton = styled(Button)` ::after { @@ -66,42 +63,16 @@ class Lookup extends React.Component { this.state = { isModalOpen: false, selectedItems, - results: [], - count: 0, - error: null, }; - this.qsConfig = getQSConfig(props.qsNamespace, { - page: 1, - page_size: 5, - order_by: props.sortedColumnKey, - }); this.handleModalToggle = this.handleModalToggle.bind(this); this.addItem = this.addItem.bind(this); this.removeItem = this.removeItem.bind(this); this.saveModal = this.saveModal.bind(this); - this.getData = this.getData.bind(this); this.clearQSParams = this.clearQSParams.bind(this); } - componentDidMount() { - this.getData(); - } - - componentDidUpdate(prevProps) { - const { location, selectedCategory } = this.props; - if ( - location !== prevProps.location || - prevProps.selectedCategory !== selectedCategory - ) { - this.getData(); - } - } - assertCorrectValueType() { - const { multiple, value, selectCategoryOptions } = this.props; - if (selectCategoryOptions) { - return; - } + const { multiple, value } = this.props; if (!multiple && Array.isArray(value)) { throw new Error( 'Lookup value must not be an array unless `multiple` is set' @@ -112,27 +83,6 @@ class Lookup extends React.Component { } } - async getData() { - const { - getItems, - location: { search }, - } = this.props; - const queryParams = parseQueryString(this.qsConfig, search); - - this.setState({ error: false }); - try { - const { data } = await getItems(queryParams); - const { results, count } = data; - - this.setState({ - results, - count, - }); - } catch (err) { - this.setState({ error: true }); - } - } - removeItem(row) { const { selectedItems } = this.state; const { onToggleItem } = this.props; @@ -163,55 +113,11 @@ class Lookup extends React.Component { } this.setState({ selectedItems: [...selectedItems, row] }); } - // toggleSelected(row) { - // const { - // name, - // onChange, - // multiple, - // onToggleItem, - // selectCategoryOptions, - // onChange, - // value - // } = this.props; - // const { - // selectedItems: updatedSelectedItems, - // isModalOpen, - // } = this.state; - - // const selectedIndex = updatedSelectedItems.findIndex( - // selectedRow => selectedRow.id === row.id - // ); - // - // if (multiple) { - // - // if (selectCategoryOptions) { - // - // onToggleItem(row, isModalOpen); - // } - // if (selectedIndex > -1) { - // - // const valueToUpdate = value.filter(itemValue => itemValue.id !==row.id ); - // onChange(valueToUpdate) - // } else { - // - // onChange([...value, row]) - // } - // } else { - // - // onChange(row) - // } - - // Updates the selected items from parent state - // This handles the case where the user removes chips from the lookup input - // while the modal is closed - // if (!isModalOpen) { - // onChange(updatedSelectedItems, name); - // } - // } + // TODO: cleanup handleModalToggle() { const { isModalOpen } = this.state; - const { value, multiple, selectCategory } = this.props; + const { value, multiple } = this.props; // Resets the selected items from parent state whenever modal is opened // This handles the case where the user closes/cancels the modal and // opens it again @@ -223,20 +129,17 @@ class Lookup extends React.Component { this.setState({ selectedItems }); } else { this.clearQSParams(); - if (selectCategory) { - selectCategory(null, 'Machine'); - } } this.setState(prevState => ({ isModalOpen: !prevState.isModalOpen, })); } - removeItemAndSave(row) { + removeItemAndSave(item) { const { value, onChange, multiple } = this.props; if (multiple) { - onChange(value.filter(item => item.id !== row.id)); - } else if (value.id === row.id) { + onChange(value.filter(i => i.id !== item.id)); + } else if (value.id === item.id) { onChange(null); } } @@ -251,58 +154,31 @@ class Lookup extends React.Component { } clearQSParams() { - const { history } = this.props; + const { qsConfig, history } = this.props; const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = this.qsConfig.namespace; + const ns = qsConfig.namespace; const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); history.push(`${history.location.pathname}?${otherParts.join('&')}`); } render() { - const { isModalOpen, selectedItems, error, results, count } = this.state; + const { isModalOpen, selectedItems } = this.state; const { - form, id, lookupHeader, value, + items, + count, columns, multiple, name, onBlur, - selectCategory, required, + qsConfig, i18n, - selectCategoryOptions, - selectedCategory, } = this.props; const header = lookupHeader || i18n._(t`Items`); const canDelete = !required || (multiple && value.length > 1); - const chips = () => { - return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - credential={chip} - /> - ))} - - ) : ( - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - ); - }; return ( @@ -315,7 +191,17 @@ class Lookup extends React.Component { - {value ? chips(value) : null} + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + > + {chip.name} + + ))} + , ]} > - {selectCategoryOptions && selectCategoryOptions.length > 0 && ( - - Selected Category - - - - )} {selectedItems.length > 0 && ( 0 - } /> )} ( i.id === item.id)} onSelect={() => this.addItem(item)} - isRadio={ - !multiple || - (selectCategoryOptions && - selectCategoryOptions.length && - selectedCategory.value !== 'Vault') - } + isRadio={!multiple} /> )} renderToolbar={props => } showPageSizeOptions={false} /> - {error ?
error
: ''}
); @@ -406,15 +268,16 @@ const Item = shape({ Lookup.propTypes = { id: string, - getItems: func.isRequired, + items: arrayOf(shape({})).isRequired, + count: number.isRequired, + // TODO: change to `header` lookupHeader: string, name: string, onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), - sortedColumnKey: string.isRequired, multiple: bool, required: bool, - qsNamespace: string, + qsConfig: QSConfig.isRequired, }; Lookup.defaultProps = { @@ -424,7 +287,6 @@ Lookup.defaultProps = { value: null, multiple: false, required: false, - qsNamespace: 'lookup', }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 9e40ebccec..0277aa0ad3 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -1,22 +1,29 @@ import React from 'react'; +import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup, Tooltip } from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; - import { CredentialsAPI, CredentialTypesAPI } from '@api'; -import Lookup from '@components/Lookup'; +import CategoryLookup from '@components/Lookup/CategoryLookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; const QuestionCircleIcon = styled(PFQuestionCircleIcon)` margin-left: 10px; `; + +const QS_CONFIG = getQSConfig('credentials', { + page: 1, + page_size: 5, + order_by: 'name', +}); + function toggleCredentialSelection(credentialsToUpdate, newCredential) { let newCredentialsList; const isSelectedCredentialInState = - credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > - 0; + credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > 0; if (isSelectedCredentialInState) { newCredentialsList = credentialsToUpdate.filter( @@ -31,6 +38,7 @@ function toggleCredentialSelection(credentialsToUpdate, newCredential) { } return newCredentialsList; } + class MultiCredentialsLookup extends React.Component { constructor(props) { super(props); @@ -48,6 +56,7 @@ class MultiCredentialsLookup extends React.Component { componentDidMount() { this.loadCredentialTypes(); + this.loadCredentials(); } async loadCredentialTypes() { @@ -80,23 +89,38 @@ class MultiCredentialsLookup extends React.Component { } } - async loadCredentials(params) { + async loadCredentials() { + const { history, onError } = this.props; const { selectedCredentialType } = this.state; + const params = parseQueryString(QS_CONFIG, history.location.search); params.credential_type = selectedCredentialType.id || 1; - return CredentialsAPI.read(params); + try { + const { data } = await CredentialsAPI.read(params); + this.setState({ + credentials: data.results, + count: data.count, + }); + } catch (err) { + onError(err); + } } - - handleCredentialTypeSelect(value, type) { const { credentialTypes } = this.state; const selectedType = credentialTypes.filter(item => item.label === type); - this.setState({ selectedCredentialType: selectedType[0] }); + this.setState({ selectedCredentialType: selectedType[0] }, () => { + this.loadCredentials(); + }); } render() { - const { selectedCredentialType, credentialTypes } = this.state; - const { tooltip, i18n, credentials, onChange } = this.props; + const { + selectedCredentialType, + credentialTypes, + credentials, + count, + } = this.state; + const { tooltip, i18n, value, onChange } = this.props; return ( {tooltip && ( @@ -105,7 +129,7 @@ class MultiCredentialsLookup extends React.Component { )} {credentialTypes && ( - )} @@ -137,7 +161,7 @@ class MultiCredentialsLookup extends React.Component { MultiCredentialsLookup.propTypes = { tooltip: PropTypes.string, - credentials: PropTypes.arrayOf( + value: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -152,8 +176,8 @@ MultiCredentialsLookup.propTypes = { MultiCredentialsLookup.defaultProps = { tooltip: '', - credentials: [], + value: [], }; export { MultiCredentialsLookup as _MultiCredentialsLookup }; -export default withI18n()(MultiCredentialsLookup); +export default withI18n()(withRouter(MultiCredentialsLookup)); diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index cde48e2bcd..5e2959c00e 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -1,4 +1,5 @@ export { default } from './Lookup'; +export { default as CategoryLookup } from './CategoryLookup'; export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; export { default as InventoryLookup } from './InventoryLookup'; export { default as ProjectLookup } from './ProjectLookup'; diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index 8d7c716ef9..683404d35f 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -28,6 +28,7 @@ class SelectedList extends Component { isReadOnly, isCredentialList, } = this.props; + // TODO: replace isCredentialList with renderChip ? const chips = isCredentialList ? selected.map(item => ( null, isReadOnly: false, + isCredentialList: false, }; export default SelectedList; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 846d7f4fb8..67e94ced20 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -317,7 +317,7 @@ class JobTemplateForm extends Component { fieldId="template-credentials" render={({ field }) => ( setFieldValue('credentials', newCredentials) } From 8ec856f3b6fa939b38da19264ea22ca0cd622a17 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 18 Nov 2019 15:20:58 -0800 Subject: [PATCH 043/109] start Lookup reducer --- .../CheckboxListItem/CheckboxListItem.jsx | 4 +- .../src/components/Lookup/CategoryLookup.jsx | 73 ++--- .../Lookup/InstanceGroupsLookup.jsx | 2 +- awx/ui_next/src/components/Lookup/Lookup.jsx | 42 +-- .../src/components/Lookup/NewLookup.jsx | 192 +++++++++++++ awx/ui_next/src/components/Lookup/README.md | 5 + .../components/Lookup/shared/SelectList.jsx | 84 ++++++ .../src/components/Lookup/shared/reducer.js | 110 ++++++++ .../components/Lookup/shared/reducer.test.js | 262 ++++++++++++++++++ 9 files changed, 696 insertions(+), 78 deletions(-) create mode 100644 awx/ui_next/src/components/Lookup/NewLookup.jsx create mode 100644 awx/ui_next/src/components/Lookup/README.md create mode 100644 awx/ui_next/src/components/Lookup/shared/SelectList.jsx create mode 100644 awx/ui_next/src/components/Lookup/shared/reducer.js create mode 100644 awx/ui_next/src/components/Lookup/shared/reducer.test.js diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx index 65958e47e8..9508672789 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx @@ -16,6 +16,7 @@ const CheckboxListItem = ({ label, isSelected, onSelect, + onDeselect, isRadio, }) => { const CheckboxRadio = isRadio ? DataListRadio : DataListCheck; @@ -25,7 +26,7 @@ const CheckboxListItem = ({ 1); - const chips = () => { - return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - credential={chip} - /> - ))} - - ) : ( - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - ); - }; return ( @@ -231,7 +205,16 @@ class CategoryLookup extends React.Component { - {value ? chips(value) : null} + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + credential={chip} + /> + ))} + , ]} > - {selectedItems.length > 0 && ( - - )} - this.setState({ selectedItems: newVal })} + options={items} + optionCount={count} + columns={columns} + multiple={multiple} + header={lookupHeader} + name={name} qsConfig={qsConfig} - toolbarColumns={columns} - renderItem={item => ( - i.id === item.id)} - onSelect={() => this.addItem(item)} - isRadio={!multiple} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} + readOnly={!canDelete} /> diff --git a/awx/ui_next/src/components/Lookup/NewLookup.jsx b/awx/ui_next/src/components/Lookup/NewLookup.jsx new file mode 100644 index 0000000000..0721e4e4d1 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/NewLookup.jsx @@ -0,0 +1,192 @@ +import React, { Fragment, useReducer, useEffect } from 'react'; +import { + string, + bool, + arrayOf, + func, + number, + oneOfType, + shape, +} from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { SearchIcon } from '@patternfly/react-icons'; +import { + Button, + ButtonVariant, + InputGroup as PFInputGroup, + Modal, +} from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; + +import reducer, { initReducer } from './shared/reducer'; +import SelectList from './shared/SelectList'; +import { ChipGroup, Chip } from '../Chip'; +import { QSConfig } from '@types'; + +const SearchButton = styled(Button)` + ::after { + border: var(--pf-c-button--BorderWidth) solid + var(--pf-global--BorderColor--200); + } +`; + +const InputGroup = styled(PFInputGroup)` + ${props => + props.multiple && + ` + --pf-c-form-control--Height: 90px; + overflow-y: auto; + `} +`; + +const ChipHolder = styled.div` + --pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200); + --pf-c-form-control--BorderRightColor: var(--pf-global--BorderColor--200); + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +`; + +function Lookup(props) { + const { + id, + items, + count, + header, + name, + onChange, + onBlur, + columns, + value, + multiple, + required, + qsConfig, + i18n, + } = props; + const [state, dispatch] = useReducer(reducer, props, initReducer); + + useEffect(() => { + dispatch({ type: 'SET_MULTIPLE', value: multiple }); + }, [multiple]); + + useEffect(() => { + dispatch({ type: 'SET_VALUE', value }); + }, [value]); + + const save = () => { + const { selectedItems } = state; + const val = multiple ? selectedItems : selectedItems[0] || null; + onChange(val); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const removeItem = item => { + if (multiple) { + onChange(value.filter(i => i.id !== item.id)); + } else { + onChange(null); + } + }; + + const { isModalOpen, selectedItems } = state; + + const canDelete = !required || (multiple && value.length > 1); + return ( + + + dispatch({ type: 'TOGGLE_MODAL' })} + variant={ButtonVariant.tertiary} + > + + + + + {(multiple ? value : [value]).map(item => ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.name} + + ))} + + + + dispatch({ type: 'TOGGLE_MODAL' })} + actions={[ + , + , + ]} + > + + + + ); +} + +const Item = shape({ + id: number.isRequired, +}); + +Lookup.propTypes = { + id: string, + items: arrayOf(shape({})).isRequired, + count: number.isRequired, + // TODO: change to `header` + header: string, + name: string, + onChange: func.isRequired, + value: oneOfType([Item, arrayOf(Item)]), + multiple: bool, + required: bool, + onBlur: func, + qsConfig: QSConfig.isRequired, +}; + +Lookup.defaultProps = { + id: 'lookup-search', + header: null, + name: null, + value: null, + multiple: false, + required: false, + onBlur: () => {}, +}; + +export { Lookup as _Lookup }; +export default withI18n()(withRouter(Lookup)); diff --git a/awx/ui_next/src/components/Lookup/README.md b/awx/ui_next/src/components/Lookup/README.md new file mode 100644 index 0000000000..4d5dc69674 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/README.md @@ -0,0 +1,5 @@ +# Lookup + +required single select lookups should not include a close X on the tag... you would have to select something else to change it + +optional single select lookups should include a close X to remove it on the spot diff --git a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx new file mode 100644 index 0000000000..96db387c72 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { + arrayOf, + shape, + bool, + func, + number, + string, + oneOfType, +} from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import SelectedList from '../../SelectedList'; +import PaginatedDataList from '../../PaginatedDataList'; +import CheckboxListItem from '../../CheckboxListItem'; +import DataListToolbar from '../../DataListToolbar'; +import { QSConfig } from '@types'; + +function SelectList({ + value, + options, + optionCount, + columns, + multiple, + header, + name, + qsConfig, + readOnly, + dispatch, + i18n, +}) { + return ( +
+ {value.length > 0 && ( + dispatch({ type: 'DESELECT_ITEM', item })} + isReadOnly={readOnly} + /> + )} + ( + i.id === item.id)} + onSelect={() => dispatch({ type: 'SELECT_ITEM', item })} + onDeselect={() => dispatch({ type: 'DESELECT_ITEM', item })} + isRadio={!multiple} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); +} + +const Item = shape({ + id: oneOfType([number, string]).isRequired, +}); +SelectList.propTypes = { + value: arrayOf(Item).isRequired, + options: arrayOf(Item).isRequired, + optionCount: number.isRequired, + columns: arrayOf(shape({})).isRequired, + multiple: bool, + qsConfig: QSConfig.isRequired, + dispatch: func.isRequired, +}; +SelectList.defaultProps = { + multiple: false, +}; + +export default withI18n()(SelectList); diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.js b/awx/ui_next/src/components/Lookup/shared/reducer.js new file mode 100644 index 0000000000..2e2c88f096 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/reducer.js @@ -0,0 +1,110 @@ +export default function reducer(state, action) { + // console.log(action, state); + switch (action.type) { + case 'SELECT_ITEM': + return selectItem(state, action.item); + case 'DESELECT_ITEM': + return deselectItem(state, action.item); + case 'TOGGLE_MODAL': + return toggleModal(state); + case 'CLOSE_MODAL': + return closeModal(state); + case 'SET_MULTIPLE': + return { ...state, multiple: action.value }; + case 'SET_VALUE': + return { ...state, value: action.value }; + default: + throw new Error(`Unrecognized action type: ${action.type}`); + } +} + +function selectItem(state, item) { + const { selectedItems, multiple } = state; + if (!multiple) { + return { + ...state, + selectedItems: [item], + }; + } + const index = selectedItems.findIndex(i => i.id === item.id); + if (index > -1) { + return state; + } + return { + ...state, + selectedItems: [...selectedItems, item], + }; +} + +function deselectItem(state, item) { + return { + ...state, + selectedItems: state.selectedItems.filter(i => i.id !== item.id), + }; +} + +function toggleModal(state) { + const { isModalOpen, value, multiple } = state; + if (isModalOpen) { + return closeModal(state); + } + return { + ...state, + isModalOpen: !isModalOpen, + selectedItems: multiple ? [...value] : [value], + }; +} + +function closeModal(state) { + // TODO clear QSParams & push history state? + // state.clearQSParams(); + return { + ...state, + isModalOpen: false, + }; +} +// clearQSParams() { +// const { qsConfig, history } = this.props; +// const parts = history.location.search.replace(/^\?/, '').split('&'); +// const ns = qsConfig.namespace; +// const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); +// history.push(`${history.location.pathname}?${otherParts.join('&')}`); +// } + +export function initReducer({ + id, + items, + count, + header, + name, + onChange, + value, + multiple = false, + required = false, + qsConfig, +}) { + assertCorrectValueType(value, multiple); + let selectedItems = []; + if (value) { + selectedItems = multiple ? [...value] : [value]; + } + return { + selectedItems, + value, + multiple, + isModalOpen: false, + required, + onChange, + }; +} + +function assertCorrectValueType(value, multiple) { + if (!multiple && Array.isArray(value)) { + throw new Error( + 'Lookup value must not be an array unless `multiple` is set' + ); + } + if (multiple && !Array.isArray(value)) { + throw new Error('Lookup value must be an array if `multiple` is set'); + } +} diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.test.js b/awx/ui_next/src/components/Lookup/shared/reducer.test.js new file mode 100644 index 0000000000..22bf9da106 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/reducer.test.js @@ -0,0 +1,262 @@ +import reducer, { initReducer } from './reducer'; + +describe('Lookup reducer', () => { + describe('SELECT_ITEM', () => { + it('should add item to selected items (multiple select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 2 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }); + }); + + it('should not duplicate item if already selected (multiple select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }], + multiple: true, + }); + }); + + it('should replace selected item (single select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: false, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 2 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 2 }], + multiple: false, + }); + }); + + it('should not duplicate item if already selected (single select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: false, + }; + const result = reducer(state, { + type: 'SELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }], + multiple: false, + }); + }); + }); + + describe('DESELECT_ITEM', () => { + it('should de-select item (multiple)', () => { + const state = { + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }; + const result = reducer(state, { + type: 'DESELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 2 }], + multiple: true, + }); + }); + + it('should not change list if item not selected (multiple)', () => { + const state = { + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }; + const result = reducer(state, { + type: 'DESELECT_ITEM', + item: { id: 3 }, + }); + expect(result).toEqual({ + selectedItems: [{ id: 1 }, { id: 2 }], + multiple: true, + }); + }); + + it('should de-select item (single select)', () => { + const state = { + selectedItems: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'DESELECT_ITEM', + item: { id: 1 }, + }); + expect(result).toEqual({ + selectedItems: [], + multiple: true, + }); + }); + }); + + describe('TOGGLE_MODAL', () => { + it('should open the modal (single)', () => { + const state = { + isModalOpen: false, + selectedItems: [], + value: { id: 1 }, + multiple: false, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: { id: 1 }, + multiple: false, + }); + }); + + it('should open the modal (multiple)', () => { + const state = { + isModalOpen: false, + selectedItems: [], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + + it('should close the modal', () => { + const state = { + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + }); + + describe('CLOSE_MODAL', () => { + it('should close the modal', () => { + const state = { + isModalOpen: true, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'CLOSE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + }); + + describe('SET_MULTIPLE', () => { + it('should set multiple to true', () => { + const state = { + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: false, + }; + const result = reducer(state, { + type: 'SET_MULTIPLE', + value: true, + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }); + }); + + it('should set multiple to false', () => { + const state = { + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SET_MULTIPLE', + value: false, + }); + expect(result).toEqual({ + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: [{ id: 1 }], + multiple: false, + }); + }); + }); + + describe('SET_VALUE', () => { + it('should set the value', () => { + const state = { + value: [{ id: 1 }], + multiple: true, + }; + const result = reducer(state, { + type: 'SET_VALUE', + value: [{ id: 3 }], + }); + expect(result).toEqual({ + value: [{ id: 3 }], + multiple: true, + }); + }); + }); +}); + +describe('initReducer', () => { + it('should init', () => { + const state = initReducer({ + value: [], + multiple: true, + required: true, + }); + expect(state).toEqual({ + selectedItems: [], + value: [], + multiple: true, + isModalOpen: false, + required: true, + }); + }); +}); From 62606339748a71d112a7ba958b9824a9ce521b3c Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 21 Nov 2019 16:11:19 -0800 Subject: [PATCH 044/109] flushing out new approach to MultiCredentialsLookup --- .../Lookup/InstanceGroupsLookup.jsx | 125 ++++---- .../Lookup/MultiCredentialsLookup.jsx | 289 ++++++++++-------- .../src/components/Lookup/NewLookup.jsx | 93 +++--- .../components/Lookup/shared/SelectList.jsx | 12 +- .../src/components/Lookup/shared/reducer.js | 40 ++- 5 files changed, 305 insertions(+), 254 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 089ec6d969..8071c8b26f 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -3,32 +3,21 @@ import { arrayOf, string, func, object } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup, Tooltip } from '@patternfly/react-core'; -import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; +import { FormGroup } from '@patternfly/react-core'; import { InstanceGroupsAPI } from '@api'; import { getQSConfig, parseQueryString } from '@util/qs'; +import { FieldTooltip } from '@components/FormField'; import Lookup from './NewLookup'; +import SelectList from './shared/SelectList'; -const QuestionCircleIcon = styled(PFQuestionCircleIcon)` - margin-left: 10px; -`; - -const QS_CONFIG = getQSConfig('instance-groups', { +const QS_CONFIG = getQSConfig('instance_groups', { page: 1, page_size: 5, order_by: 'name', }); -// const getInstanceGroups = async params => InstanceGroupsAPI.read(params); -function InstanceGroupsLookup({ - value, - onChange, - tooltip, - className, - history, - i18n, -}) { +function InstanceGroupsLookup(props) { + const { value, onChange, tooltip, className, history, i18n } = props; const [instanceGroups, setInstanceGroups] = useState([]); const [count, setCount] = useState(0); const [error, setError] = useState(null); @@ -46,56 +35,62 @@ function InstanceGroupsLookup({ })(); }, [history.location]); - /* - Wrapping
added to workaround PF bug: - https://github.com/patternfly/patternfly-react/issues/2855 - */ return ( -
- - {tooltip && ( - - - + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> )} - - {error ?
error {error.message}
: ''} -
-
+ /> + {error ?
error {error.message}
: ''} + ); } diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 0277aa0ad3..005c8d620d 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -1,18 +1,18 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { FormGroup, Tooltip } from '@patternfly/react-core'; -import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; -import styled from 'styled-components'; +import { FormGroup, ToolbarItem } from '@patternfly/react-core'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; -import CategoryLookup from '@components/Lookup/CategoryLookup'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { FieldTooltip } from '@components/FormField'; +import { CredentialChip } from '@components/Chip'; +import VerticalSeperator from '@components/VerticalSeparator'; import { getQSConfig, parseQueryString } from '@util/qs'; - -const QuestionCircleIcon = styled(PFQuestionCircleIcon)` - margin-left: 10px; -`; +import Lookup from './NewLookup'; +import SelectList from './shared/SelectList'; +import multiCredentialReducer from './shared/multiCredentialReducer'; const QS_CONFIG = getQSConfig('credentials', { page: 1, @@ -20,6 +20,7 @@ const QS_CONFIG = getQSConfig('credentials', { order_by: 'name', }); +// TODO: move into reducer function toggleCredentialSelection(credentialsToUpdate, newCredential) { let newCredentialsList; const isSelectedCredentialInState = @@ -39,124 +40,164 @@ function toggleCredentialSelection(credentialsToUpdate, newCredential) { return newCredentialsList; } -class MultiCredentialsLookup extends React.Component { - constructor(props) { - super(props); - - this.state = { - selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' }, - credentialTypes: [], - }; - this.loadCredentialTypes = this.loadCredentialTypes.bind(this); - this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind( - this - ); - this.loadCredentials = this.loadCredentials.bind(this); - } - - componentDidMount() { - this.loadCredentialTypes(); - this.loadCredentials(); - } - - async loadCredentialTypes() { - const { onError } = this.props; - try { - const { data } = await CredentialTypesAPI.read(); - const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; - const credentialTypes = []; - data.results.forEach(cred => { - acceptableTypes.forEach(aT => { - if (aT === cred.kind) { - // This object has several repeated values as some of it's children - // require different field values. - cred = { - id: cred.id, - key: cred.id, - kind: cred.kind, - type: cred.namespace, - value: cred.name, - label: cred.name, - isDisabled: false, - }; - credentialTypes.push(cred); - } - }); - }); - this.setState({ credentialTypes }); - } catch (err) { - onError(err); - } - } - - async loadCredentials() { - const { history, onError } = this.props; - const { selectedCredentialType } = this.state; - const params = parseQueryString(QS_CONFIG, history.location.search); - params.credential_type = selectedCredentialType.id || 1; - try { - const { data } = await CredentialsAPI.read(params); - this.setState({ - credentials: data.results, - count: data.count, - }); - } catch (err) { - onError(err); - } - } - - handleCredentialTypeSelect(value, type) { - const { credentialTypes } = this.state; - const selectedType = credentialTypes.filter(item => item.label === type); - this.setState({ selectedCredentialType: selectedType[0] }, () => { - this.loadCredentials(); +async function loadCredentialTypes() { + const { data } = await CredentialTypesAPI.read(); + const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; + const credentialTypes = []; + // TODO: cleanup + data.results.forEach(cred => { + acceptableTypes.forEach(aT => { + if (aT === cred.kind) { + // This object has several repeated values as some of it's children + // require different field values. + cred = { + id: cred.id, + key: cred.id, + kind: cred.kind, + type: cred.namespace, + value: cred.name, + label: cred.name, + isDisabled: false, + }; + credentialTypes.push(cred); + } }); - } + }); + return credentialTypes; +} - render() { - const { - selectedCredentialType, - credentialTypes, - credentials, - count, - } = this.state; - const { tooltip, i18n, value, onChange } = this.props; - return ( - - {tooltip && ( - - - - )} - {credentialTypes && ( - { + (async () => { + try { + const types = await loadCredentialTypes(); + setCredentialTypes(types); + setSelectedType(types[0]); + } catch (err) { + onError(err); + } + })(); + }, []); + + useEffect(() => { + console.log('useEffect', selectedType); + (async () => { + if (!selectedType) { + return; + } + try { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { results, count } = await loadCredentials( + params, + selectedType.id + ); + setCredentials(results); + setCredentialsCount(count); + } catch (err) { + onError(err); + } + })(); + }, [selectedType]); + + // handleCredentialTypeSelect(value, type) { + // const { credentialTypes } = this.state; + // const selectedType = credentialTypes.filter(item => item.label === type); + // this.setState({ selectedCredentialType: selectedType[0] }, () => { + // this.loadCredentials(); + // }); + // } + + // const { + // selectedCredentialType, + // credentialTypes, + // credentials, + // credentialsCount, + // } = state; + + return ( + + {tooltip && } + ( + removeItem(item)} + isReadOnly={!canDelete} + credential={item} /> )} - - ); - } + renderSelectList={({ state, dispatch, canDelete }) => { + return ( + <> + {credentialTypes && credentialTypes.length > 0 && ( + +
{i18n._(t`Selected Category`)}
+ + { + setSelectedType( + credentialTypes.find(o => o.label === label) + ); + }} + /> +
+ )} + {}} + deselectItem={() => {}} + /> + + ); + }} + /> +
+ ); } MultiCredentialsLookup.propTypes = { @@ -178,6 +219,6 @@ MultiCredentialsLookup.defaultProps = { tooltip: '', value: [], }; -export { MultiCredentialsLookup as _MultiCredentialsLookup }; +export { MultiCredentialsLookup as _MultiCredentialsLookup }; export default withI18n()(withRouter(MultiCredentialsLookup)); diff --git a/awx/ui_next/src/components/Lookup/NewLookup.jsx b/awx/ui_next/src/components/Lookup/NewLookup.jsx index 0721e4e4d1..0f3342e5b5 100644 --- a/awx/ui_next/src/components/Lookup/NewLookup.jsx +++ b/awx/ui_next/src/components/Lookup/NewLookup.jsx @@ -21,7 +21,6 @@ import { t } from '@lingui/macro'; import styled from 'styled-components'; import reducer, { initReducer } from './shared/reducer'; -import SelectList from './shared/SelectList'; import { ChipGroup, Chip } from '../Chip'; import { QSConfig } from '@types'; @@ -51,20 +50,28 @@ const ChipHolder = styled.div` function Lookup(props) { const { id, - items, - count, + // items, + // count, header, - name, + // name, onChange, onBlur, - columns, + // columns, value, multiple, required, qsConfig, + renderItemChip, + renderSelectList, + history, i18n, } = props; - const [state, dispatch] = useReducer(reducer, props, initReducer); + + const [state, dispatch] = useReducer( + reducer, + { value, multiple, required }, + initReducer + ); useEffect(() => { dispatch({ type: 'SET_MULTIPLE', value: multiple }); @@ -74,10 +81,18 @@ function Lookup(props) { dispatch({ type: 'SET_VALUE', value }); }, [value]); + const clearQSParams = () => { + const parts = history.location.search.replace(/^\?/, '').split('&'); + const ns = qsConfig.namespace; + const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); + history.push(`${history.location.pathname}?${otherParts.join('&')}`); + }; + const save = () => { const { selectedItems } = state; const val = multiple ? selectedItems : selectedItems[0] || null; onChange(val); + clearQSParams(); dispatch({ type: 'CLOSE_MODAL' }); }; @@ -89,8 +104,12 @@ function Lookup(props) { } }; - const { isModalOpen, selectedItems } = state; + const closeModal = () => { + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + const { isModalOpen, selectedItems } = state; const canDelete = !required || (multiple && value.length > 1); return ( @@ -105,15 +124,13 @@ function Lookup(props) { - {(multiple ? value : [value]).map(item => ( - removeItem(item)} - isReadOnly={!canDelete} - > - {item.name} - - ))} + {(multiple ? value : [value]).map(item => + renderItemChip({ + item, + removeItem, + canDelete, + }) + )} @@ -121,7 +138,7 @@ function Lookup(props) { className="awx-c-modal" title={i18n._(t`Select ${header || i18n._(t`Items`)}`)} isOpen={isModalOpen} - onClose={() => dispatch({ type: 'TOGGLE_MODAL' })} + onClose={closeModal} actions={[ , - , ]} > - + {renderSelectList({ + state, + dispatch, + canDelete, + })} ); @@ -165,27 +171,38 @@ const Item = shape({ Lookup.propTypes = { id: string, - items: arrayOf(shape({})).isRequired, - count: number.isRequired, + // items: arrayOf(shape({})).isRequired, + // count: number.isRequired, // TODO: change to `header` header: string, - name: string, + // name: string, onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), multiple: bool, required: bool, onBlur: func, qsConfig: QSConfig.isRequired, + renderItemChip: func, + renderSelectList: func.isRequired, }; Lookup.defaultProps = { id: 'lookup-search', header: null, - name: null, + // name: null, value: null, multiple: false, required: false, onBlur: () => {}, + renderItemChip: ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.name} + + ), }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx index 96db387c72..128d9371d8 100644 --- a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx @@ -26,7 +26,8 @@ function SelectList({ name, qsConfig, readOnly, - dispatch, + selectItem, + deselectItem, i18n, }) { return ( @@ -36,7 +37,7 @@ function SelectList({ label={i18n._(t`Selected`)} selected={value} showOverflowAfter={5} - onRemove={item => dispatch({ type: 'DESELECT_ITEM', item })} + onRemove={item => deselectItem(item)} isReadOnly={readOnly} /> )} @@ -53,8 +54,8 @@ function SelectList({ name={multiple ? item.name : name} label={item.name} isSelected={value.some(i => i.id === item.id)} - onSelect={() => dispatch({ type: 'SELECT_ITEM', item })} - onDeselect={() => dispatch({ type: 'DESELECT_ITEM', item })} + onSelect={() => selectItem(item)} + onDeselect={() => deselectItem(item)} isRadio={!multiple} /> )} @@ -75,7 +76,8 @@ SelectList.propTypes = { columns: arrayOf(shape({})).isRequired, multiple: bool, qsConfig: QSConfig.isRequired, - dispatch: func.isRequired, + selectItem: func.isRequired, + deselectItem: func.isRequired, }; SelectList.defaultProps = { multiple: false, diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.js b/awx/ui_next/src/components/Lookup/shared/reducer.js index 2e2c88f096..9f7f6b37e0 100644 --- a/awx/ui_next/src/components/Lookup/shared/reducer.js +++ b/awx/ui_next/src/components/Lookup/shared/reducer.js @@ -1,3 +1,5 @@ +// import { useReducer, useEffect } from 'react'; + export default function reducer(state, action) { // console.log(action, state); switch (action.type) { @@ -56,33 +58,13 @@ function toggleModal(state) { } function closeModal(state) { - // TODO clear QSParams & push history state? - // state.clearQSParams(); return { ...state, isModalOpen: false, }; } -// clearQSParams() { -// const { qsConfig, history } = this.props; -// const parts = history.location.search.replace(/^\?/, '').split('&'); -// const ns = qsConfig.namespace; -// const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); -// history.push(`${history.location.pathname}?${otherParts.join('&')}`); -// } -export function initReducer({ - id, - items, - count, - header, - name, - onChange, - value, - multiple = false, - required = false, - qsConfig, -}) { +export function initReducer({ value, multiple = false, required = false }) { assertCorrectValueType(value, multiple); let selectedItems = []; if (value) { @@ -94,7 +76,6 @@ export function initReducer({ multiple, isModalOpen: false, required, - onChange, }; } @@ -108,3 +89,18 @@ function assertCorrectValueType(value, multiple) { throw new Error('Lookup value must be an array if `multiple` is set'); } } +// +// export function useLookup(config) { +// const { value, multiple, required, onChange, history } = config; +// const [state, dispatch] = useReducer( +// config.reducer || reducer, +// { +// value, +// multiple, +// required, +// }, +// config.initReducer || initReducer +// ); +// +// return [state, dispatch]; +// } From 4341d67fb0f7aa6ea989f77ef7158b7b54e73135 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 25 Nov 2019 10:02:22 -0800 Subject: [PATCH 045/109] add MultiCredentialsLookup select/deselect logic --- .../Lookup/MultiCredentialsLookup.jsx | 40 ++++++++----------- .../src/components/Lookup/NewLookup.jsx | 5 --- .../src/components/Lookup/shared/reducer.js | 2 + 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 005c8d620d..7578205923 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -92,7 +92,6 @@ function MultiCredentialsLookup(props) { }, []); useEffect(() => { - console.log('useEffect', selectedType); (async () => { if (!selectedType) { return; @@ -111,21 +110,7 @@ function MultiCredentialsLookup(props) { })(); }, [selectedType]); - // handleCredentialTypeSelect(value, type) { - // const { credentialTypes } = this.state; - // const selectedType = credentialTypes.filter(item => item.label === type); - // this.setState({ selectedCredentialType: selectedType[0] }, () => { - // this.loadCredentials(); - // }); - // } - - // const { - // selectedCredentialType, - // credentialTypes, - // credentials, - // credentialsCount, - // } = state; - + const isMultiple = selectedType && selectedType.value === 'Vault'; return ( {tooltip && } @@ -134,15 +119,10 @@ function MultiCredentialsLookup(props) { onToggleItem={toggleCredentialSelection} id="multiCredential" lookupHeader={i18n._(t`Credentials`)} - // name="credentials" value={value} multiple onChange={onChange} - // items={credentials} - // count={credentialsCount} qsConfig={QS_CONFIG} - // columns={} - // TODO bind removeItem renderItemChip={({ item, removeItem, canDelete }) => ( {}} - deselectItem={() => {}} + selectItem={item => { + if (isMultiple) { + return dispatch({ type: 'SELECT_ITEM', item }); + } + const selectedItems = state.selectedItems.filter( + i => i.kind !== item.kind + ); + selectedItems.push(item); + return dispatch({ + type: 'SET_SELECTED_ITEMS', + selectedItems, + }); + }} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} /> ); diff --git a/awx/ui_next/src/components/Lookup/NewLookup.jsx b/awx/ui_next/src/components/Lookup/NewLookup.jsx index 0f3342e5b5..c0e8cfa943 100644 --- a/awx/ui_next/src/components/Lookup/NewLookup.jsx +++ b/awx/ui_next/src/components/Lookup/NewLookup.jsx @@ -171,11 +171,7 @@ const Item = shape({ Lookup.propTypes = { id: string, - // items: arrayOf(shape({})).isRequired, - // count: number.isRequired, - // TODO: change to `header` header: string, - // name: string, onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), multiple: bool, @@ -189,7 +185,6 @@ Lookup.propTypes = { Lookup.defaultProps = { id: 'lookup-search', header: null, - // name: null, value: null, multiple: false, required: false, diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.js b/awx/ui_next/src/components/Lookup/shared/reducer.js index 9f7f6b37e0..d608c58cde 100644 --- a/awx/ui_next/src/components/Lookup/shared/reducer.js +++ b/awx/ui_next/src/components/Lookup/shared/reducer.js @@ -15,6 +15,8 @@ export default function reducer(state, action) { return { ...state, multiple: action.value }; case 'SET_VALUE': return { ...state, value: action.value }; + case 'SET_SELECTED_ITEMS': + return { ...state, selectedItems: action.selectedItems }; default: throw new Error(`Unrecognized action type: ${action.type}`); } From 639b297027452a55580e15ec7aab72e2ac848134 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 25 Nov 2019 14:22:34 -0800 Subject: [PATCH 046/109] fix credential chips in SelectedList, MultiCredential cleanup --- .../AnsibleSelect/AnsibleSelect.jsx | 19 +- .../src/components/Lookup/CategoryLookup.jsx | 331 ------------------ .../Lookup/MultiCredentialsLookup.jsx | 90 ++--- .../src/components/Lookup/NewLookup.jsx | 4 - awx/ui_next/src/components/Lookup/index.js | 1 - .../components/Lookup/shared/SelectList.jsx | 4 + .../components/SelectedList/SelectedList.jsx | 48 ++- 7 files changed, 69 insertions(+), 428 deletions(-) delete mode 100644 awx/ui_next/src/components/Lookup/CategoryLookup.jsx diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 1de791ab58..6c87032341 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -36,12 +36,12 @@ class AnsibleSelect extends React.Component { aria-label={i18n._(t`Select Input`)} isValid={isValid} > - {data.map(datum => ( + {data.map(option => ( ))} @@ -49,6 +49,13 @@ class AnsibleSelect extends React.Component { } } +const Option = shape({ + id: oneOfType([string, number]).isRequired, + value: oneOfType([string, number]).isRequired, + label: string.isRequired, + isDisabled: bool, +}); + AnsibleSelect.defaultProps = { data: [], isValid: true, @@ -56,7 +63,7 @@ AnsibleSelect.defaultProps = { }; AnsibleSelect.propTypes = { - data: arrayOf(shape()), + data: arrayOf(Option), id: string.isRequired, isValid: bool, onBlur: func, diff --git a/awx/ui_next/src/components/Lookup/CategoryLookup.jsx b/awx/ui_next/src/components/Lookup/CategoryLookup.jsx deleted file mode 100644 index 90009079bb..0000000000 --- a/awx/ui_next/src/components/Lookup/CategoryLookup.jsx +++ /dev/null @@ -1,331 +0,0 @@ -import React, { Fragment } from 'react'; -import { - string, - bool, - arrayOf, - func, - number, - oneOfType, - shape, -} from 'prop-types'; -import { withRouter } from 'react-router-dom'; -import { SearchIcon } from '@patternfly/react-icons'; -import { - Button, - ButtonVariant, - InputGroup as PFInputGroup, - Modal, - ToolbarItem, -} from '@patternfly/react-core'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import styled from 'styled-components'; - -import AnsibleSelect from '../AnsibleSelect'; -import PaginatedDataList from '../PaginatedDataList'; -import VerticalSeperator from '../VerticalSeparator'; -import DataListToolbar from '../DataListToolbar'; -import CheckboxListItem from '../CheckboxListItem'; -import SelectedList from '../SelectedList'; -import { ChipGroup, CredentialChip } from '../Chip'; -import { QSConfig } from '@types'; - -const SearchButton = styled(Button)` - ::after { - border: var(--pf-c-button--BorderWidth) solid - var(--pf-global--BorderColor--200); - } -`; - -const InputGroup = styled(PFInputGroup)` - ${props => - props.multiple && - ` - --pf-c-form-control--Height: 90px; - overflow-y: auto; - `} -`; - -const ChipHolder = styled.div` - --pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200); - --pf-c-form-control--BorderRightColor: var(--pf-global--BorderColor--200); - border-top-right-radius: 3px; - border-bottom-right-radius: 3px; -`; - -class CategoryLookup extends React.Component { - constructor(props) { - super(props); - - // this.assertCorrectValueType(); - let selectedItems = []; - if (props.value) { - selectedItems = props.multiple ? [...props.value] : [props.value]; - } - this.state = { - isModalOpen: false, - selectedItems, - error: null, - }; - this.handleModalToggle = this.handleModalToggle.bind(this); - this.addItem = this.addItem.bind(this); - this.removeItem = this.removeItem.bind(this); - this.saveModal = this.saveModal.bind(this); - this.clearQSParams = this.clearQSParams.bind(this); - } - - // assertCorrectValueType() { - // const { multiple, value, selectCategoryOptions } = this.props; - // if (selectCategoryOptions) { - // return; - // } - // if (!multiple && Array.isArray(value)) { - // throw new Error( - // 'CategoryLookup value must not be an array unless `multiple` is set' - // ); - // } - // if (multiple && !Array.isArray(value)) { - // throw new Error( - // 'CategoryLookup value must be an array if `multiple` is set' - // ); - // } - // } - - removeItem(row) { - const { selectedItems } = this.state; - const { onToggleItem } = this.props; - if (onToggleItem) { - this.setState({ selectedItems: onToggleItem(selectedItems, row) }); - return; - } - this.setState({ - selectedItems: selectedItems.filter(item => item.id !== row.id), - }); - } - - addItem(row) { - const { selectedItems } = this.state; - const { multiple, onToggleItem } = this.props; - if (onToggleItem) { - this.setState({ selectedItems: onToggleItem(selectedItems, row) }); - return; - } - const index = selectedItems.findIndex(item => item.id === row.id); - - if (!multiple) { - this.setState({ selectedItems: [row] }); - return; - } - if (index > -1) { - return; - } - this.setState({ selectedItems: [...selectedItems, row] }); - } - - // TODO: clean up - handleModalToggle() { - const { isModalOpen } = this.state; - const { value, multiple, selectCategory } = this.props; - // Resets the selected items from parent state whenever modal is opened - // This handles the case where the user closes/cancels the modal and - // opens it again - if (!isModalOpen) { - let selectedItems = []; - if (value) { - selectedItems = multiple ? [...value] : [value]; - } - this.setState({ selectedItems }); - } else { - this.clearQSParams(); - if (selectCategory) { - selectCategory(null, 'Machine'); - } - } - this.setState(prevState => ({ - isModalOpen: !prevState.isModalOpen, - })); - } - - removeItemAndSave(row) { - const { value, onChange, multiple } = this.props; - if (multiple) { - onChange(value.filter(item => item.id !== row.id)); - } else if (value.id === row.id) { - onChange(null); - } - } - - saveModal() { - const { onChange, multiple } = this.props; - const { selectedItems } = this.state; - const value = multiple ? selectedItems : selectedItems[0] || null; - - this.handleModalToggle(); - onChange(value); - } - - clearQSParams() { - const { qsConfig, history } = this.props; - const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = qsConfig.namespace; - const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); - history.push(`${history.location.pathname}?${otherParts.join('&')}`); - } - - render() { - const { isModalOpen, selectedItems, error } = this.state; - const { - id, - items, - count, - lookupHeader, - value, - columns, - multiple, - name, - onBlur, - qsConfig, - required, - selectCategory, - selectCategoryOptions, - selectedCategory, - i18n, - } = this.props; - const header = lookupHeader || i18n._(t`Items`); - const canDelete = !required || (multiple && value.length > 1); - return ( - - - - - - - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - credential={chip} - /> - ))} - - - - - {i18n._(t`Select`)} - , - , - ]} - > - {selectCategoryOptions && selectCategoryOptions.length > 0 && ( - - Selected Category - - - - )} - {selectedItems.length > 0 && ( - 0 - } - /> - )} - ( - i.id === item.id)} - onSelect={() => this.addItem(item)} - isRadio={ - !multiple || - (selectCategoryOptions && - selectCategoryOptions.length && - selectedCategory.value !== 'Vault') - } - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> - {error ?
error: {error.message}
: ''} -
-
- ); - } -} - -const Item = shape({ - id: number.isRequired, -}); - -CategoryLookup.propTypes = { - id: string, - items: arrayOf(shape({})).isRequired, - // TODO: change to `header` - lookupHeader: string, - name: string, - onChange: func.isRequired, - value: oneOfType([Item, arrayOf(Item)]), - multiple: bool, - required: bool, - qsConfig: QSConfig.isRequired, - selectCategory: func.isRequired, - selectCategoryOptions: oneOfType(shape({})).isRequired, - selectedCategory: shape({}).isRequired, -}; - -CategoryLookup.defaultProps = { - id: 'lookup-search', - lookupHeader: null, - name: null, - value: null, - multiple: false, - required: false, -}; - -export { CategoryLookup as _CategoryLookup }; -export default withI18n()(withRouter(CategoryLookup)); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 7578205923..20d5180e7c 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; @@ -12,7 +12,6 @@ import VerticalSeperator from '@components/VerticalSeparator'; import { getQSConfig, parseQueryString } from '@util/qs'; import Lookup from './NewLookup'; import SelectList from './shared/SelectList'; -import multiCredentialReducer from './shared/multiCredentialReducer'; const QS_CONFIG = getQSConfig('credentials', { page: 1, @@ -20,50 +19,10 @@ const QS_CONFIG = getQSConfig('credentials', { order_by: 'name', }); -// TODO: move into reducer -function toggleCredentialSelection(credentialsToUpdate, newCredential) { - let newCredentialsList; - const isSelectedCredentialInState = - credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > 0; - - if (isSelectedCredentialInState) { - newCredentialsList = credentialsToUpdate.filter( - cred => cred.id !== newCredential.id - ); - } else { - newCredentialsList = credentialsToUpdate.filter( - credential => - credential.kind === 'vault' || credential.kind !== newCredential.kind - ); - newCredentialsList = [...newCredentialsList, newCredential]; - } - return newCredentialsList; -} - async function loadCredentialTypes() { const { data } = await CredentialTypesAPI.read(); const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; - const credentialTypes = []; - // TODO: cleanup - data.results.forEach(cred => { - acceptableTypes.forEach(aT => { - if (aT === cred.kind) { - // This object has several repeated values as some of it's children - // require different field values. - cred = { - id: cred.id, - key: cred.id, - kind: cred.kind, - type: cred.namespace, - value: cred.name, - label: cred.name, - isDisabled: false, - }; - credentialTypes.push(cred); - } - }); - }); - return credentialTypes; + return data.results.filter(type => acceptableTypes.includes(type.kind)); } async function loadCredentials(params, selectedCredentialTypeId) { @@ -78,13 +37,16 @@ function MultiCredentialsLookup(props) { const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); const [credentialsCount, setCredentialsCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { (async () => { try { const types = await loadCredentialTypes(); setCredentialTypes(types); - setSelectedType(types[0]); + setSelectedType( + types.find(type => type.name === 'Machine') || types[0] + ); } catch (err) { onError(err); } @@ -98,10 +60,12 @@ function MultiCredentialsLookup(props) { } try { const params = parseQueryString(QS_CONFIG, history.location.search); + setIsLoading(true); const { results, count } = await loadCredentials( params, selectedType.id ); + setIsLoading(false); setCredentials(results); setCredentialsCount(count); } catch (err) { @@ -111,29 +75,29 @@ function MultiCredentialsLookup(props) { }, [selectedType]); const isMultiple = selectedType && selectedType.value === 'Vault'; + const renderChip = ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + credential={item} + /> + ); + return ( {tooltip && } ( - removeItem(item)} - isReadOnly={!canDelete} - credential={item} - /> - )} + renderItemChip={renderChip} renderSelectList={({ state, dispatch, canDelete }) => { return ( - <> + {credentialTypes && credentialTypes.length > 0 && (
{i18n._(t`Selected Category`)}
@@ -142,11 +106,16 @@ function MultiCredentialsLookup(props) { css="flex: 1 1 75%;" id="multiCredentialsLookUp-select" label={i18n._(t`Selected Category`)} - data={credentialTypes} - value={selectedType && selectedType.label} - onChange={(e, label) => { + data={credentialTypes.map(type => ({ + id: type.id, + value: type.id, + label: type.name, + isDisabled: false, + }))} + value={selectedType && selectedType.id} + onChange={(e, id) => { setSelectedType( - credentialTypes.find(o => o.label === label) + credentialTypes.find(o => o.id === parseInt(id, 10)) ); }} /> @@ -183,8 +152,9 @@ function MultiCredentialsLookup(props) { }); }} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + renderItemChip={renderChip} /> - +
); }} /> diff --git a/awx/ui_next/src/components/Lookup/NewLookup.jsx b/awx/ui_next/src/components/Lookup/NewLookup.jsx index c0e8cfa943..8c93b18d87 100644 --- a/awx/ui_next/src/components/Lookup/NewLookup.jsx +++ b/awx/ui_next/src/components/Lookup/NewLookup.jsx @@ -50,13 +50,9 @@ const ChipHolder = styled.div` function Lookup(props) { const { id, - // items, - // count, header, - // name, onChange, onBlur, - // columns, value, multiple, required, diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index 5e2959c00e..cde48e2bcd 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -1,5 +1,4 @@ export { default } from './Lookup'; -export { default as CategoryLookup } from './CategoryLookup'; export { default as InstanceGroupsLookup } from './InstanceGroupsLookup'; export { default as InventoryLookup } from './InventoryLookup'; export { default as ProjectLookup } from './ProjectLookup'; diff --git a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx index 128d9371d8..b9a2ec1f84 100644 --- a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx @@ -28,6 +28,7 @@ function SelectList({ readOnly, selectItem, deselectItem, + renderItemChip, i18n, }) { return ( @@ -39,6 +40,7 @@ function SelectList({ showOverflowAfter={5} onRemove={item => deselectItem(item)} isReadOnly={readOnly} + renderItemChip={renderItemChip} /> )} ( - onRemove(item)} - credential={item} - > - {item[displayKey]} - - )) - : selected.map(item => ( - onRemove(item)} - > - {item[displayKey]} - - )); + + const renderChip = + renderItemChip || + (({ item, removeItem }) => ( + + {item[displayKey]} + + )); + return ( {label} - {chips} + + {selected.map(item => + renderChip({ + item, + removeItem: () => onRemove(item), + canDelete: !isReadOnly, + }) + )} + ); @@ -67,7 +63,7 @@ SelectedList.propTypes = { onRemove: PropTypes.func, selected: PropTypes.arrayOf(PropTypes.object).isRequired, isReadOnly: PropTypes.bool, - isCredentialList: PropTypes.bool, + renderItemChip: PropTypes.func, }; SelectedList.defaultProps = { @@ -75,7 +71,7 @@ SelectedList.defaultProps = { label: 'Selected', onRemove: () => null, isReadOnly: false, - isCredentialList: false, + renderItemChip: null, }; export default SelectedList; From cb07e9c757b9eca693102721e5715c80940bf9eb Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 26 Nov 2019 13:38:44 -0800 Subject: [PATCH 047/109] convert all lookups to use new Lookup component --- .../AnsibleSelect/AnsibleSelect.jsx | 4 +- .../components/Lookup/CredentialLookup.jsx | 60 +++- .../Lookup/InstanceGroupsLookup.jsx | 16 +- .../src/components/Lookup/InventoryLookup.jsx | 141 +++++--- awx/ui_next/src/components/Lookup/Lookup.jsx | 310 +++++++----------- .../Lookup/MultiCredentialsLookup.jsx | 15 +- .../src/components/Lookup/NewLookup.jsx | 200 ----------- .../components/Lookup/OrganizationLookup.jsx | 52 ++- .../src/components/Lookup/ProjectLookup.jsx | 121 ++++--- .../Lookup/shared/LookupErrorMessage.jsx | 15 + .../{SelectList.jsx => OptionsList.jsx} | 8 +- 11 files changed, 406 insertions(+), 536 deletions(-) delete mode 100644 awx/ui_next/src/components/Lookup/NewLookup.jsx create mode 100644 awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx rename awx/ui_next/src/components/Lookup/shared/{SelectList.jsx => OptionsList.jsx} (94%) diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index 6c87032341..c6e427309d 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -38,7 +38,7 @@ class AnsibleSelect extends React.Component { > {data.map(option => ( - CredentialsAPI.read( - mergeParams(params, { credential_type: credentialTypeId }) - ); + const [credentials, setCredentials] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await CredentialsAPI.read( + mergeParams(params, { credential_type: credentialTypeId }) + ); + setCredentials(data.results); + setCount(data.count); + } catch (err) { + if (setError) { + setError(err); + } + } + })(); + }); return ( ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} /> + ); } @@ -65,4 +101,4 @@ CredentialLookup.defaultProps = { }; export { CredentialLookup as _CredentialLookup }; -export default withI18n()(CredentialLookup); +export default withI18n()(withRouter(CredentialLookup)); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 8071c8b26f..1c551e14d7 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -7,8 +7,9 @@ import { FormGroup } from '@patternfly/react-core'; import { InstanceGroupsAPI } from '@api'; import { getQSConfig, parseQueryString } from '@util/qs'; import { FieldTooltip } from '@components/FormField'; -import Lookup from './NewLookup'; -import SelectList from './shared/SelectList'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; const QS_CONFIG = getQSConfig('instance_groups', { page: 1, @@ -45,17 +46,12 @@ function InstanceGroupsLookup(props) { ( - ( + )} /> - {error ?
error {error.message}
: ''} +
); } diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 494b4d3069..cd687b18c5 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; @@ -7,61 +8,93 @@ import { InventoriesAPI } from '@api'; import { Inventory } from '@types'; import Lookup from '@components/Lookup'; import { FieldTooltip } from '@components/FormField'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const getInventories = async params => InventoriesAPI.read(params); +const QS_CONFIG = getQSConfig('inventory', { + page: 1, + page_size: 5, + order_by: 'name', +}); -class InventoryLookup extends React.Component { - render() { - const { - value, - tooltip, - onChange, - onBlur, - required, - isValid, - helperTextInvalid, - i18n, - } = this.props; +function InventoryLookup({ + value, + tooltip, + onChange, + onBlur, + required, + isValid, + helperTextInvalid, + i18n, + history, +}) { + const [inventories, setInventories] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); - return ( - - {tooltip && } - - - ); - } + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await InventoriesAPI.read(params); + setInventories(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + + return ( + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); } InventoryLookup.propTypes = { @@ -77,4 +110,4 @@ InventoryLookup.defaultProps = { required: false, }; -export default withI18n()(InventoryLookup); +export default withI18n()(withRouter(InventoryLookup)); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index 0a9b7c473b..afb67b54c4 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useReducer, useEffect } from 'react'; import { string, bool, @@ -20,7 +20,7 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import SelectList from './shared/SelectList'; +import reducer, { initReducer } from './shared/reducer'; import { ChipGroup, Chip } from '../Chip'; import { QSConfig } from '@types'; @@ -48,198 +48,118 @@ const ChipHolder = styled.div` border-bottom-right-radius: 3px; `; -class Lookup extends React.Component { - constructor(props) { - super(props); +function Lookup(props) { + const { + id, + header, + onChange, + onBlur, + value, + multiple, + required, + qsConfig, + renderItemChip, + renderOptionsList, + history, + i18n, + } = props; - this.assertCorrectValueType(); - let selectedItems = []; - if (props.value) { - selectedItems = props.multiple ? [...props.value] : [props.value]; - } - this.state = { - isModalOpen: false, - selectedItems, - }; - this.handleModalToggle = this.handleModalToggle.bind(this); - this.addItem = this.addItem.bind(this); - this.removeItem = this.removeItem.bind(this); - this.saveModal = this.saveModal.bind(this); - this.clearQSParams = this.clearQSParams.bind(this); - } + const [state, dispatch] = useReducer( + reducer, + { value, multiple, required }, + initReducer + ); - assertCorrectValueType() { - const { multiple, value } = this.props; - if (!multiple && Array.isArray(value)) { - throw new Error( - 'Lookup value must not be an array unless `multiple` is set' - ); - } - if (multiple && !Array.isArray(value)) { - throw new Error('Lookup value must be an array if `multiple` is set'); - } - } + useEffect(() => { + dispatch({ type: 'SET_MULTIPLE', value: multiple }); + }, [multiple]); - removeItem(row) { - const { selectedItems } = this.state; - const { onToggleItem } = this.props; - if (onToggleItem) { - this.setState({ selectedItems: onToggleItem(selectedItems, row) }); - return; - } - this.setState({ - selectedItems: selectedItems.filter(item => item.id !== row.id), - }); - } + useEffect(() => { + dispatch({ type: 'SET_VALUE', value }); + }, [value]); - addItem(row) { - const { selectedItems } = this.state; - const { multiple, onToggleItem } = this.props; - if (onToggleItem) { - this.setState({ selectedItems: onToggleItem(selectedItems, row) }); - return; - } - const index = selectedItems.findIndex(item => item.id === row.id); - - if (!multiple) { - this.setState({ selectedItems: [row] }); - return; - } - if (index > -1) { - return; - } - this.setState({ selectedItems: [...selectedItems, row] }); - } - - // TODO: cleanup - handleModalToggle() { - const { isModalOpen } = this.state; - const { value, multiple } = this.props; - // Resets the selected items from parent state whenever modal is opened - // This handles the case where the user closes/cancels the modal and - // opens it again - if (!isModalOpen) { - let selectedItems = []; - if (value) { - selectedItems = multiple ? [...value] : [value]; - } - this.setState({ selectedItems }); - } else { - this.clearQSParams(); - } - this.setState(prevState => ({ - isModalOpen: !prevState.isModalOpen, - })); - } - - removeItemAndSave(item) { - const { value, onChange, multiple } = this.props; - if (multiple) { - onChange(value.filter(i => i.id !== item.id)); - } else if (value.id === item.id) { - onChange(null); - } - } - - saveModal() { - const { onChange, multiple } = this.props; - const { selectedItems } = this.state; - const value = multiple ? selectedItems : selectedItems[0] || null; - - this.handleModalToggle(); - onChange(value); - } - - clearQSParams() { - const { qsConfig, history } = this.props; + const clearQSParams = () => { const parts = history.location.search.replace(/^\?/, '').split('&'); const ns = qsConfig.namespace; const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); history.push(`${history.location.pathname}?${otherParts.join('&')}`); - } + }; - render() { - const { isModalOpen, selectedItems } = this.state; - const { - id, - lookupHeader, - value, - items, - count, - columns, - multiple, - name, - onBlur, - required, - qsConfig, - i18n, - } = this.props; - const header = lookupHeader || i18n._(t`Items`); - const canDelete = !required || (multiple && value.length > 1); - return ( - - - - - - - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - - - - {i18n._(t`Select`)} - , - , - ]} + const save = () => { + const { selectedItems } = state; + const val = multiple ? selectedItems : selectedItems[0] || null; + onChange(val); + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const removeItem = item => { + if (multiple) { + onChange(value.filter(i => i.id !== item.id)); + } else { + onChange(null); + } + }; + + const closeModal = () => { + clearQSParams(); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const { isModalOpen, selectedItems } = state; + const canDelete = !required || (multiple && value.length > 1); + return ( + + + dispatch({ type: 'TOGGLE_MODAL' })} + variant={ButtonVariant.tertiary} > - this.setState({ selectedItems: newVal })} - options={items} - optionCount={count} - columns={columns} - multiple={multiple} - header={lookupHeader} - name={name} - qsConfig={qsConfig} - readOnly={!canDelete} - /> - - - ); - } + + + + + {(multiple ? value : [value]).map(item => + renderItemChip({ + item, + removeItem, + canDelete, + }) + )} + + + + + {i18n._(t`Select`)} + , + , + ]} + > + {renderOptionsList({ + state, + dispatch, + canDelete, + })} + + + ); } const Item = shape({ @@ -248,25 +168,33 @@ const Item = shape({ Lookup.propTypes = { id: string, - items: arrayOf(shape({})).isRequired, - count: number.isRequired, - // TODO: change to `header` - lookupHeader: string, - name: string, + header: string, onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), multiple: bool, required: bool, + onBlur: func, qsConfig: QSConfig.isRequired, + renderItemChip: func, + renderOptionsList: func.isRequired, }; Lookup.defaultProps = { id: 'lookup-search', - lookupHeader: null, - name: null, + header: null, value: null, multiple: false, required: false, + onBlur: () => {}, + renderItemChip: ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.name} + + ), }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 20d5180e7c..db00354305 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -10,8 +10,8 @@ import { FieldTooltip } from '@components/FormField'; import { CredentialChip } from '@components/Chip'; import VerticalSeperator from '@components/VerticalSeparator'; import { getQSConfig, parseQueryString } from '@util/qs'; -import Lookup from './NewLookup'; -import SelectList from './shared/SelectList'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; const QS_CONFIG = getQSConfig('credentials', { page: 1, @@ -37,7 +37,6 @@ function MultiCredentialsLookup(props) { const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); const [credentialsCount, setCredentialsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); useEffect(() => { (async () => { @@ -60,12 +59,10 @@ function MultiCredentialsLookup(props) { } try { const params = parseQueryString(QS_CONFIG, history.location.search); - setIsLoading(true); const { results, count } = await loadCredentials( params, selectedType.id ); - setIsLoading(false); setCredentials(results); setCredentialsCount(count); } catch (err) { @@ -74,7 +71,7 @@ function MultiCredentialsLookup(props) { })(); }, [selectedType]); - const isMultiple = selectedType && selectedType.value === 'Vault'; + const isMultiple = selectedType && selectedType.name === 'Vault'; const renderChip = ({ item, removeItem, canDelete }) => ( { + renderOptionsList={({ state, dispatch, canDelete }) => { return ( {credentialTypes && credentialTypes.length > 0 && ( @@ -107,7 +104,7 @@ function MultiCredentialsLookup(props) { id="multiCredentialsLookUp-select" label={i18n._(t`Selected Category`)} data={credentialTypes.map(type => ({ - id: type.id, + key: type.id, value: type.id, label: type.name, isDisabled: false, @@ -121,7 +118,7 @@ function MultiCredentialsLookup(props) { /> )} - - props.multiple && - ` - --pf-c-form-control--Height: 90px; - overflow-y: auto; - `} -`; - -const ChipHolder = styled.div` - --pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200); - --pf-c-form-control--BorderRightColor: var(--pf-global--BorderColor--200); - border-top-right-radius: 3px; - border-bottom-right-radius: 3px; -`; - -function Lookup(props) { - const { - id, - header, - onChange, - onBlur, - value, - multiple, - required, - qsConfig, - renderItemChip, - renderSelectList, - history, - i18n, - } = props; - - const [state, dispatch] = useReducer( - reducer, - { value, multiple, required }, - initReducer - ); - - useEffect(() => { - dispatch({ type: 'SET_MULTIPLE', value: multiple }); - }, [multiple]); - - useEffect(() => { - dispatch({ type: 'SET_VALUE', value }); - }, [value]); - - const clearQSParams = () => { - const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = qsConfig.namespace; - const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); - history.push(`${history.location.pathname}?${otherParts.join('&')}`); - }; - - const save = () => { - const { selectedItems } = state; - const val = multiple ? selectedItems : selectedItems[0] || null; - onChange(val); - clearQSParams(); - dispatch({ type: 'CLOSE_MODAL' }); - }; - - const removeItem = item => { - if (multiple) { - onChange(value.filter(i => i.id !== item.id)); - } else { - onChange(null); - } - }; - - const closeModal = () => { - clearQSParams(); - dispatch({ type: 'CLOSE_MODAL' }); - }; - - const { isModalOpen, selectedItems } = state; - const canDelete = !required || (multiple && value.length > 1); - return ( - - - dispatch({ type: 'TOGGLE_MODAL' })} - variant={ButtonVariant.tertiary} - > - - - - - {(multiple ? value : [value]).map(item => - renderItemChip({ - item, - removeItem, - canDelete, - }) - )} - - - - - {i18n._(t`Select`)} - , - , - ]} - > - {renderSelectList({ - state, - dispatch, - canDelete, - })} - - - ); -} - -const Item = shape({ - id: number.isRequired, -}); - -Lookup.propTypes = { - id: string, - header: string, - onChange: func.isRequired, - value: oneOfType([Item, arrayOf(Item)]), - multiple: bool, - required: bool, - onBlur: func, - qsConfig: QSConfig.isRequired, - renderItemChip: func, - renderSelectList: func.isRequired, -}; - -Lookup.defaultProps = { - id: 'lookup-search', - header: null, - value: null, - multiple: false, - required: false, - onBlur: () => {}, - renderItemChip: ({ item, removeItem, canDelete }) => ( - removeItem(item)} - isReadOnly={!canDelete} - > - {item.name} - - ), -}; - -export { Lookup as _Lookup }; -export default withI18n()(withRouter(Lookup)); diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 32c93f2588..17d98acd9e 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -1,13 +1,17 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { string, func, bool } from 'prop-types'; import { OrganizationsAPI } from '@api'; import { Organization } from '@types'; import { FormGroup } from '@patternfly/react-core'; -import Lookup from '@components/Lookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -const getOrganizations = async params => OrganizationsAPI.read(params); +const QS_CONFIG = getQSConfig('organizations', {}); function OrganizationLookup({ helperTextInvalid, @@ -17,7 +21,25 @@ function OrganizationLookup({ onChange, required, value, + history, }) { + const [organizations, setOrganizations] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await OrganizationsAPI.read(params); + setOrganizations(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + return ( ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} /> + ); } @@ -58,5 +94,5 @@ OrganizationLookup.defaultProps = { value: null, }; -export default withI18n()(OrganizationLookup); export { OrganizationLookup as _OrganizationLookup }; +export default withI18n()(withRouter(OrganizationLookup)); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index f83d30eb02..3203cadb30 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -1,59 +1,87 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { string, func, bool } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup } from '@patternfly/react-core'; import { ProjectsAPI } from '@api'; import { Project } from '@types'; -import Lookup from '@components/Lookup'; import { FieldTooltip } from '@components/FormField'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import Lookup from './Lookup'; +import OptionsList from './shared/OptionsList'; +import LookupErrorMessage from './shared/LookupErrorMessage'; -class ProjectLookup extends React.Component { - render() { - const { - helperTextInvalid, - i18n, - isValid, - onChange, - required, - tooltip, - value, - onBlur, - } = this.props; +const QS_CONFIG = getQSConfig('project', { + page: 1, + page_size: 5, + order_by: 'name', +}); - const loadProjects = async params => { - const response = await ProjectsAPI.read(params); - const { results, count } = response.data; - if (count === 1) { - onChange(results[0], 'project'); +function ProjectLookup({ + helperTextInvalid, + i18n, + isValid, + onChange, + required, + tooltip, + value, + onBlur, + history, +}) { + const [projects, setProjects] = useState([]); + const [count, setCount] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + try { + const { data } = await ProjectsAPI.read(params); + setProjects(data.results); + setCount(data.count); + } catch (err) { + setError(err); } - return response; - }; + })(); + }, [history.location]); - return ( - - {tooltip && } - - - ); - } + return ( + + {tooltip && } + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); } ProjectLookup.propTypes = { @@ -75,4 +103,5 @@ ProjectLookup.defaultProps = { onBlur: () => {}, }; -export default withI18n()(ProjectLookup); +export { ProjectLookup as _ProjectLookup }; +export default withI18n()(withRouter(ProjectLookup)); diff --git a/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx b/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx new file mode 100644 index 0000000000..588fe18719 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/LookupErrorMessage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; + +function LookupErrorMessage({ error }) { + if (!error) { + return null; + } + + return ( +
+ {error.message || 'An error occured'} +
+ ); +} + +export default LookupErrorMessage; diff --git a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx similarity index 94% rename from awx/ui_next/src/components/Lookup/shared/SelectList.jsx rename to awx/ui_next/src/components/Lookup/shared/OptionsList.jsx index b9a2ec1f84..73e7b6b5ca 100644 --- a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -16,7 +16,7 @@ import CheckboxListItem from '../../CheckboxListItem'; import DataListToolbar from '../../DataListToolbar'; import { QSConfig } from '@types'; -function SelectList({ +function OptionsList({ value, options, optionCount, @@ -71,7 +71,7 @@ function SelectList({ const Item = shape({ id: oneOfType([number, string]).isRequired, }); -SelectList.propTypes = { +OptionsList.propTypes = { value: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired, optionCount: number.isRequired, @@ -82,9 +82,9 @@ SelectList.propTypes = { deselectItem: func.isRequired, renderItemChip: func, }; -SelectList.defaultProps = { +OptionsList.defaultProps = { multiple: false, renderItemChip: null, }; -export default withI18n()(SelectList); +export default withI18n()(OptionsList); From f8153393b11157cb15a2a87205feb0fe34455433 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 26 Nov 2019 15:01:13 -0800 Subject: [PATCH 048/109] fix minor lookup bugs --- .../components/Lookup/InstanceGroupsLookup.jsx | 15 +++++++++++++-- .../src/components/Lookup/InventoryLookup.jsx | 1 + .../src/components/Lookup/Lookup.test.jsx | 8 ++++---- .../components/Lookup/MultiCredentialsLookup.jsx | 2 +- .../src/components/Lookup/ProjectLookup.jsx | 2 +- .../src/components/Lookup/shared/OptionsList.jsx | 5 ++++- .../src/components/Lookup/shared/reducer.js | 16 ---------------- 7 files changed, 24 insertions(+), 25 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index 1c551e14d7..20c2e0cf20 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { arrayOf, string, func, object } from 'prop-types'; +import { arrayOf, string, func, object, bool } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -18,7 +18,15 @@ const QS_CONFIG = getQSConfig('instance_groups', { }); function InstanceGroupsLookup(props) { - const { value, onChange, tooltip, className, history, i18n } = props; + const { + value, + onChange, + tooltip, + className, + required, + history, + i18n, + } = props; const [instanceGroups, setInstanceGroups] = useState([]); const [count, setCount] = useState(0); const [error, setError] = useState(null); @@ -50,6 +58,7 @@ function InstanceGroupsLookup(props) { onChange={onChange} qsConfig={QS_CONFIG} multiple + required={required} renderOptionsList={({ state, dispatch, canDelete }) => ( ( ', () => { wrapper = mountWithContexts( ', () => { document.body.innerHTML = ''; wrapper = mountWithContexts( ', () => { <_Lookup multiple name="foo" - lookupHeader="Foo Bar" + header="Foo Bar" onLookupSave={() => {}} value={mockData} columns={mockColumns} @@ -369,7 +369,7 @@ describe('', () => { <_Lookup multiple name="foo" - lookupHeader="Foo Bar" + header="Foo Bar" onLookupSave={() => {}} value={mockData} columns={mockColumns} diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index db00354305..02764f3de3 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -86,7 +86,7 @@ function MultiCredentialsLookup(props) { {tooltip && } } ( Date: Tue, 3 Dec 2019 09:18:28 -0800 Subject: [PATCH 049/109] update MultiCredentialsLookup tests --- awx/ui_next/src/components/Lookup/Lookup.jsx | 3 +- .../Lookup/MultiCredentialsLookup.jsx | 6 +- .../Lookup/MultiCredentialsLookup.test.jsx | 179 ++++++++++++++---- 3 files changed, 145 insertions(+), 43 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index afb67b54c4..b431ce4ac2 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -30,6 +30,7 @@ const SearchButton = styled(Button)` var(--pf-global--BorderColor--200); } `; +SearchButton.displayName = 'SearchButton'; const InputGroup = styled(PFInputGroup)` ${props => @@ -120,7 +121,7 @@ function Lookup(props) { - + {(multiple ? value : [value]).map(item => renderItemChip({ item, diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 02764f3de3..2d1eff8965 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -32,7 +32,7 @@ async function loadCredentials(params, selectedCredentialTypeId) { } function MultiCredentialsLookup(props) { - const { history, tooltip, value, onChange, onError, i18n } = props; + const { tooltip, value, onChange, onError, history, i18n } = props; const [credentialTypes, setCredentialTypes] = useState([]); const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); @@ -50,7 +50,7 @@ function MultiCredentialsLookup(props) { onError(err); } })(); - }, []); + }, [onError]); useEffect(() => { (async () => { @@ -69,7 +69,7 @@ function MultiCredentialsLookup(props) { onError(err); } })(); - }, [selectedType]); + }, [selectedType, history.location.search, onError]); const isMultiple = selectedType && selectedType.name === 'Vault'; const renderChip = ({ item, removeItem, canDelete }) => ( diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index cc00396525..baaea96776 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -1,6 +1,7 @@ import React from 'react'; - -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; import MultiCredentialsLookup from './MultiCredentialsLookup'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; @@ -8,9 +9,6 @@ jest.mock('@api'); describe('', () => { let wrapper; - let lookup; - let credLookup; - let onChange; const credentials = [ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, @@ -18,8 +16,9 @@ describe('', () => { { name: 'Gatsby', id: 21, kind: 'vault' }, { name: 'Gatsby', id: 8, kind: 'Machine' }, ]; + beforeEach(() => { - CredentialTypesAPI.read.mockResolvedValue({ + CredentialTypesAPI.read.mockResolvedValueOnce({ data: { results: [ { @@ -46,17 +45,6 @@ describe('', () => { count: 3, }, }); - onChange = jest.fn(); - wrapper = mountWithContexts( - {}} - credentials={credentials} - onChange={onChange} - tooltip="This is credentials look up" - /> - ); - lookup = wrapper.find('Lookup'); - credLookup = wrapper.find('MultiCredentialsLookup'); }); afterEach(() => { @@ -64,16 +52,40 @@ describe('', () => { wrapper.unmount(); }); - test('MultiCredentialsLookup renders properly', () => { + test('MultiCredentialsLookup renders properly', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1); expect(CredentialTypesAPI.read).toHaveBeenCalled(); }); test('onChange is called when you click to remove a credential from input', async () => { - const chip = wrapper.find('PFChip').find({ isOverflowChip: false }); - const button = chip.at(1).find('ChipButton'); + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const chip = wrapper.find('CredentialChip'); expect(chip).toHaveLength(4); - button.prop('onClick')(); + const button = chip.at(1).find('ChipButton'); + await act(async () => { + button.invoke('onClick')(); + }); expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 21, kind: 'vault', name: 'Gatsby' }, @@ -81,33 +93,122 @@ describe('', () => { ]); }); - test('can change credential types', () => { - lookup.prop('selectCategory')({}, 'Vault'); - expect(credLookup.state('selectedCredentialType')).toEqual({ - id: 500, - key: 500, - kind: 'vault', - type: 'buzz', - value: 'Vault', - label: 'Vault', - isDisabled: false, + test('should change credential types', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + onError={() => {}} + /> + ); }); - expect(CredentialsAPI.read).toHaveBeenCalled(); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + const select = await waitForElement(wrapper, 'AnsibleSelect'); + CredentialsAPI.read.mockResolvedValueOnce({ + data: { + results: [ + { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, + ], + count: 1, + }, + }); + expect(CredentialsAPI.read).toHaveBeenCalledTimes(2); + await act(async () => { + select.invoke('onChange')({}, 500); + }); + wrapper.update(); + expect(CredentialsAPI.read).toHaveBeenCalledTimes(3); + expect(wrapper.find('OptionsList').prop('options')).toEqual([ + { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, + ]); }); - test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => { - lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' }); + + test('should only add 1 credential per credential type except vault(see below)', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(false); + act(() => { + optionsList.invoke('selectItem')({ + id: 5, + kind: 'Machine', + name: 'Cred 5', + url: 'www.google.com', + }); + }); + wrapper.update(); + act(() => { + wrapper.find('Button[variant="primary"]').invoke('onClick')(); + }); expect(onChange).toBeCalledWith([ { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, { id: 21, kind: 'vault', name: 'Gatsby' }, - { id: 9, kind: 'Machine', name: 'Party' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]); }); - test('Toggle credentials only adds 1 credential per credential type', () => { - lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' }); + + test('should allow multiple vault credentials', async () => { + const onChange = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + const searchButton = await waitForElement(wrapper, 'SearchButton'); + await act(async () => { + searchButton.invoke('onClick')(); + }); + wrapper.update(); + const typeSelect = wrapper.find('AnsibleSelect'); + act(() => { + typeSelect.invoke('onChange')({}, 500); + }); + wrapper.update(); + const optionsList = wrapper.find('OptionsList'); + expect(optionsList.prop('multiple')).toEqual(true); + act(() => { + optionsList.invoke('selectItem')({ + id: 5, + kind: 'Machine', + name: 'Cred 5', + url: 'www.google.com', + }); + }); + wrapper.update(); + act(() => { + wrapper.find('Button[variant="primary"]').invoke('onClick')(); + }); expect(onChange).toBeCalledWith([ - ...credentials, - { name: 'Party', id: 22, kind: 'vault' }, + { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, + { id: 21, kind: 'vault', name: 'Gatsby' }, + { id: 8, kind: 'Machine', name: 'Gatsby' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, ]); }); }); From 2e525f89220a9577f9de99693cbb251ba648caab Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 3 Dec 2019 11:02:10 -0800 Subject: [PATCH 050/109] update tests for CredentialLookup, OrgLookup, ProjectLookup --- .../components/Lookup/CredentialLookup.jsx | 3 +- .../Lookup/CredentialLookup.test.jsx | 64 +++++++++++++++++-- .../Lookup/MultiCredentialsLookup.test.jsx | 1 - .../components/Lookup/OrganizationLookup.jsx | 6 +- .../Lookup/OrganizationLookup.test.jsx | 29 ++++++--- .../src/components/Lookup/ProjectLookup.jsx | 3 + .../components/Lookup/ProjectLookup.test.jsx | 11 +++- 7 files changed, 96 insertions(+), 21 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index 5de5b1c955..bea32e63de 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -46,7 +46,7 @@ function CredentialLookup({ } } })(); - }); + }, [credentialTypeId, history.location.search]); return ( ( { let wrapper; beforeEach(() => { - wrapper = mountWithContexts( - {}} /> - ); + CredentialsAPI.read.mockResolvedValueOnce({ + data: { + results: [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, + ], + count: 5, + }, + }); }); afterEach(() => { jest.clearAllMocks(); + wrapper.unmount(); }); - test('initially renders successfully', () => { + test('should render successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(wrapper.find('CredentialLookup')).toHaveLength(1); }); - test('should fetch credentials', () => { + + test('should fetch credentials', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); expect(CredentialsAPI.read).toHaveBeenCalledWith({ credential_type: 1, @@ -30,11 +60,31 @@ describe('CredentialLookup', () => { page_size: 5, }); }); - test('should display label', () => { + + test('should display label', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); expect(title.text()).toEqual('Foo'); }); - test('should define default value for function props', () => { + + test('should define default value for function props', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_CredentialLookup.defaultProps.onBlur).not.toThrow(); }); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index baaea96776..fa73edad3a 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -1,7 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import { sleep } from '@testUtils/testUtils'; import MultiCredentialsLookup from './MultiCredentialsLookup'; import { CredentialsAPI, CredentialTypesAPI } from '@api'; diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 17d98acd9e..9fd5c4bb88 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -11,7 +11,11 @@ import Lookup from './Lookup'; import OptionsList from './shared/OptionsList'; import LookupErrorMessage from './shared/LookupErrorMessage'; -const QS_CONFIG = getQSConfig('organizations', {}); +const QS_CONFIG = getQSConfig('organizations', { + page: 1, + page_size: 5, + order_by: 'name', +}); function OrganizationLookup({ helperTextInvalid, diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx index fef9a90281..1470537e29 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; import { OrganizationsAPI } from '@api'; @@ -8,18 +9,22 @@ jest.mock('@api'); describe('OrganizationLookup', () => { let wrapper; - beforeEach(() => { - wrapper = mountWithContexts( {}} />); - }); - afterEach(() => { jest.clearAllMocks(); + wrapper.unmount(); }); - test('initially renders successfully', () => { + test('should render successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); expect(wrapper).toHaveLength(1); }); - test('should fetch organizations', () => { + + test('should fetch organizations', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.read).toHaveBeenCalledWith({ order_by: 'name', @@ -27,11 +32,19 @@ describe('OrganizationLookup', () => { page_size: 5, }); }); - test('should display "Organization" label', () => { + + test('should display "Organization" label', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); const title = wrapper.find('FormGroup .pf-c-form__label-text'); expect(title.text()).toEqual('Organization'); }); - test('should define default value for function props', () => { + + test('should define default value for function props', async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); }); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index bf9d72face..a160ecc527 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -40,6 +40,9 @@ function ProjectLookup({ const { data } = await ProjectsAPI.read(params); setProjects(data.results); setCount(data.count); + if (data.count === 1) { + onChange(data.results[0]); + } } catch (err) { setError(err); } diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx index 00fd2ad4bf..743067745e 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import { ProjectsAPI } from '@api'; @@ -15,9 +16,11 @@ describe('', () => { }, }); const onChange = jest.fn(); - mountWithContexts(); + await act(async () => { + mountWithContexts(); + }); await sleep(0); - expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project'); + expect(onChange).toHaveBeenCalledWith({ id: 1 }); }); test('should not auto-select project when multiple available', async () => { @@ -28,7 +31,9 @@ describe('', () => { }, }); const onChange = jest.fn(); - mountWithContexts(); + await act(async () => { + mountWithContexts(); + }); await sleep(0); expect(onChange).not.toHaveBeenCalled(); }); From 9ab9c6961bec4054642b35806309feeaafb497c8 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 3 Dec 2019 15:41:03 -0800 Subject: [PATCH 051/109] update Lookup tests, add OptionsList tests --- awx/ui_next/src/components/Lookup/Lookup.jsx | 8 +- .../src/components/Lookup/Lookup.test.jsx | 402 ++++-------------- .../src/components/Lookup/ProjectLookup.jsx | 2 +- .../components/Lookup/shared/OptionsList.jsx | 2 + .../Lookup/shared/OptionsList.test.jsx | 53 +++ 5 files changed, 148 insertions(+), 319 deletions(-) create mode 100644 awx/ui_next/src/components/Lookup/shared/OptionsList.test.jsx diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index b431ce4ac2..500c4ce986 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -109,6 +109,12 @@ function Lookup(props) { const { isModalOpen, selectedItems } = state; const canDelete = !required || (multiple && value.length > 1); + let items = []; + if (multiple) { + items = value; + } else if (value) { + items.push(value); + } return ( @@ -122,7 +128,7 @@ function Lookup(props) { - {(multiple ? value : [value]).map(item => + {items.map(item => renderItemChip({ item, removeItem, diff --git a/awx/ui_next/src/components/Lookup/Lookup.test.jsx b/awx/ui_next/src/components/Lookup/Lookup.test.jsx index 8f784db8ec..143ba5a709 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.test.jsx @@ -1,11 +1,9 @@ /* eslint-disable react/jsx-pascal-case */ import React from 'react'; -import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import Lookup, { _Lookup } from './Lookup'; - -let mockData = [{ name: 'foo', id: 1, isChecked: false }]; -const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }]; +import { getQSConfig } from '@util/qs'; +import Lookup from './Lookup'; /** * Check that an element is present on the document body @@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) { }); } -/** - * Check lookup modal list for expected values - * @param {wrapper} enzyme wrapper instance - * @param {expected} array of [selected, text] pairs describing - * the expected visible state of the modal data list - */ -async function checkModalListValues(wrapper, expected) { - // fail if modal isn't actually visible - checkRootElementPresent('body div[role="dialog"]'); - // check list item values - const rows = await waitForElement( - wrapper, - 'DataListItemRow', - el => el.length === expected.length - ); - expect(rows).toHaveLength(expected.length); - rows.forEach((el, index) => { - const [expectedChecked, expectedText] = expected[index]; - expect(expectedText).toEqual(el.text()); - expect(expectedChecked).toEqual(el.find('input').props().checked); - }); -} - -/** - * Check lookup modal selection tags for expected values - * @param {wrapper} enzyme wrapper instance - * @param {expected} array of expected tag values - */ -async function checkModalTagValues(wrapper, expected) { - // fail if modal isn't actually visible - checkRootElementPresent('body div[role="dialog"]'); - // check modal chip values - const chips = await waitForElement( - wrapper, - 'Modal Chip span', - el => el.length === expected.length - ); - expect(chips).toHaveLength(expected.length); - chips.forEach((el, index) => { - expect(el.text()).toEqual(expected[index]); - }); -} - -describe('', () => { - let wrapper; - let onChange; - - beforeEach(() => { - const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; - onChange = jest.fn(); - document.body.innerHTML = ''; - wrapper = mountWithContexts( - ({ - data: { - count: 2, - results: [ - ...mockSelected, - { name: 'bar', id: 2, url: '/api/v2/item/2' }, - ], - }, - })} - columns={mockColumns} - sortedColumnKey="name" - /> - ); - }); - - test('Initially renders succesfully', () => { - expect(wrapper.find('Lookup')).toHaveLength(1); - }); - - test('Expected items are shown', async done => { - expect(wrapper.find('Lookup')).toHaveLength(1); - await checkInputTagValues(wrapper, ['foo']); - done(); - }); - - test('Open and close modal', async done => { - checkRootElementNotPresent('body div[role="dialog"]'); - wrapper.find('button[aria-label="Search"]').simulate('click'); - checkRootElementPresent('body div[role="dialog"]'); - // This check couldn't pass unless api response was formatted properly - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper.find('Modal button[aria-label="Close"]').simulate('click'); - checkRootElementNotPresent('body div[role="dialog"]'); - wrapper.find('button[aria-label="Search"]').simulate('click'); - checkRootElementPresent('body div[role="dialog"]'); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Cancel') - .first() - .simulate('click'); - checkRootElementNotPresent('body div[role="dialog"]'); - done(); - }); - - test('Add item with checkbox then save', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="checkbox"]') - .simulate('change'); - await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Save') - .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange.mock.calls[0][0].map(({ name }) => name)).toEqual([ - 'foo', - 'bar', - ]); - done(); - }); - - test('Add item with checkbox then cancel', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="checkbox"]') - .simulate('change'); - await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Cancel') - .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(0); - await checkInputTagValues(wrapper, ['foo']); - done(); - }); - - test('Remove item with checkbox', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'foo') - .find('input[type="checkbox"]') - .simulate('change'); - await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, []); - done(); - }); - - test('Remove item with selected icon button', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); - wrapper - .find('Modal Chip') - .findWhere(el => el.text() === 'foo') - .first() - .find('button') - .simulate('click'); - await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, []); - done(); - }); - - test('Remove item with input group button', async done => { - await checkInputTagValues(wrapper, ['foo']); - wrapper - .find('Lookup InputGroup Chip') - .findWhere(el => el.text() === 'foo') - .first() - .find('button') - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith([], 'foobar'); - done(); - }); -}); +const QS_CONFIG = getQSConfig('test', {}); +const TestList = () =>
; describe('', () => { let wrapper; let onChange; + async function mountWrapper() { + const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }]; + await act(async () => { + wrapper = mountWithContexts( + ( + + )} + /> + ); + }); + return wrapper; + } + beforeEach(() => { - const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' }; onChange = jest.fn(); document.body.innerHTML = ''; - wrapper = mountWithContexts( - ({ - data: { - count: 2, - results: [ - mockSelected, - { name: 'bar', id: 2, url: '/api/v2/item/2' }, - ], - }, - })} - columns={mockColumns} - sortedColumnKey="name" - /> - ); }); - test('Initially renders succesfully', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should render succesfully', async () => { + wrapper = await mountWrapper(); expect(wrapper.find('Lookup')).toHaveLength(1); }); - test('Expected items are shown', async done => { + test('should show selected items', async () => { + wrapper = await mountWrapper(); expect(wrapper.find('Lookup')).toHaveLength(1); await checkInputTagValues(wrapper, ['foo']); - done(); }); - test('Open and close modal', async done => { - checkRootElementNotPresent('body div[role="dialog"]'); - wrapper.find('button[aria-label="Search"]').simulate('click'); - checkRootElementPresent('body div[role="dialog"]'); - // This check couldn't pass unless api response was formatted properly - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - wrapper.find('Modal button[aria-label="Close"]').simulate('click'); + test('should open and close modal', async () => { + wrapper = await mountWrapper(); checkRootElementNotPresent('body div[role="dialog"]'); wrapper.find('button[aria-label="Search"]').simulate('click'); checkRootElementPresent('body div[role="dialog"]'); + const list = wrapper.find('TestList'); + expect(list).toHaveLength(1); + expect(list.prop('state')).toEqual({ + selectedItems: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }], + value: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }], + multiple: true, + isModalOpen: true, + required: false, + }); + expect(list.prop('dispatch')).toBeTruthy(); + expect(list.prop('canDelete')).toEqual(true); wrapper .find('Modal button') .findWhere(e => e.text() === 'Cancel') .first() .simulate('click'); checkRootElementNotPresent('body div[role="dialog"]'); - done(); }); - test('Change selected item with radio control then save', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); + test('should remove item when X button clicked', async () => { + wrapper = await mountWrapper(); + await checkInputTagValues(wrapper, ['foo']); wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="radio"]') - .simulate('change'); - await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]); - await checkModalTagValues(wrapper, ['bar']); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Save') + .find('Lookup InputGroup Chip') + .findWhere(el => el.text() === 'foo') .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(1); - const [[{ name }]] = onChange.mock.calls; - expect(name).toEqual('bar'); - done(); - }); - - test('Change selected item with checkbox then cancel', async done => { - wrapper.find('button[aria-label="Search"]').simulate('click'); - await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); - await checkModalTagValues(wrapper, ['foo']); - wrapper - .find('DataListItemRow') - .findWhere(el => el.text() === 'bar') - .find('input[type="radio"]') - .simulate('change'); - await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]); - await checkModalTagValues(wrapper, ['bar']); - wrapper - .find('Modal button') - .findWhere(e => e.text() === 'Cancel') - .first() - .simulate('click'); - expect(onChange).toHaveBeenCalledTimes(0); - done(); - }); - - test('should re-fetch data when URL params change', async done => { - mockData = [{ name: 'foo', id: 1, isChecked: false }]; - const history = createMemoryHistory({ - initialEntries: ['/organizations/add'], - }); - const getItems = jest.fn(); - const LookupWrapper = mountWithContexts( - <_Lookup - multiple - name="foo" - header="Foo Bar" - onLookupSave={() => {}} - value={mockData} - columns={mockColumns} - sortedColumnKey="name" - getItems={getItems} - location={{ history }} - i18n={{ _: val => val.toString() }} - /> - ); - expect(getItems).toHaveBeenCalledTimes(1); - history.push('organizations/add?page=2'); - LookupWrapper.setProps({ - location: { history }, - }); - LookupWrapper.update(); - expect(getItems).toHaveBeenCalledTimes(2); - done(); - }); - - test('should clear its query params when closed', async () => { - mockData = [{ name: 'foo', id: 1, isChecked: false }]; - const history = createMemoryHistory({ - initialEntries: ['/organizations/add?inventory.name=foo&bar=baz'], - }); - wrapper = mountWithContexts( - <_Lookup - multiple - name="foo" - header="Foo Bar" - onLookupSave={() => {}} - value={mockData} - columns={mockColumns} - sortedColumnKey="name" - getItems={() => {}} - location={{ history }} - history={history} - qsNamespace="inventory" - i18n={{ _: val => val.toString() }} - /> - ); - wrapper - .find('InputGroup Button') - .at(0) .invoke('onClick')(); - wrapper.find('Modal').invoke('onClose')(); - expect(history.location.search).toEqual('?bar=baz'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([]); + }); + + test('should pass canDelete false if required single select', async () => { + await act(async () => { + const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' }; + wrapper = mountWithContexts( + ( + + )} + /> + ); + }); + wrapper.find('button[aria-label="Search"]').simulate('click'); + const list = wrapper.find('TestList'); + expect(list.prop('canDelete')).toEqual(false); }); }); diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index a160ecc527..983a214661 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -47,7 +47,7 @@ function ProjectLookup({ setError(err); } })(); - }, [history.location]); + }, [onChange, history.location]); return ( ', () => { + it('should display list of options', () => { + const options = [ + { id: 1, name: 'foo', url: '/item/1' }, + { id: 2, name: 'bar', url: '/item/2' }, + { id: 3, name: 'baz', url: '/item/3' }, + ]; + const wrapper = mountWithContexts( + {}} + deselectItem={() => {}} + name="Item" + /> + ); + expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(options); + expect(wrapper.find('SelectedList')).toHaveLength(0); + }); + + it('should render selected list', () => { + const options = [ + { id: 1, name: 'foo', url: '/item/1' }, + { id: 2, name: 'bar', url: '/item/2' }, + { id: 3, name: 'baz', url: '/item/3' }, + ]; + const wrapper = mountWithContexts( + {}} + deselectItem={() => {}} + name="Item" + /> + ); + const list = wrapper.find('SelectedList'); + expect(list).toHaveLength(1); + expect(list.prop('selected')).toEqual([options[1]]); + }); +}); From 569b5bc53364cd9b427fbeb17c0907c77633fb84 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 3 Dec 2019 16:28:52 -0800 Subject: [PATCH 052/109] clean up multiple test 'act()' warnings --- .../CheckboxListItem.test.jsx | 1 + .../components/Lookup/shared/OptionsList.jsx | 2 +- .../Project/ProjectAdd/ProjectAdd.test.jsx | 28 ++++++++----- .../Project/shared/ProjectForm.test.jsx | 42 ++++++++++--------- .../src/screens/Team/TeamAdd/TeamAdd.test.jsx | 30 +++++++++---- .../screens/Team/TeamEdit/TeamEdit.test.jsx | 13 ++++-- .../JobTemplateAdd/JobTemplateAdd.test.jsx | 24 ++++++----- .../Template/shared/JobTemplateForm.jsx | 11 +++-- .../screens/User/UserList/UserList.test.jsx | 4 +- 9 files changed, 95 insertions(+), 60 deletions(-) diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx index a28003b71a..3e61d9c980 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.test.jsx @@ -12,6 +12,7 @@ describe('CheckboxListItem', () => { label="Buzz" isSelected={false} onSelect={() => {}} + onDeselect={() => {}} /> ); expect(wrapper).toHaveLength(1); diff --git a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx index e988eb5656..77b4611c61 100644 --- a/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx +++ b/awx/ui_next/src/components/Lookup/shared/OptionsList.jsx @@ -72,8 +72,8 @@ function OptionsList({ const Item = shape({ id: oneOfType([number, string]).isRequired, - url: string.isRequired, name: string.isRequired, + url: string, }); OptionsList.propTypes = { value: arrayOf(Item).isRequired, diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx index 554ebf83ef..c4d5bc16f1 100644 --- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx @@ -98,17 +98,19 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...projectData, + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...projectData, + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; await act(async () => { wrapper.find('form').simulate('submit'); }); @@ -146,7 +148,9 @@ describe('', () => { context: { router: { history } }, }).find('ProjectAdd CardHeader'); }); - wrapper.find('CardCloseButton').simulate('click'); + await act(async () => { + wrapper.find('CardCloseButton').simulate('click'); + }); expect(history.location.pathname).toEqual('/projects'); }); @@ -158,7 +162,9 @@ describe('', () => { }); }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); + await act(async () => { + wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); + }); expect(history.location.pathname).toEqual('/projects'); }); }); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx index 584287444e..d4b901c47c 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx @@ -131,17 +131,19 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...mockData, + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockData, + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; wrapper.update(); expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1); expect( @@ -191,18 +193,20 @@ describe('', () => { }); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...mockData, - scm_type: 'insights', + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockData, + scm_type: 'insights', + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; wrapper.update(); expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe( 1 diff --git a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx index 20eb762710..02225fa733 100644 --- a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import TeamAdd from './TeamAdd'; @@ -7,32 +8,38 @@ import { TeamsAPI } from '@api'; jest.mock('@api'); describe('', () => { - test('handleSubmit should post to api', () => { + test('handleSubmit should post to api', async () => { const wrapper = mountWithContexts(); const updatedTeamData = { name: 'new name', description: 'new description', organization: 1, }; - wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + await act(async () => { + wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); + }); expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); }); - test('should navigate to teams list when cancel is clicked', () => { + test('should navigate to teams list when cancel is clicked', async () => { const history = createMemoryHistory({}); const wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/teams'); }); - test('should navigate to teams list when close (x) is clicked', () => { + test('should navigate to teams list when close (x) is clicked', async () => { const history = createMemoryHistory({}); const wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Close"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/teams'); }); @@ -55,11 +62,16 @@ describe('', () => { }, }, }); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('TeamForm').prop('handleSubmit')(teamData); + await act(async () => { + await wrapper.find('TeamForm').invoke('handleSubmit')(teamData); + }); expect(history.location.pathname).toEqual('/teams/5'); }); }); diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx index 7ea335361a..cb410405b1 100644 --- a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { TeamsAPI } from '@api'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; @@ -19,25 +20,29 @@ describe('', () => { }, }; - test('handleSubmit should call api update', () => { + test('handleSubmit should call api update', async () => { const wrapper = mountWithContexts(); const updatedTeamData = { name: 'new name', description: 'new description', }; - wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + await act(async () => { + wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData); + }); expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData); }); - test('should navigate to team detail when cancel is clicked', () => { + test('should navigate to team detail when cancel is clicked', async () => { const history = createMemoryHistory({}); const wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); expect(history.location.pathname).toEqual('/teams/1/details'); }); diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx index 0539c657fe..bedb63e678 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.test.jsx @@ -101,19 +101,21 @@ describe('', () => { }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); const formik = wrapper.find('Formik').instance(); - const changeState = new Promise(resolve => { - formik.setState( - { - values: { - ...jobTemplateData, - labels: [], - instanceGroups: [], + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...jobTemplateData, + labels: [], + instanceGroups: [], + }, }, - }, - () => resolve() - ); + () => resolve() + ); + }); + await changeState; }); - await changeState; wrapper.find('form').simulate('submit'); await sleep(1); expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData); diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 67e94ced20..2f5ab24c64 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -79,6 +79,7 @@ class JobTemplateForm extends Component { }; this.handleProjectValidation = this.handleProjectValidation.bind(this); this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this); + this.setContentError = this.setContentError.bind(this); } componentDidMount() { @@ -119,6 +120,10 @@ class JobTemplateForm extends Component { }; } + setContentError(contentError) { + this.setState({ contentError }); + } + render() { const { contentError, @@ -285,7 +290,7 @@ class JobTemplateForm extends Component { form={form} field={field} onBlur={() => form.setFieldTouched('playbook')} - onError={err => this.setState({ contentError: err })} + onError={this.setContentError} /> ); @@ -305,7 +310,7 @@ class JobTemplateForm extends Component { setFieldValue('labels', labels)} - onError={err => this.setState({ contentError: err })} + onError={this.setContentError} /> )} @@ -321,7 +326,7 @@ class JobTemplateForm extends Component { onChange={newCredentials => setFieldValue('credentials', newCredentials) } - onError={err => this.setState({ contentError: err })} + onError={this.setContentError} tooltip={i18n._( t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.` )} diff --git a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx index cc54d9d0f5..a727cdae81 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.test.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.test.jsx @@ -214,7 +214,7 @@ describe('UsersList with full permissions', () => { ); }); - test('api is called to delete users for each selected user.', () => { + test('api is called to delete users for each selected user.', async () => { UsersAPI.destroy = jest.fn(); wrapper.find('UsersList').setState({ users: mockUsers, @@ -223,7 +223,7 @@ describe('UsersList with full permissions', () => { isModalOpen: true, selected: mockUsers, }); - wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await wrapper.find('ToolbarDeleteButton').prop('onDelete')(); expect(UsersAPI.destroy).toHaveBeenCalledTimes(2); }); From 75b7d74f915fffb670da8117a839e624e35669ed Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 4 Dec 2019 08:48:49 -0800 Subject: [PATCH 053/109] Lookup tweaks/bug fixes --- .../src/components/Lookup/CredentialLookup.jsx | 2 +- .../Lookup/MultiCredentialsLookup.jsx | 6 ++---- .../Lookup/shared/LookupErrorMessage.jsx | 8 +++++--- .../src/components/Lookup/shared/reducer.js | 10 +++++++--- .../components/Lookup/shared/reducer.test.js | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index bea32e63de..6b61b3c486 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -71,7 +71,7 @@ function CredentialLookup({ optionCount={count} header={label} qsConfig={QS_CONFIG} - readOnly={canDelete} + readOnly={!canDelete} selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} /> diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 2d1eff8965..1a020cab26 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -43,9 +43,7 @@ function MultiCredentialsLookup(props) { try { const types = await loadCredentialTypes(); setCredentialTypes(types); - setSelectedType( - types.find(type => type.name === 'Machine') || types[0] - ); + setSelectedType(types.find(type => type.kind === 'ssh') || types[0]); } catch (err) { onError(err); } @@ -71,7 +69,7 @@ function MultiCredentialsLookup(props) { })(); }, [selectedType, history.location.search, onError]); - const isMultiple = selectedType && selectedType.name === 'Vault'; + const isMultiple = selectedType && selectedType.kind === 'vault'; const renderChip = ({ item, removeItem, canDelete }) => ( - {error.message || 'An error occured'} + {error.message || i18n._(t`An error occured`)}
); } -export default LookupErrorMessage; +export default withI18n()(LookupErrorMessage); diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.js b/awx/ui_next/src/components/Lookup/shared/reducer.js index bf97284f88..315f652846 100644 --- a/awx/ui_next/src/components/Lookup/shared/reducer.js +++ b/awx/ui_next/src/components/Lookup/shared/reducer.js @@ -1,5 +1,3 @@ -// import { useReducer, useEffect } from 'react'; - export default function reducer(state, action) { switch (action.type) { case 'SELECT_ITEM': @@ -51,10 +49,16 @@ function toggleModal(state) { if (isModalOpen) { return closeModal(state); } + let selectedItems = []; + if (multiple) { + selectedItems = [...value]; + } else if (value) { + selectedItems.push(value); + } return { ...state, isModalOpen: !isModalOpen, - selectedItems: multiple ? [...value] : [value], + selectedItems, }; } diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.test.js b/awx/ui_next/src/components/Lookup/shared/reducer.test.js index 22bf9da106..62c963cbfb 100644 --- a/awx/ui_next/src/components/Lookup/shared/reducer.test.js +++ b/awx/ui_next/src/components/Lookup/shared/reducer.test.js @@ -129,6 +129,24 @@ describe('Lookup reducer', () => { }); }); + it('should set null value to empty array', () => { + const state = { + isModalOpen: false, + selectedItems: [{ id: 1 }], + value: null, + multiple: false, + }; + const result = reducer(state, { + type: 'TOGGLE_MODAL', + }); + expect(result).toEqual({ + isModalOpen: true, + selectedItems: [], + value: null, + multiple: false, + }); + }); + it('should open the modal (multiple)', () => { const state = { isModalOpen: false, From 6e64b5c07083ff7abbb6b29e70a39b2c7018deba Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 4 Dec 2019 10:36:25 -0800 Subject: [PATCH 054/109] clean up act() errors in form tests after Lookup changes --- .../AnsibleSelect/AnsibleSelect.jsx | 5 +- .../src/screens/Host/HostAdd/HostAdd.test.jsx | 39 ++- .../OrganizationAdd/OrganizationAdd.test.jsx | 42 ++- .../OrganizationEdit.test.jsx | 45 +-- .../shared/OrganizationForm.test.jsx | 270 ++++++++++-------- .../Project/ProjectEdit/ProjectEdit.test.jsx | 6 +- .../src/screens/Team/shared/TeamForm.test.jsx | 40 +-- 7 files changed, 261 insertions(+), 186 deletions(-) diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx index c6e427309d..cf860ab8f0 100644 --- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx +++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.jsx @@ -25,7 +25,7 @@ class AnsibleSelect extends React.Component { } render() { - const { id, data, i18n, isValid, onBlur, value } = this.props; + const { id, data, i18n, isValid, onBlur, value, className } = this.props; return ( {data.map(option => ( {}, + className: '', }; AnsibleSelect.propTypes = { @@ -69,6 +71,7 @@ AnsibleSelect.propTypes = { onBlur: func, onChange: func.isRequired, value: oneOfType([string, number]).isRequired, + className: string, }; export { AnsibleSelect as _AnsibleSelect }; diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx index ab1be4ed6f..cd561c5815 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import HostAdd from './HostAdd'; @@ -7,8 +8,11 @@ import { HostsAPI } from '@api'; jest.mock('@api'); describe('', () => { - test('handleSubmit should post to api', () => { - const wrapper = mountWithContexts(); + test('handleSubmit should post to api', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedHostData = { name: 'new name', description: 'new description', @@ -19,21 +23,27 @@ describe('', () => { expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData); }); - test('should navigate to hosts list when cancel is clicked', () => { + test('should navigate to hosts list when cancel is clicked', async () => { const history = createMemoryHistory({}); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/hosts'); }); - test('should navigate to hosts list when close (x) is clicked', () => { + test('should navigate to hosts list when close (x) is clicked', async () => { const history = createMemoryHistory({}); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); + wrapper.find('button[aria-label="Close"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/hosts'); }); @@ -51,11 +61,14 @@ describe('', () => { ...hostData, }, }); - const wrapper = mountWithContexts(, { - context: { router: { history } }, + let wrapper; + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); }); await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('HostForm').prop('handleSubmit')(hostData); + await wrapper.find('HostForm').invoke('handleSubmit')(hostData); expect(history.location.pathname).toEqual('/hosts/5'); }); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx index b5823fa1c4..6d5974b217 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx @@ -9,6 +9,10 @@ jest.mock('@api'); describe('', () => { test('handleSubmit should post to api', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedOrgData = { name: 'new name', description: 'new description', @@ -27,22 +31,24 @@ describe('', () => { test('should navigate to organizations list when cancel is clicked', async () => { const history = createMemoryHistory({}); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); }); expect(history.location.pathname).toEqual('/organizations'); }); test('should navigate to organizations list when close (x) is clicked', async () => { const history = createMemoryHistory({}); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history } }, }); - wrapper.find('button[aria-label="Close"]').prop('onClick')(); + wrapper.find('button[aria-label="Close"]').invoke('onClick')(); }); expect(history.location.pathname).toEqual('/organizations'); }); @@ -63,8 +69,9 @@ describe('', () => { ...orgData, }, }); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(, { + wrapper = mountWithContexts(, { context: { router: { history } }, }); await waitForElement(wrapper, 'button[aria-label="Save"]'); @@ -92,23 +99,27 @@ describe('', () => { ...orgData, }, }); + let wrapper; await act(async () => { - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'button[aria-label="Save"]'); - await wrapper.find('OrganizationForm').prop('handleSubmit')( - orgData, - [3], - [] - ); + wrapper = mountWithContexts(); }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await wrapper.find('OrganizationForm').prop('handleSubmit')( + orgData, + [3], + [] + ); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3); }); test('AnsibleSelect component renders if there are virtual environments', async () => { + const config = { + custom_virtualenvs: ['foo', 'bar'], + }; let wrapper; await act(async () => { wrapper = mountWithContexts(, { - context: { config: { custom_virtualenvs: ['foo', 'bar'] } }, + context: { config }, }).find('AnsibleSelect'); }); expect(wrapper.find('FormSelect')).toHaveLength(1); @@ -122,10 +133,13 @@ describe('', () => { }); test('AnsibleSelect component does not render if there are 0 virtual environments', async () => { + const config = { + custom_virtualenvs: [], + }; let wrapper; await act(async () => { wrapper = mountWithContexts(, { - context: { config: { custom_virtualenvs: [] } }, + context: { config }, }).find('AnsibleSelect'); }); expect(wrapper.find('FormSelect')).toHaveLength(0); diff --git a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx index 0a9a4f9e82..42093807ae 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { OrganizationsAPI } from '@api'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; @@ -19,10 +20,11 @@ describe('', () => { }, }; - test('handleSubmit should call api update', () => { - const wrapper = mountWithContexts( - - ); + test('handleSubmit should call api update', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedOrgData = { name: 'new name', @@ -39,21 +41,23 @@ describe('', () => { }); test('handleSubmit associates and disassociates instance groups', async () => { - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); const updatedOrgData = { name: 'new name', description: 'new description', custom_virtualenv: 'Buzz', }; - wrapper.find('OrganizationForm').prop('handleSubmit')( - updatedOrgData, - [3, 4], - [2] - ); - await sleep(1); + await act(async () => { + wrapper.find('OrganizationForm').invoke('handleSubmit')( + updatedOrgData, + [3, 4], + [2] + ); + }); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4); @@ -63,14 +67,17 @@ describe('', () => { ); }); - test('should navigate to organization detail when cancel is clicked', () => { + test('should navigate to organization detail when cancel is clicked', async () => { const history = createMemoryHistory({}); - const wrapper = mountWithContexts( - , - { context: { router: { history } } } - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { context: { router: { history } } } + ); + }); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(history.location.pathname).toEqual('/organizations/1/details'); }); diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx index 52e234cf9c..9fda0b279d 100644 --- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx +++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; - +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import { OrganizationsAPI } from '@api'; @@ -30,18 +30,20 @@ describe('', () => { jest.clearAllMocks(); }); - test('should request related instance groups from api', () => { - mountWithContexts( - , - { - context: { network }, - } - ); + test('should request related instance groups from api', async () => { + await act(async () => { + mountWithContexts( + , + { + context: { network }, + } + ); + }); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1); }); @@ -53,34 +55,39 @@ describe('', () => { results: mockInstanceGroups, }, }); - const wrapper = mountWithContexts( - , - { - context: { network }, - } - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { network }, + } + ); + }); - await sleep(0); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled(); expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual( mockInstanceGroups ); }); - test('changing instance group successfully sets instanceGroups state', () => { - const wrapper = mountWithContexts( - - ); + test('changing instance group successfully sets instanceGroups state', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); const lookup = wrapper.find('InstanceGroupsLookup'); expect(lookup.length).toBe(1); @@ -102,15 +109,18 @@ describe('', () => { ]); }); - test('changing inputs should update form values', () => { - const wrapper = mountWithContexts( - - ); + test('changing inputs should update form values', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); const form = wrapper.find('Formik'); wrapper.find('input#org-name').simulate('change', { @@ -127,21 +137,24 @@ describe('', () => { expect(form.state('values').max_hosts).toEqual('134'); }); - test('AnsibleSelect component renders if there are virtual environments', () => { + test('AnsibleSelect component renders if there are virtual environments', async () => { const config = { custom_virtualenvs: ['foo', 'bar'], }; - const wrapper = mountWithContexts( - , - { - context: { config }, - } - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { config }, + } + ); + }); expect(wrapper.find('FormSelect')).toHaveLength(1); expect(wrapper.find('FormSelectOption')).toHaveLength(3); expect( @@ -154,14 +167,17 @@ describe('', () => { test('calls handleSubmit when form submitted', async () => { const handleSubmit = jest.fn(); - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(handleSubmit).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(1); @@ -194,18 +210,20 @@ describe('', () => { OrganizationsAPI.update.mockResolvedValue(1, mockDataForm); OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done'); OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done'); - const wrapper = mountWithContexts( - , - { - context: { network }, - } - ); - await sleep(0); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { network }, + } + ); + }); wrapper.find('InstanceGroupsLookup').prop('onChange')( [{ name: 'One', id: 1 }, { name: 'Three', id: 3 }], 'instanceGroups' @@ -219,15 +237,17 @@ describe('', () => { test('handleSubmit is called with max_hosts value if it is in range', async () => { const handleSubmit = jest.fn(); - // normal mount - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).toHaveBeenCalledWith( @@ -245,32 +265,38 @@ describe('', () => { test('handleSubmit does not get called if max_hosts value is out of range', async () => { const handleSubmit = jest.fn(); - // not mount with Negative value + // mount with negative value + let wrapper1; const mockDataNegative = JSON.parse(JSON.stringify(mockData)); mockDataNegative.max_hosts = -5; - const wrapper1 = mountWithContexts( - - ); + await act(async () => { + wrapper1 = mountWithContexts( + + ); + }); wrapper1.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).not.toHaveBeenCalled(); - // not mount with Out of Range value + // mount with out of range value + let wrapper2; const mockDataOoR = JSON.parse(JSON.stringify(mockData)); mockDataOoR.max_hosts = 999999999999; - const wrapper2 = mountWithContexts( - - ); + await act(async () => { + wrapper2 = mountWithContexts( + + ); + }); wrapper2.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).not.toHaveBeenCalled(); @@ -282,14 +308,17 @@ describe('', () => { // mount with String value (default to zero) const mockDataString = JSON.parse(JSON.stringify(mockData)); mockDataString.max_hosts = 'Bee'; - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(0); expect(handleSubmit).toHaveBeenCalledWith( @@ -304,17 +333,20 @@ describe('', () => { ); }); - test('calls "handleCancel" when Cancel button is clicked', () => { + test('calls "handleCancel" when Cancel button is clicked', async () => { const handleCancel = jest.fn(); - const wrapper = mountWithContexts( - - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); expect(handleCancel).toBeCalled(); diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx index 8f8b400cf1..8927dff7c2 100644 --- a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx @@ -144,8 +144,8 @@ describe('', () => { wrapper = mountWithContexts(, { context: { router: { history } }, }); + wrapper.find('CardCloseButton').simulate('click'); }); - wrapper.find('CardCloseButton').simulate('click'); expect(history.location.pathname).toEqual('/projects/123/details'); }); @@ -157,7 +157,9 @@ describe('', () => { }); }); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); - wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click'); + await act(async () => { + wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click'); + }); expect(history.location.pathname).toEqual('/projects/123/details'); }); }); diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx index 0d8a483417..da8a5d282e 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx @@ -30,15 +30,17 @@ describe('', () => { jest.clearAllMocks(); }); - test('changing inputs should update form values', () => { - wrapper = mountWithContexts( - - ); + test('changing inputs should update form values', async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); const form = wrapper.find('Formik'); wrapper.find('input#team-name').simulate('change', { @@ -78,17 +80,19 @@ describe('', () => { expect(handleSubmit).toBeCalled(); }); - test('calls handleCancel when Cancel button is clicked', () => { + test('calls handleCancel when Cancel button is clicked', async () => { const handleCancel = jest.fn(); - wrapper = mountWithContexts( - - ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); expect(handleCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); expect(handleCancel).toBeCalled(); From c003e89ea92242559e8f2f57258d316526ec3e0c Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 4 Dec 2019 10:57:01 -0800 Subject: [PATCH 055/109] fix loading jank in MultiCredentialLookup --- .../ContentLoading/ContentLoading.jsx | 4 +- .../Lookup/MultiCredentialsLookup.jsx | 4 ++ .../PaginatedDataList/PaginatedDataList.jsx | 39 +++++++++++++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx index f242bbca10..90747007da 100644 --- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx +++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx @@ -4,8 +4,8 @@ import { withI18n } from '@lingui/react'; import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; // TODO: Better loading state - skeleton lines / spinner, etc. -const ContentLoading = ({ i18n }) => ( - +const ContentLoading = ({ className, i18n }) => ( + {i18n._(t`Loading...`)} ); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 1a020cab26..0a472e361f 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -37,6 +37,7 @@ function MultiCredentialsLookup(props) { const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); const [credentialsCount, setCredentialsCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { (async () => { @@ -56,6 +57,7 @@ function MultiCredentialsLookup(props) { return; } try { + setIsLoading(true); const params = parseQueryString(QS_CONFIG, history.location.search); const { results, count } = await loadCredentials( params, @@ -63,6 +65,7 @@ function MultiCredentialsLookup(props) { ); setCredentials(results); setCredentialsCount(count); + setIsLoading(false); } catch (err) { onError(err); } @@ -133,6 +136,7 @@ function MultiCredentialsLookup(props) { name="credentials" qsConfig={QS_CONFIG} readOnly={!canDelete} + isLoading={isLoading} selectItem={item => { if (isMultiple) { return dispatch({ type: 'SELECT_ITEM', item }); diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index e89431d3a8..223bcd5950 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -25,10 +25,34 @@ import PaginatedDataListItem from './PaginatedDataListItem'; class PaginatedDataList extends React.Component { constructor(props) { super(props); + this.state = { + height: 0, + }; + this.ref = React.createRef(); this.handleSetPage = this.handleSetPage.bind(this); this.handleSetPageSize = this.handleSetPageSize.bind(this); } + componentDidUpdate(prevProps) { + const { items } = this.props; + if (prevProps.items !== items) { + this.findHeight(); + } + } + + findHeight() { + if (!this.ref || !this.ref.current) { + return; + } + const { state } = this; + const height = this.ref.current.scrollHeight; + if (height && height !== state.height) { + this.setState({ + height: this.ref.current.scrollHeight, + }); + } + } + handleSetPage(event, pageNumber) { const { history, qsConfig } = this.props; const { search } = history.location; @@ -66,6 +90,7 @@ class PaginatedDataList extends React.Component { i18n, renderToolbar, } = this.props; + const { height } = this.state; const columns = toolbarColumns.length ? toolbarColumns : [ @@ -85,8 +110,14 @@ class PaginatedDataList extends React.Component { const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); let Content; - if (hasContentLoading && items.length <= 0) { - Content = ; + if (hasContentLoading) { + Content = ( + + ); } else if (contentError) { Content = ; } else if (items.length <= 0) { @@ -100,7 +131,7 @@ class PaginatedDataList extends React.Component { } return ( - +
) : null} - +
); } } From f54616912dfde2dd218ce05dc85638e2723dcec3 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 4 Dec 2019 11:02:02 -0800 Subject: [PATCH 056/109] de-lint --- .../src/components/PaginatedDataList/PaginatedDataList.jsx | 2 +- .../Organization/OrganizationEdit/OrganizationEdit.test.jsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index 223bcd5950..9c3c33dce9 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React from 'react'; import PropTypes, { arrayOf, shape, string, bool } from 'prop-types'; import { DataList } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; diff --git a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx index 42093807ae..2ecf914440 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationEdit/OrganizationEdit.test.jsx @@ -7,8 +7,6 @@ import OrganizationEdit from './OrganizationEdit'; jest.mock('@api'); -const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); - describe('', () => { const mockData = { name: 'Foo', From 9de165a676c31daaa7a39ffe96514a5994ca22b8 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 4 Dec 2019 11:41:47 -0800 Subject: [PATCH 057/109] revert MultiCredentialLookup loading jank fix --- .../Lookup/MultiCredentialsLookup.jsx | 10 ++--- .../PaginatedDataList/PaginatedDataList.jsx | 41 +++---------------- 2 files changed, 9 insertions(+), 42 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 0a472e361f..1effa9282d 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -37,14 +37,14 @@ function MultiCredentialsLookup(props) { const [selectedType, setSelectedType] = useState(null); const [credentials, setCredentials] = useState([]); const [credentialsCount, setCredentialsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); useEffect(() => { (async () => { try { const types = await loadCredentialTypes(); setCredentialTypes(types); - setSelectedType(types.find(type => type.kind === 'ssh') || types[0]); + const match = types.find(type => type.kind === 'ssh') || types[0]; + setSelectedType(match); } catch (err) { onError(err); } @@ -57,7 +57,6 @@ function MultiCredentialsLookup(props) { return; } try { - setIsLoading(true); const params = parseQueryString(QS_CONFIG, history.location.search); const { results, count } = await loadCredentials( params, @@ -65,14 +64,12 @@ function MultiCredentialsLookup(props) { ); setCredentials(results); setCredentialsCount(count); - setIsLoading(false); } catch (err) { onError(err); } })(); }, [selectedType, history.location.search, onError]); - const isMultiple = selectedType && selectedType.kind === 'vault'; const renderChip = ({ item, removeItem, canDelete }) => ( ); + const isMultiple = selectedType && selectedType.kind === 'vault'; + return ( {tooltip && } @@ -136,7 +135,6 @@ function MultiCredentialsLookup(props) { name="credentials" qsConfig={QS_CONFIG} readOnly={!canDelete} - isLoading={isLoading} selectItem={item => { if (isMultiple) { return dispatch({ type: 'SELECT_ITEM', item }); diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index 9c3c33dce9..e89431d3a8 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes, { arrayOf, shape, string, bool } from 'prop-types'; import { DataList } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; @@ -25,34 +25,10 @@ import PaginatedDataListItem from './PaginatedDataListItem'; class PaginatedDataList extends React.Component { constructor(props) { super(props); - this.state = { - height: 0, - }; - this.ref = React.createRef(); this.handleSetPage = this.handleSetPage.bind(this); this.handleSetPageSize = this.handleSetPageSize.bind(this); } - componentDidUpdate(prevProps) { - const { items } = this.props; - if (prevProps.items !== items) { - this.findHeight(); - } - } - - findHeight() { - if (!this.ref || !this.ref.current) { - return; - } - const { state } = this; - const height = this.ref.current.scrollHeight; - if (height && height !== state.height) { - this.setState({ - height: this.ref.current.scrollHeight, - }); - } - } - handleSetPage(event, pageNumber) { const { history, qsConfig } = this.props; const { search } = history.location; @@ -90,7 +66,6 @@ class PaginatedDataList extends React.Component { i18n, renderToolbar, } = this.props; - const { height } = this.state; const columns = toolbarColumns.length ? toolbarColumns : [ @@ -110,14 +85,8 @@ class PaginatedDataList extends React.Component { const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `); let Content; - if (hasContentLoading) { - Content = ( - - ); + if (hasContentLoading && items.length <= 0) { + Content = ; } else if (contentError) { Content = ; } else if (items.length <= 0) { @@ -131,7 +100,7 @@ class PaginatedDataList extends React.Component { } return ( -
+ ) : null} -
+
); } } From 3409d39150058f73a5b6af2685e2fc6dd047e205 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 4 Dec 2019 12:59:28 -0800 Subject: [PATCH 058/109] fix ProjectLookup re-renders --- .../src/screens/Template/shared/JobTemplateForm.jsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 2f5ab24c64..68aff316f0 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -79,6 +79,7 @@ class JobTemplateForm extends Component { }; this.handleProjectValidation = this.handleProjectValidation.bind(this); this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this); + this.handleProjectUpdate = this.handleProjectUpdate.bind(this); this.setContentError = this.setContentError.bind(this); } @@ -120,6 +121,12 @@ class JobTemplateForm extends Component { }; } + handleProjectUpdate(project) { + const { setFieldValue } = this.props; + setFieldValue('project', project.id); + this.setState({ project }); + } + setContentError(contentError) { this.setState({ contentError }); } @@ -257,10 +264,7 @@ class JobTemplateForm extends Component { you want this job to execute.`)} isValid={!form.touched.project || !form.errors.project} helperTextInvalid={form.errors.project} - onChange={value => { - form.setFieldValue('project', value.id); - this.setState({ project: value }); - }} + onChange={this.handleProjectUpdate} required /> )} From 846fd676180f0a4d2dedd621fcbd583cc0de5b14 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 10 Dec 2019 12:13:22 -0800 Subject: [PATCH 059/109] de-lint --- .../Organization/OrganizationAdd/OrganizationAdd.test.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx index 6d5974b217..c690c8b9f6 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx @@ -9,10 +9,6 @@ jest.mock('@api'); describe('', () => { test('handleSubmit should post to api', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); const updatedOrgData = { name: 'new name', description: 'new description', From b58bff4686d834454ede2a967619fb04ac689e0e Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 13:09:46 -0500 Subject: [PATCH 060/109] Add setting for configurable login redirect URL --- awx/api/conf.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/awx/api/conf.py b/awx/api/conf.py index 688aad162f..b2cb2a641c 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -62,3 +62,14 @@ register( category=_('Authentication'), category_slug='authentication', ) +register( + 'LOGIN_REDIRECT_OVERRIDE', + field_class=fields.CharField, + allow_blank=True, + required=False, + label=_('Login redirect override URL'), + help_text=_('URL to which unauthorized users will be redirected to log in. ' + 'If blank, users will be sent to the Tower login page.'), + category=_('Authentication'), + category_slug='authentication', +) From 2569ec4f4fa6373be993ae0a17bae15ea2592a05 Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 14:57:03 -0500 Subject: [PATCH 061/109] Add default for LOGIN_REDIRECT_OVERRIDE --- awx/settings/defaults.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 617ac2e440..ed3a4dfdcb 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -379,6 +379,10 @@ TACACSPLUS_AUTH_PROTOCOL = 'ascii' # Note: This setting may be overridden by database settings. AUTH_BASIC_ENABLED = True +# If set, specifies a URL that unauthenticated users will be redirected to +# when trying to access a UI page that requries authentication. +LOGIN_REDIRECT_OVERRIDE = None + # If set, serve only minified JS for UI. USE_MINIFIED_JS = False From f94438cf9b10e734a25fbaa4d86d172249f70b41 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 10 Dec 2019 15:07:12 -0500 Subject: [PATCH 062/109] Adds login redirect override field to the System (Misc System) Settings interface --- .../forms/system-form/sub-forms/system-misc.form.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js b/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js index b283107729..22f2b8bdc0 100644 --- a/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js +++ b/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js @@ -43,6 +43,10 @@ export default ['i18n', function(i18n) { ALLOW_OAUTH2_FOR_EXTERNAL_USERS: { type: 'toggleSwitch', }, + LOGIN_REDIRECT_OVERRIDE: { + type: 'text', + reset: 'LOGIN_REDIRECT_OVERRIDE' + }, ACCESS_TOKEN_EXPIRE_SECONDS: { type: 'text', reset: 'ACCESS_TOKEN_EXPIRE_SECONDS' From ab2f212b044560492d2ca343ecdff79bd3d71f87 Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 15:29:21 -0500 Subject: [PATCH 063/109] Add /login convenience URL --- awx/main/views.py | 5 ++++- awx/urls.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/awx/main/views.py b/awx/main/views.py index 1947bd001c..bc791976db 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -4,7 +4,7 @@ import json # Django -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ @@ -97,3 +97,6 @@ def handle_csp_violation(request): logger = logging.getLogger('awx') logger.error(json.loads(request.body)) return HttpResponse(content=None) + +def handle_login_redirect(request): + return HttpResponseRedirect("/#/login") diff --git a/awx/urls.py b/awx/urls.py index 970047151d..ba0f0ee421 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -9,6 +9,7 @@ from awx.main.views import ( handle_404, handle_500, handle_csp_violation, + handle_login_redirect, ) @@ -22,6 +23,7 @@ urlpatterns = [ url(r'^(?:api/)?404.html$', handle_404), url(r'^(?:api/)?500.html$', handle_500), url(r'^csp-violation/', handle_csp_violation), + url(r'^login/', handle_login_redirect), ] if settings.SETTINGS_MODULE == 'awx.settings.development': From 732da522393c53929cd137e37fb034d8b25e7933 Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 15:46:56 -0500 Subject: [PATCH 064/109] Expose login redirect URL in unauthenticated /api view --- awx/api/views/root.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 91f6b62149..e4e0657652 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -60,6 +60,7 @@ class ApiRootView(APIView): data['oauth2'] = drf_reverse('api:oauth_authorization_root_view') data['custom_logo'] = settings.CUSTOM_LOGO data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO + data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE return Response(data) From d899e75ad7c223d6ec9302f3a9069cf3f5874af4 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 09:19:38 -0500 Subject: [PATCH 065/109] Adds logic to redirect unauthenticated user if LOGIN_REDIRECT_OVERRIDE is set as long as the user is not navigating to /login or /#/login. Also redirects on logout if LOGIN_REDIRECT_OVERRIDE is set. --- awx/ui/client/src/app.js | 14 ++++++++++++-- awx/ui/client/src/login/logout.route.js | 6 +++++- .../src/shared/load-config/load-config.factory.js | 10 ++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 54e05c4a1e..e6902339e2 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -375,7 +375,13 @@ angular if (!/^\/(login|logout)/.test($location.path())) { $rootScope.preAuthUrl = $location.path(); } - $location.path('/login'); + if ($location.path() !== '/login') { + if (global.$AnsibleConfig.login_redirect_override) { + window.location.replace(global.$AnsibleConfig.login_redirect_override); + } else { + $location.path('/login'); + } + } } else { var lastUser = $cookies.getObject('current_user'), timestammp = Store('sessionTime'); @@ -383,7 +389,11 @@ angular var stime = timestammp[lastUser.id].time, now = new Date().getTime(); if ((stime - now) <= 0) { - $location.path('/login'); + if (global.$AnsibleConfig.login_redirect_override) { + window.location.replace(global.$AnsibleConfig.login_redirect_override); + } else { + $location.path('/login'); + } } } // If browser refresh, set the user_is_superuser value diff --git a/awx/ui/client/src/login/logout.route.js b/awx/ui/client/src/login/logout.route.js index 47da767ec5..cf5c228a30 100644 --- a/awx/ui/client/src/login/logout.route.js +++ b/awx/ui/client/src/login/logout.route.js @@ -11,7 +11,11 @@ export default { route: '/logout', controller: ['Authorization', '$state', function(Authorization, $state) { Authorization.logout().then( () =>{ - $state.go('signIn'); + if (global.$AnsibleConfig.login_redirect_override) { + window.location.replace(global.$AnsibleConfig.login_redirect_override); + } else { + $state.go('signIn'); + } }); }], diff --git a/awx/ui/client/src/shared/load-config/load-config.factory.js b/awx/ui/client/src/shared/load-config/load-config.factory.js index ca12f1739b..38a2d8b3b7 100644 --- a/awx/ui/client/src/shared/load-config/load-config.factory.js +++ b/awx/ui/client/src/shared/load-config/load-config.factory.js @@ -2,7 +2,6 @@ export default function LoadConfig($log, $rootScope, $http, Store) { return function() { - var configSettings = {}; var configInit = function() { @@ -10,12 +9,11 @@ export default if ($rootScope.loginConfig) { $rootScope.loginConfig.resolve('config loaded'); } + global.$AnsibleConfig = configSettings; + Store('AnsibleConfig', global.$AnsibleConfig); $rootScope.$emit('ConfigReady'); // Load new hardcoded settings from above - - global.$AnsibleConfig = configSettings; - Store('AnsibleConfig', global.$AnsibleConfig); $rootScope.$emit('LoadConfig'); }; @@ -39,6 +37,10 @@ export default configSettings.custom_login_info = false; } + if (data.login_redirect_override) { + configSettings.login_redirect_override = data.login_redirect_override; + } + configInit(); }).catch(({error}) => { From 25cc34188870af55e0e31f6ba42d4240df3cf253 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 10:56:46 -0500 Subject: [PATCH 066/109] Reverts changes to logout logic. We don't want to redirect to an override url if the user explicitly logs out. --- awx/ui/client/src/login/logout.route.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/awx/ui/client/src/login/logout.route.js b/awx/ui/client/src/login/logout.route.js index cf5c228a30..47da767ec5 100644 --- a/awx/ui/client/src/login/logout.route.js +++ b/awx/ui/client/src/login/logout.route.js @@ -11,11 +11,7 @@ export default { route: '/logout', controller: ['Authorization', '$state', function(Authorization, $state) { Authorization.logout().then( () =>{ - if (global.$AnsibleConfig.login_redirect_override) { - window.location.replace(global.$AnsibleConfig.login_redirect_override); - } else { - $state.go('signIn'); - } + $state.go('signIn'); }); }], From bf6c16197ca3fec33d8363ebb5866a586eda6e01 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 11:19:55 -0500 Subject: [PATCH 067/109] Moves config request out to block of code that gets executed before the app is bootstrapped. This should allow us to redirect to the override url before the app begins to render, improving the UX. --- awx/ui/client/src/app.js | 8 +-- awx/ui/client/src/app.start.js | 23 ++++++- .../shared/load-config/load-config.factory.js | 69 +++++++------------ 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index e6902339e2..071db41067 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -375,13 +375,7 @@ angular if (!/^\/(login|logout)/.test($location.path())) { $rootScope.preAuthUrl = $location.path(); } - if ($location.path() !== '/login') { - if (global.$AnsibleConfig.login_redirect_override) { - window.location.replace(global.$AnsibleConfig.login_redirect_override); - } else { - $location.path('/login'); - } - } + $location.path('/login'); } else { var lastUser = $cookies.getObject('current_user'), timestammp = Store('sessionTime'); diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 44f8113567..61c8ac5bb4 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -15,7 +15,9 @@ function bootstrap (callback) { angular.module('I18N').constant('LOCALE', locale); } - angular.element(document).ready(() => callback()); + fetchConfig((config) => { + angular.element(document).ready(() => callback()); + }); }); } @@ -49,6 +51,25 @@ function fetchLocaleStrings (callback) { request.fail(() => callback({ code: DEFAULT_LOCALE })); } +function fetchConfig (callback) { + const request = $.ajax(`/api`); + + request.done(res => { + angular.module('awApp').constant('ConfigSettings', res); + if (res.login_redirect_override) { + if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) { + window.location.replace(res.login_redirect_override); + } else { + callback(); + } + } else { + callback(); + } + }); + + request.fail(() => callback()); +} + /** * Grabs the language off of navigator for browser compatibility. * If the language isn't set, then it falls back to the DEFAULT_LOCALE. The diff --git a/awx/ui/client/src/shared/load-config/load-config.factory.js b/awx/ui/client/src/shared/load-config/load-config.factory.js index 38a2d8b3b7..c1adfeee8f 100644 --- a/awx/ui/client/src/shared/load-config/load-config.factory.js +++ b/awx/ui/client/src/shared/load-config/load-config.factory.js @@ -1,57 +1,40 @@ export default - function LoadConfig($log, $rootScope, $http, Store) { + function LoadConfig($rootScope, Store, ConfigSettings) { return function() { var configSettings = {}; - var configInit = function() { - // Auto-resolving what used to be found when attempting to load local_setting.json - if ($rootScope.loginConfig) { - $rootScope.loginConfig.resolve('config loaded'); - } - global.$AnsibleConfig = configSettings; - Store('AnsibleConfig', global.$AnsibleConfig); - $rootScope.$emit('ConfigReady'); + if(ConfigSettings.custom_logo) { + configSettings.custom_logo = true; + $rootScope.custom_logo = ConfigSettings.custom_logo; + } else { + configSettings.custom_logo = false; + } - // Load new hardcoded settings from above - $rootScope.$emit('LoadConfig'); - }; + if(ConfigSettings.custom_login_info) { + configSettings.custom_login_info = ConfigSettings.custom_login_info; + $rootScope.custom_login_info = ConfigSettings.custom_login_info; + } else { + configSettings.custom_login_info = false; + } - // Retrieve the custom logo information - update configSettings from above - $http({ - method: 'GET', - url: '/api/', - }) - .then(function({data}) { - if(data.custom_logo) { - configSettings.custom_logo = true; - $rootScope.custom_logo = data.custom_logo; - } else { - configSettings.custom_logo = false; - } + if (ConfigSettings.login_redirect_override) { + configSettings.login_redirect_override = ConfigSettings.login_redirect_override; + } - if(data.custom_login_info) { - configSettings.custom_login_info = data.custom_login_info; - $rootScope.custom_login_info = data.custom_login_info; - } else { - configSettings.custom_login_info = false; - } + // Auto-resolving what used to be found when attempting to load local_setting.json + if ($rootScope.loginConfig) { + $rootScope.loginConfig.resolve('config loaded'); + } + global.$AnsibleConfig = configSettings; + Store('AnsibleConfig', global.$AnsibleConfig); + $rootScope.$emit('ConfigReady'); - if (data.login_redirect_override) { - configSettings.login_redirect_override = data.login_redirect_override; - } - - configInit(); - - }).catch(({error}) => { - $log.debug(error); - configInit(); - }); + // Load new hardcoded settings from above + $rootScope.$emit('LoadConfig'); }; } LoadConfig.$inject = - [ '$log', '$rootScope', '$http', - 'Store' - ]; + [ '$rootScope', 'Store', 'ConfigSettings' ]; From a8d34b46fb73f1de550e97399dedd26631916abb Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 13:09:46 -0500 Subject: [PATCH 068/109] Add setting for configurable login redirect URL --- awx/api/conf.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/awx/api/conf.py b/awx/api/conf.py index 688aad162f..b2cb2a641c 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -62,3 +62,14 @@ register( category=_('Authentication'), category_slug='authentication', ) +register( + 'LOGIN_REDIRECT_OVERRIDE', + field_class=fields.CharField, + allow_blank=True, + required=False, + label=_('Login redirect override URL'), + help_text=_('URL to which unauthorized users will be redirected to log in. ' + 'If blank, users will be sent to the Tower login page.'), + category=_('Authentication'), + category_slug='authentication', +) From 7700050d1029e84532a41fe8ad40af31cceed65d Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 14:57:03 -0500 Subject: [PATCH 069/109] Add default for LOGIN_REDIRECT_OVERRIDE --- awx/settings/defaults.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index d105828225..08aa4f73f6 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -373,6 +373,10 @@ TACACSPLUS_AUTH_PROTOCOL = 'ascii' # Note: This setting may be overridden by database settings. AUTH_BASIC_ENABLED = True +# If set, specifies a URL that unauthenticated users will be redirected to +# when trying to access a UI page that requries authentication. +LOGIN_REDIRECT_OVERRIDE = None + # If set, serve only minified JS for UI. USE_MINIFIED_JS = False From f467e26842cae49bf799087d79aebb4c45afac0c Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 10 Dec 2019 15:07:12 -0500 Subject: [PATCH 070/109] Adds login redirect override field to the System (Misc System) Settings interface --- .../forms/system-form/sub-forms/system-misc.form.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js b/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js index 18d3b1c8be..636d4de7f3 100644 --- a/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js +++ b/awx/ui/client/src/configuration/forms/system-form/sub-forms/system-misc.form.js @@ -40,6 +40,10 @@ export default ['i18n', function(i18n) { ALLOW_OAUTH2_FOR_EXTERNAL_USERS: { type: 'toggleSwitch', }, + LOGIN_REDIRECT_OVERRIDE: { + type: 'text', + reset: 'LOGIN_REDIRECT_OVERRIDE' + }, ACCESS_TOKEN_EXPIRE_SECONDS: { type: 'text', reset: 'ACCESS_TOKEN_EXPIRE_SECONDS' From 2b111c81df5cc6ede7fc3cea80e1a267e55b4098 Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 15:29:21 -0500 Subject: [PATCH 071/109] Add /login convenience URL --- awx/main/views.py | 5 ++++- awx/urls.py | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/awx/main/views.py b/awx/main/views.py index 1947bd001c..bc791976db 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -4,7 +4,7 @@ import json # Django -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ @@ -97,3 +97,6 @@ def handle_csp_violation(request): logger = logging.getLogger('awx') logger.error(json.loads(request.body)) return HttpResponse(content=None) + +def handle_login_redirect(request): + return HttpResponseRedirect("/#/login") diff --git a/awx/urls.py b/awx/urls.py index 970047151d..ba0f0ee421 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -9,6 +9,7 @@ from awx.main.views import ( handle_404, handle_500, handle_csp_violation, + handle_login_redirect, ) @@ -22,6 +23,7 @@ urlpatterns = [ url(r'^(?:api/)?404.html$', handle_404), url(r'^(?:api/)?500.html$', handle_500), url(r'^csp-violation/', handle_csp_violation), + url(r'^login/', handle_login_redirect), ] if settings.SETTINGS_MODULE == 'awx.settings.development': From 9c9496a683a2e8d490f220822dfff2bc633401e3 Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 10 Dec 2019 15:46:56 -0500 Subject: [PATCH 072/109] Expose login redirect URL in unauthenticated /api view --- awx/api/views/root.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 91f6b62149..e4e0657652 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -60,6 +60,7 @@ class ApiRootView(APIView): data['oauth2'] = drf_reverse('api:oauth_authorization_root_view') data['custom_logo'] = settings.CUSTOM_LOGO data['custom_login_info'] = settings.CUSTOM_LOGIN_INFO + data['login_redirect_override'] = settings.LOGIN_REDIRECT_OVERRIDE return Response(data) From 181421a2ee6191eea51192faeabb7dd2b5c66274 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 09:19:38 -0500 Subject: [PATCH 073/109] Adds logic to redirect unauthenticated user if LOGIN_REDIRECT_OVERRIDE is set as long as the user is not navigating to /login or /#/login. Also redirects on logout if LOGIN_REDIRECT_OVERRIDE is set. --- awx/ui/client/src/app.js | 14 ++++++++++++-- awx/ui/client/src/login/logout.route.js | 6 +++++- .../src/shared/load-config/load-config.factory.js | 10 ++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 54e05c4a1e..e6902339e2 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -375,7 +375,13 @@ angular if (!/^\/(login|logout)/.test($location.path())) { $rootScope.preAuthUrl = $location.path(); } - $location.path('/login'); + if ($location.path() !== '/login') { + if (global.$AnsibleConfig.login_redirect_override) { + window.location.replace(global.$AnsibleConfig.login_redirect_override); + } else { + $location.path('/login'); + } + } } else { var lastUser = $cookies.getObject('current_user'), timestammp = Store('sessionTime'); @@ -383,7 +389,11 @@ angular var stime = timestammp[lastUser.id].time, now = new Date().getTime(); if ((stime - now) <= 0) { - $location.path('/login'); + if (global.$AnsibleConfig.login_redirect_override) { + window.location.replace(global.$AnsibleConfig.login_redirect_override); + } else { + $location.path('/login'); + } } } // If browser refresh, set the user_is_superuser value diff --git a/awx/ui/client/src/login/logout.route.js b/awx/ui/client/src/login/logout.route.js index 47da767ec5..cf5c228a30 100644 --- a/awx/ui/client/src/login/logout.route.js +++ b/awx/ui/client/src/login/logout.route.js @@ -11,7 +11,11 @@ export default { route: '/logout', controller: ['Authorization', '$state', function(Authorization, $state) { Authorization.logout().then( () =>{ - $state.go('signIn'); + if (global.$AnsibleConfig.login_redirect_override) { + window.location.replace(global.$AnsibleConfig.login_redirect_override); + } else { + $state.go('signIn'); + } }); }], diff --git a/awx/ui/client/src/shared/load-config/load-config.factory.js b/awx/ui/client/src/shared/load-config/load-config.factory.js index ca12f1739b..38a2d8b3b7 100644 --- a/awx/ui/client/src/shared/load-config/load-config.factory.js +++ b/awx/ui/client/src/shared/load-config/load-config.factory.js @@ -2,7 +2,6 @@ export default function LoadConfig($log, $rootScope, $http, Store) { return function() { - var configSettings = {}; var configInit = function() { @@ -10,12 +9,11 @@ export default if ($rootScope.loginConfig) { $rootScope.loginConfig.resolve('config loaded'); } + global.$AnsibleConfig = configSettings; + Store('AnsibleConfig', global.$AnsibleConfig); $rootScope.$emit('ConfigReady'); // Load new hardcoded settings from above - - global.$AnsibleConfig = configSettings; - Store('AnsibleConfig', global.$AnsibleConfig); $rootScope.$emit('LoadConfig'); }; @@ -39,6 +37,10 @@ export default configSettings.custom_login_info = false; } + if (data.login_redirect_override) { + configSettings.login_redirect_override = data.login_redirect_override; + } + configInit(); }).catch(({error}) => { From 794ce96b17323e202f15c3d3e664004948723978 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 10:56:46 -0500 Subject: [PATCH 074/109] Reverts changes to logout logic. We don't want to redirect to an override url if the user explicitly logs out. --- awx/ui/client/src/login/logout.route.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/awx/ui/client/src/login/logout.route.js b/awx/ui/client/src/login/logout.route.js index cf5c228a30..47da767ec5 100644 --- a/awx/ui/client/src/login/logout.route.js +++ b/awx/ui/client/src/login/logout.route.js @@ -11,11 +11,7 @@ export default { route: '/logout', controller: ['Authorization', '$state', function(Authorization, $state) { Authorization.logout().then( () =>{ - if (global.$AnsibleConfig.login_redirect_override) { - window.location.replace(global.$AnsibleConfig.login_redirect_override); - } else { - $state.go('signIn'); - } + $state.go('signIn'); }); }], From 1d9ce6cc15573f78b200c942008e32d44ebc9aa2 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 11:19:55 -0500 Subject: [PATCH 075/109] Moves config request out to block of code that gets executed before the app is bootstrapped. This should allow us to redirect to the override url before the app begins to render, improving the UX. --- awx/ui/client/src/app.js | 8 +-- awx/ui/client/src/app.start.js | 23 ++++++- .../shared/load-config/load-config.factory.js | 69 +++++++------------ 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index e6902339e2..071db41067 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -375,13 +375,7 @@ angular if (!/^\/(login|logout)/.test($location.path())) { $rootScope.preAuthUrl = $location.path(); } - if ($location.path() !== '/login') { - if (global.$AnsibleConfig.login_redirect_override) { - window.location.replace(global.$AnsibleConfig.login_redirect_override); - } else { - $location.path('/login'); - } - } + $location.path('/login'); } else { var lastUser = $cookies.getObject('current_user'), timestammp = Store('sessionTime'); diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 44f8113567..61c8ac5bb4 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -15,7 +15,9 @@ function bootstrap (callback) { angular.module('I18N').constant('LOCALE', locale); } - angular.element(document).ready(() => callback()); + fetchConfig((config) => { + angular.element(document).ready(() => callback()); + }); }); } @@ -49,6 +51,25 @@ function fetchLocaleStrings (callback) { request.fail(() => callback({ code: DEFAULT_LOCALE })); } +function fetchConfig (callback) { + const request = $.ajax(`/api`); + + request.done(res => { + angular.module('awApp').constant('ConfigSettings', res); + if (res.login_redirect_override) { + if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) { + window.location.replace(res.login_redirect_override); + } else { + callback(); + } + } else { + callback(); + } + }); + + request.fail(() => callback()); +} + /** * Grabs the language off of navigator for browser compatibility. * If the language isn't set, then it falls back to the DEFAULT_LOCALE. The diff --git a/awx/ui/client/src/shared/load-config/load-config.factory.js b/awx/ui/client/src/shared/load-config/load-config.factory.js index 38a2d8b3b7..c1adfeee8f 100644 --- a/awx/ui/client/src/shared/load-config/load-config.factory.js +++ b/awx/ui/client/src/shared/load-config/load-config.factory.js @@ -1,57 +1,40 @@ export default - function LoadConfig($log, $rootScope, $http, Store) { + function LoadConfig($rootScope, Store, ConfigSettings) { return function() { var configSettings = {}; - var configInit = function() { - // Auto-resolving what used to be found when attempting to load local_setting.json - if ($rootScope.loginConfig) { - $rootScope.loginConfig.resolve('config loaded'); - } - global.$AnsibleConfig = configSettings; - Store('AnsibleConfig', global.$AnsibleConfig); - $rootScope.$emit('ConfigReady'); + if(ConfigSettings.custom_logo) { + configSettings.custom_logo = true; + $rootScope.custom_logo = ConfigSettings.custom_logo; + } else { + configSettings.custom_logo = false; + } - // Load new hardcoded settings from above - $rootScope.$emit('LoadConfig'); - }; + if(ConfigSettings.custom_login_info) { + configSettings.custom_login_info = ConfigSettings.custom_login_info; + $rootScope.custom_login_info = ConfigSettings.custom_login_info; + } else { + configSettings.custom_login_info = false; + } - // Retrieve the custom logo information - update configSettings from above - $http({ - method: 'GET', - url: '/api/', - }) - .then(function({data}) { - if(data.custom_logo) { - configSettings.custom_logo = true; - $rootScope.custom_logo = data.custom_logo; - } else { - configSettings.custom_logo = false; - } + if (ConfigSettings.login_redirect_override) { + configSettings.login_redirect_override = ConfigSettings.login_redirect_override; + } - if(data.custom_login_info) { - configSettings.custom_login_info = data.custom_login_info; - $rootScope.custom_login_info = data.custom_login_info; - } else { - configSettings.custom_login_info = false; - } + // Auto-resolving what used to be found when attempting to load local_setting.json + if ($rootScope.loginConfig) { + $rootScope.loginConfig.resolve('config loaded'); + } + global.$AnsibleConfig = configSettings; + Store('AnsibleConfig', global.$AnsibleConfig); + $rootScope.$emit('ConfigReady'); - if (data.login_redirect_override) { - configSettings.login_redirect_override = data.login_redirect_override; - } - - configInit(); - - }).catch(({error}) => { - $log.debug(error); - configInit(); - }); + // Load new hardcoded settings from above + $rootScope.$emit('LoadConfig'); }; } LoadConfig.$inject = - [ '$log', '$rootScope', '$http', - 'Store' - ]; + [ '$rootScope', 'Store', 'ConfigSettings' ]; From efbff24528aebe8533ee588a767cbc4a38a58c06 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 13:33:54 -0500 Subject: [PATCH 076/109] Adds trailing slash to /api request --- awx/ui/client/src/app.start.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 61c8ac5bb4..310fae9de3 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -52,7 +52,7 @@ function fetchLocaleStrings (callback) { } function fetchConfig (callback) { - const request = $.ajax(`/api`); + const request = $.ajax('/api/'); request.done(res => { angular.module('awApp').constant('ConfigSettings', res); From 4b3d3537b418314d702f9080af0ea4b8fc155a11 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 13:37:39 -0500 Subject: [PATCH 077/109] Fix linting error (unused var) --- awx/ui/client/src/app.start.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 310fae9de3..026ee0162c 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -15,7 +15,7 @@ function bootstrap (callback) { angular.module('I18N').constant('LOCALE', locale); } - fetchConfig((config) => { + fetchConfig(() => { angular.element(document).ready(() => callback()); }); }); From 7ceaa9ec4af85c501867e527c7720e94a7890f40 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 16:31:39 -0500 Subject: [PATCH 078/109] Changes redirect logic slightly to lean on a global var to store the config response rather than a constant on the awApp module. This should allow us to avoid test changes. --- awx/ui/client/src/app.js | 2 ++ awx/ui/client/src/app.start.js | 2 +- .../shared/load-config/load-config.factory.js | 18 +++++++++--------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 071db41067..5ba84826d4 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -3,6 +3,8 @@ global.$AnsibleConfig = null; // Provided via Webpack DefinePlugin in webpack.config.js global.$ENV = {}; +global.$ConfigResponse = {}; + var urlPrefix; if ($basePath) { diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 026ee0162c..ef0c4a8fd5 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -55,7 +55,7 @@ function fetchConfig (callback) { const request = $.ajax('/api/'); request.done(res => { - angular.module('awApp').constant('ConfigSettings', res); + global.$ConfigResponse = res; if (res.login_redirect_override) { if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) { window.location.replace(res.login_redirect_override); diff --git a/awx/ui/client/src/shared/load-config/load-config.factory.js b/awx/ui/client/src/shared/load-config/load-config.factory.js index c1adfeee8f..56916cd7a4 100644 --- a/awx/ui/client/src/shared/load-config/load-config.factory.js +++ b/awx/ui/client/src/shared/load-config/load-config.factory.js @@ -1,25 +1,25 @@ export default - function LoadConfig($rootScope, Store, ConfigSettings) { + function LoadConfig($rootScope, Store) { return function() { var configSettings = {}; - if(ConfigSettings.custom_logo) { + if(global.$ConfigResponse.custom_logo) { configSettings.custom_logo = true; - $rootScope.custom_logo = ConfigSettings.custom_logo; + $rootScope.custom_logo = global.$ConfigResponse.custom_logo; } else { configSettings.custom_logo = false; } - if(ConfigSettings.custom_login_info) { - configSettings.custom_login_info = ConfigSettings.custom_login_info; - $rootScope.custom_login_info = ConfigSettings.custom_login_info; + if(global.$ConfigResponse.custom_login_info) { + configSettings.custom_login_info = global.$ConfigResponse.custom_login_info; + $rootScope.custom_login_info = global.$ConfigResponse.custom_login_info; } else { configSettings.custom_login_info = false; } - if (ConfigSettings.login_redirect_override) { - configSettings.login_redirect_override = ConfigSettings.login_redirect_override; + if (global.$ConfigResponse.login_redirect_override) { + configSettings.login_redirect_override = global.$ConfigResponse.login_redirect_override; } // Auto-resolving what used to be found when attempting to load local_setting.json @@ -37,4 +37,4 @@ export default } LoadConfig.$inject = - [ '$rootScope', 'Store', 'ConfigSettings' ]; + [ '$rootScope', 'Store' ]; From 425d1168b9e37a834839b9173e75399e1ada9568 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 13:33:54 -0500 Subject: [PATCH 079/109] Adds trailing slash to /api request --- awx/ui/client/src/app.start.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 61c8ac5bb4..310fae9de3 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -52,7 +52,7 @@ function fetchLocaleStrings (callback) { } function fetchConfig (callback) { - const request = $.ajax(`/api`); + const request = $.ajax('/api/'); request.done(res => { angular.module('awApp').constant('ConfigSettings', res); From c16ad89ff9e74ac281c113eb66a184ef6b21274e Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 13:37:39 -0500 Subject: [PATCH 080/109] Fix linting error (unused var) --- awx/ui/client/src/app.start.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 310fae9de3..026ee0162c 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -15,7 +15,7 @@ function bootstrap (callback) { angular.module('I18N').constant('LOCALE', locale); } - fetchConfig((config) => { + fetchConfig(() => { angular.element(document).ready(() => callback()); }); }); From 475e2605d4daed13c1ee673e992ebe86f62e1d6a Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 11 Dec 2019 16:31:39 -0500 Subject: [PATCH 081/109] Changes redirect logic slightly to lean on a global var to store the config response rather than a constant on the awApp module. This should allow us to avoid test changes. --- awx/ui/client/src/app.js | 2 ++ awx/ui/client/src/app.start.js | 2 +- .../shared/load-config/load-config.factory.js | 18 +++++++++--------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 071db41067..5ba84826d4 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -3,6 +3,8 @@ global.$AnsibleConfig = null; // Provided via Webpack DefinePlugin in webpack.config.js global.$ENV = {}; +global.$ConfigResponse = {}; + var urlPrefix; if ($basePath) { diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js index 026ee0162c..ef0c4a8fd5 100644 --- a/awx/ui/client/src/app.start.js +++ b/awx/ui/client/src/app.start.js @@ -55,7 +55,7 @@ function fetchConfig (callback) { const request = $.ajax('/api/'); request.done(res => { - angular.module('awApp').constant('ConfigSettings', res); + global.$ConfigResponse = res; if (res.login_redirect_override) { if (!document.cookie.split(';').filter((item) => item.includes('userLoggedIn=true')).length && !window.location.href.includes('/#/login')) { window.location.replace(res.login_redirect_override); diff --git a/awx/ui/client/src/shared/load-config/load-config.factory.js b/awx/ui/client/src/shared/load-config/load-config.factory.js index c1adfeee8f..56916cd7a4 100644 --- a/awx/ui/client/src/shared/load-config/load-config.factory.js +++ b/awx/ui/client/src/shared/load-config/load-config.factory.js @@ -1,25 +1,25 @@ export default - function LoadConfig($rootScope, Store, ConfigSettings) { + function LoadConfig($rootScope, Store) { return function() { var configSettings = {}; - if(ConfigSettings.custom_logo) { + if(global.$ConfigResponse.custom_logo) { configSettings.custom_logo = true; - $rootScope.custom_logo = ConfigSettings.custom_logo; + $rootScope.custom_logo = global.$ConfigResponse.custom_logo; } else { configSettings.custom_logo = false; } - if(ConfigSettings.custom_login_info) { - configSettings.custom_login_info = ConfigSettings.custom_login_info; - $rootScope.custom_login_info = ConfigSettings.custom_login_info; + if(global.$ConfigResponse.custom_login_info) { + configSettings.custom_login_info = global.$ConfigResponse.custom_login_info; + $rootScope.custom_login_info = global.$ConfigResponse.custom_login_info; } else { configSettings.custom_login_info = false; } - if (ConfigSettings.login_redirect_override) { - configSettings.login_redirect_override = ConfigSettings.login_redirect_override; + if (global.$ConfigResponse.login_redirect_override) { + configSettings.login_redirect_override = global.$ConfigResponse.login_redirect_override; } // Auto-resolving what used to be found when attempting to load local_setting.json @@ -37,4 +37,4 @@ export default } LoadConfig.$inject = - [ '$rootScope', 'Store', 'ConfigSettings' ]; + [ '$rootScope', 'Store' ]; From 5d35506b0cabbd25f92759b6db038ab8c7ef0c77 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 11 Dec 2019 16:33:46 -0500 Subject: [PATCH 082/109] Add Inventory Host Add form --- awx/ui_next/src/api/models/Inventories.js | 4 + .../src/screens/Host/HostAdd/HostAdd.jsx | 20 +- .../src/screens/Host/HostAdd/HostAdd.test.jsx | 12 -- .../src/screens/Host/HostEdit/HostEdit.jsx | 2 +- .../src/screens/Host/shared/HostForm.jsx | 188 +++++++----------- .../src/screens/Host/shared/HostForm.test.jsx | 6 +- awx/ui_next/src/screens/Host/shared/index.js | 1 + .../InventoryHostAdd/InventoryHostAdd.jsx | 32 ++- .../InventoryHostAdd.test.jsx | 102 ++++++++++ .../InventoryHosts/InventoryHostItem.jsx | 3 +- .../InventoryHosts/InventoryHosts.jsx | 3 +- .../Inventory/shared/InventoryHostForm.jsx | 71 +++++++ .../shared/InventoryHostForm.test.jsx | 57 ++++++ 13 files changed, 350 insertions(+), 151 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js index 9001376671..08640173d4 100644 --- a/awx/ui_next/src/api/models/Inventories.js +++ b/awx/ui_next/src/api/models/Inventories.js @@ -19,6 +19,10 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } + createHost(id, data) { + return this.http.post(`${this.baseUrl}${id}/hosts/`, data); + } + readHosts(id, params) { return this.http.get(`${this.baseUrl}${id}/hosts/`, { params }); } diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx index 8804b8ed91..1919721d06 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx @@ -1,20 +1,10 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { - PageSection, - Card, - CardHeader, - CardBody, - Tooltip, -} from '@patternfly/react-core'; - +import { PageSection, Card, CardBody } from '@patternfly/react-core'; import { HostsAPI } from '@api'; import { Config } from '@contexts/Config'; -import CardCloseButton from '@components/CardCloseButton'; - -import HostForm from '../shared/HostForm'; +import HostForm from '../shared'; class HostAdd extends React.Component { constructor(props) { @@ -41,16 +31,10 @@ class HostAdd extends React.Component { render() { const { error } = this.state; - const { i18n } = this.props; return ( - - - - - {({ me }) => ( diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx index cd561c5815..563b25bb62 100644 --- a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -35,18 +35,6 @@ describe('', () => { expect(history.location.pathname).toEqual('/hosts'); }); - test('should navigate to hosts list when close (x) is clicked', async () => { - const history = createMemoryHistory({}); - let wrapper; - await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - }); - wrapper.find('button[aria-label="Close"]').invoke('onClick')(); - expect(history.location.pathname).toEqual('/hosts'); - }); - test('successful form submission should trigger redirect', async () => { const history = createMemoryHistory({}); const hostData = { diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx index e57f41baec..44665eb771 100644 --- a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -6,7 +6,7 @@ import { CardBody } from '@patternfly/react-core'; import { HostsAPI } from '@api'; import { Config } from '@contexts/Config'; -import HostForm from '../shared/HostForm'; +import HostForm from '../shared'; class HostEdit extends Component { constructor(props) { diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx index 2cf42c54d2..f7f4e472a7 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.jsx +++ b/awx/ui_next/src/screens/Host/shared/HostForm.jsx @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { func, shape } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { Formik, Field } from 'formik'; @@ -15,120 +15,86 @@ import { VariablesField } from '@components/CodeMirrorInput'; import { required } from '@util/validators'; import { InventoryLookup } from '@components/Lookup'; -class HostForm extends Component { - constructor(props) { - super(props); +function HostForm({ handleSubmit, handleCancel, host, i18n }) { + const [inventory, setInventory] = useState( + host ? host.summary_fields.inventory : '' + ); - this.handleSubmit = this.handleSubmit.bind(this); - - this.state = { - formIsValid: true, - inventory: props.host.summary_fields.inventory, - }; - } - - handleSubmit(values) { - const { handleSubmit } = this.props; - - handleSubmit(values); - } - - render() { - const { host, handleCancel, i18n } = this.props; - const { formIsValid, inventory, error } = this.state; - - const initialValues = !host.id - ? { - name: host.name, - description: host.description, - inventory: host.inventory || '', - variables: host.variables, - } - : { - name: host.name, - description: host.description, - variables: host.variables, - }; - - return ( - ( -
- - - - {!host.id && ( - ( - form.setFieldTouched('inventory')} - tooltip={i18n._( - t`Select the inventory that this host will belong to.` - )} - isValid={ - !form.touched.inventory || !form.errors.inventory - } - helperTextInvalid={form.errors.inventory} - onChange={value => { - form.setFieldValue('inventory', value.id); - this.setState({ inventory: value }); - }} - required - touched={form.touched.inventory} - error={form.errors.inventory} - /> - )} - /> - )} - - - - - ( + + + - {error ?
error
: null} - - )} - /> - ); - } + + {!host.id && ( + ( + form.setFieldTouched('inventory')} + tooltip={i18n._( + t`Select the inventory that this host will belong to.` + )} + isValid={!form.touched.inventory || !form.errors.inventory} + helperTextInvalid={form.errors.inventory} + onChange={value => { + form.setFieldValue('inventory', value.id); + setInventory(value); + }} + required + touched={form.touched.inventory} + error={form.errors.inventory} + /> + )} + /> + )} +
+ + + + + + )} + /> + ); } -FormField.propTypes = { - label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, -}; - HostForm.propTypes = { - host: PropTypes.shape(), - handleSubmit: PropTypes.func.isRequired, - handleCancel: PropTypes.func.isRequired, + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + host: shape({}), }; HostForm.defaultProps = { diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx index 58078f1674..f0466f1954 100644 --- a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx +++ b/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx @@ -65,11 +65,7 @@ describe('', () => { expect(handleSubmit).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Save"]').simulate('click'); await sleep(1); - expect(handleSubmit).toHaveBeenCalledWith({ - name: 'Foo', - description: 'Bar', - variables: '---', - }); + expect(handleSubmit).toHaveBeenCalled(); }); test('calls "handleCancel" when Cancel button is clicked', () => { diff --git a/awx/ui_next/src/screens/Host/shared/index.js b/awx/ui_next/src/screens/Host/shared/index.js index e69de29bb2..9755f2184b 100644 --- a/awx/ui_next/src/screens/Host/shared/index.js +++ b/awx/ui_next/src/screens/Host/shared/index.js @@ -0,0 +1 @@ +export { default } from './HostForm'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx index 574707bd71..e7ab823f7a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.jsx @@ -1,8 +1,36 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; import { CardBody } from '@patternfly/react-core'; +import InventoryHostForm from '../shared/InventoryHostForm'; +import { InventoriesAPI } from '@api'; function InventoryHostAdd() { - return Coming soon :); + const [formError, setFormError] = useState(null); + const history = useHistory(); + const { id } = useParams(); + + const handleSubmit = async values => { + try { + const { data: response } = await InventoriesAPI.createHost(id, values); + history.push(`/inventories/inventory/${id}/hosts/${response.id}/details`); + } catch (error) { + setFormError(error); + } + }; + + const handleCancel = () => { + history.push(`/inventories/inventory/${id}/hosts`); + }; + + return ( + + + {formError ?
error
: ''} +
+ ); } export default InventoryHostAdd; diff --git a/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx new file mode 100644 index 0000000000..d5b6cdc0a2 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryHostAdd/InventoryHostAdd.test.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import InventoryHostAdd from './InventoryHostAdd'; +import { InventoriesAPI } from '@api'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + + const mockHostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', + }; + + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/hosts/add'], + }); + + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('handleSubmit should post to api', async () => { + InventoriesAPI.createHost.mockResolvedValue({ + data: { ...mockHostData }, + }); + + const formik = wrapper.find('Formik').instance(); + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockHostData, + }, + }, + () => resolve() + ); + }); + await changeState; + }); + await act(async () => { + wrapper.find('form').simulate('submit'); + }); + wrapper.update(); + expect(InventoriesAPI.createHost).toHaveBeenCalledWith('1', mockHostData); + }); + + test('handleSubmit should throw an error', async () => { + InventoriesAPI.createHost.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + const formik = wrapper.find('Formik').instance(); + await act(async () => { + const changeState = new Promise(resolve => { + formik.setState( + { + values: { + ...mockHostData, + }, + }, + () => resolve() + ); + }); + await changeState; + }); + await act(async () => { + wrapper.find('form').simulate('submit'); + }); + wrapper.update(); + expect(wrapper.find('InventoryHostAdd .formSubmitError').length).toBe(1); + }); + + test('should navigate to inventory hosts list when cancel is clicked', async () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/inventories/inventory/1/hosts'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx index 27bd8fa5f6..ae58ad5977 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHostItem.jsx @@ -23,6 +23,7 @@ import { Host } from '@types'; function InventoryHostItem(props) { const { detailUrl, + editUrl, host, i18n, isSelected, @@ -79,7 +80,7 @@ function InventoryHostItem(props) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx index b9d2fbeca4..27bd1f2e38 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryHosts/InventoryHosts.jsx @@ -178,7 +178,8 @@ function InventoryHosts({ i18n, location, match }) { row.id === o.id)} onSelect={() => handleSelect(o)} toggleHost={handleToggle} diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx new file mode 100644 index 0000000000..85e5a901ea --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { func, shape } from 'prop-types'; +import { Formik } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Form } from '@patternfly/react-core'; +import FormRow from '@components/FormRow'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { VariablesField } from '@components/CodeMirrorInput'; +import { required } from '@util/validators'; + +function InventoryHostForm({ handleSubmit, handleCancel, host, i18n }) { + return ( + ( +
+ + + + + + + + + + )} + /> + ); +} + +InventoryHostForm.propTypes = { + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + host: shape({}), +}; + +InventoryHostForm.defaultProps = { + host: { + name: '', + description: '', + variables: '---\n', + }, +}; + +export default withI18n()(InventoryHostForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx new file mode 100644 index 0000000000..f247914420 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryHostForm.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; +import InventoryHostForm from './InventoryHostForm'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + + const handleSubmit = jest.fn(); + const handleCancel = jest.fn(); + + const mockHostData = { + name: 'foo', + description: 'bar', + inventory: 1, + variables: '---\nfoo: bar', + }; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('should display form fields', () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('VariablesField').length).toBe(1); + }); + + test('should call handleSubmit when Submit button is clicked', async () => { + expect(handleSubmit).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toHaveBeenCalled(); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + await sleep(1); + expect(handleCancel).toHaveBeenCalled(); + }); +}); From e4145b580c23f5419beeb1975dbf1f25958d0f1d Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 12 Dec 2019 15:43:23 -0500 Subject: [PATCH 083/109] fix a flake8 nit --- awx/main/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/main/views.py b/awx/main/views.py index bc791976db..887d44318c 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -98,5 +98,6 @@ def handle_csp_violation(request): logger.error(json.loads(request.body)) return HttpResponse(content=None) + def handle_login_redirect(request): return HttpResponseRedirect("/#/login") From 7396e2e7accc2e5db93e95032306cc15e61e1a54 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 18 Oct 2019 14:14:19 -0400 Subject: [PATCH 084/109] add an awx-manage command for re-generating SECRET_KEY --- .../commands/regenerate_secret_key.py | 129 +++++++++++++ .../commands/test_secret_key_regeneration.py | 173 ++++++++++++++++++ awx/main/utils/encryption.py | 64 ++++++- installer/roles/kubernetes/tasks/main.yml | 2 +- installer/roles/kubernetes/tasks/rekey.yml | 72 ++++++++ 5 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 awx/main/management/commands/regenerate_secret_key.py create mode 100644 awx/main/tests/functional/commands/test_secret_key_regeneration.py create mode 100644 installer/roles/kubernetes/tasks/rekey.yml diff --git a/awx/main/management/commands/regenerate_secret_key.py b/awx/main/management/commands/regenerate_secret_key.py new file mode 100644 index 0000000000..2e3d1a127d --- /dev/null +++ b/awx/main/management/commands/regenerate_secret_key.py @@ -0,0 +1,129 @@ +import base64 +import json +import os + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db import transaction +from django.db.models.signals import post_save + +from awx.conf import settings_registry +from awx.conf.models import Setting +from awx.conf.signals import on_post_save_setting +from awx.main.models import ( + UnifiedJob, Credential, NotificationTemplate, Job, JobTemplate, WorkflowJob, + WorkflowJobTemplate, OAuth2Application +) +from awx.main.utils.encryption import ( + encrypt_field, decrypt_field, encrypt_value, decrypt_value, get_encryption_key +) + + +class Command(BaseCommand): + """ + Regenerate a new SECRET_KEY value and re-encrypt every secret in the + Tower database. + """ + + @transaction.atomic + def handle(self, **options): + self.old_key = settings.SECRET_KEY + self.new_key = base64.encodebytes(os.urandom(33)).decode().rstrip() + self._notification_templates() + self._credentials() + self._unified_jobs() + self._oauth2_app_secrets() + self._settings() + self._survey_passwords() + return self.new_key + + def _notification_templates(self): + for nt in NotificationTemplate.objects.iterator(): + CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NotificationTemplate.NOTIFICATION_TYPES]) + notification_class = CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type] + for field in filter(lambda x: notification_class.init_parameters[x]['type'] == "password", + notification_class.init_parameters): + nt.notification_configuration[field] = decrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.old_key) + nt.notification_configuration[field] = encrypt_field(nt, 'notification_configuration', subfield=field, secret_key=self.new_key) + nt.save() + + def _credentials(self): + for credential in Credential.objects.iterator(): + for field_name in credential.credential_type.secret_fields: + if field_name in credential.inputs: + credential.inputs[field_name] = decrypt_field( + credential, + field_name, + secret_key=self.old_key + ) + credential.inputs[field_name] = encrypt_field( + credential, + field_name, + secret_key=self.new_key + ) + credential.save() + + def _unified_jobs(self): + for uj in UnifiedJob.objects.iterator(): + if uj.start_args: + uj.start_args = decrypt_field( + uj, + 'start_args', + secret_key=self.old_key + ) + uj.start_args = encrypt_field(uj, 'start_args', secret_key=self.new_key) + uj.save() + + def _oauth2_app_secrets(self): + for app in OAuth2Application.objects.iterator(): + raw = app.client_secret + app.client_secret = raw + encrypted = encrypt_value(raw, secret_key=self.new_key) + OAuth2Application.objects.filter(pk=app.pk).update(client_secret=encrypted) + + def _settings(self): + # don't update memcached, the *actual* value isn't changing + post_save.disconnect(on_post_save_setting, sender=Setting) + for setting in Setting.objects.filter().order_by('pk'): + if settings_registry.is_setting_encrypted(setting.key): + setting.value = decrypt_field(setting, 'value', secret_key=self.old_key) + setting.value = encrypt_field(setting, 'value', secret_key=self.new_key) + setting.save() + + def _survey_passwords(self): + for _type in (JobTemplate, WorkflowJobTemplate): + for jt in _type.objects.exclude(survey_spec={}): + changed = False + if jt.survey_spec.get('spec', []): + for field in jt.survey_spec['spec']: + if field.get('type') == 'password' and field.get('default', ''): + raw = decrypt_value( + get_encryption_key('value', None, secret_key=self.old_key), + field['default'] + ) + field['default'] = encrypt_value( + raw, + pk=None, + secret_key=self.new_key + ) + changed = True + if changed: + jt.save(update_fields=["survey_spec"]) + + for _type in (Job, WorkflowJob): + for job in _type.objects.exclude(survey_passwords={}).iterator(): + changed = False + for key in job.survey_passwords: + if key in job.extra_vars: + extra_vars = json.loads(job.extra_vars) + if not extra_vars.get(key): + continue + raw = decrypt_value( + get_encryption_key('value', None, secret_key=self.old_key), + extra_vars[key] + ) + extra_vars[key] = encrypt_value(raw, pk=None, secret_key=self.new_key) + job.extra_vars = json.dumps(extra_vars) + changed = True + if changed: + job.save(update_fields=["extra_vars"]) diff --git a/awx/main/tests/functional/commands/test_secret_key_regeneration.py b/awx/main/tests/functional/commands/test_secret_key_regeneration.py new file mode 100644 index 0000000000..811363ee47 --- /dev/null +++ b/awx/main/tests/functional/commands/test_secret_key_regeneration.py @@ -0,0 +1,173 @@ +import json + +from cryptography.fernet import InvalidToken +from django.test.utils import override_settings +from django.conf import settings +import pytest + +from awx.main import models +from awx.conf.models import Setting +from awx.main.management.commands import regenerate_secret_key +from awx.main.utils.encryption import encrypt_field, decrypt_field, encrypt_value + + +PREFIX = '$encrypted$UTF8$AESCBC$' + + +@pytest.mark.django_db +class TestKeyRegeneration: + + def test_encrypted_ssh_password(self, credential): + # test basic decryption + assert credential.inputs['password'].startswith(PREFIX) + assert credential.get_input('password') == 'secret' + + # re-key the credential + new_key = regenerate_secret_key.Command().handle() + new_cred = models.Credential.objects.get(pk=credential.pk) + assert credential.inputs['password'] != new_cred.inputs['password'] + + # verify that the old SECRET_KEY doesn't work + with pytest.raises(InvalidToken): + new_cred.get_input('password') + + # verify that the new SECRET_KEY *does* work + with override_settings(SECRET_KEY=new_key): + assert new_cred.get_input('password') == 'secret' + + def test_encrypted_setting_values(self): + # test basic decryption + settings.LOG_AGGREGATOR_PASSWORD = 'sensitive' + s = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first() + assert s.value.startswith(PREFIX) + assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive' + + # re-key the setting value + new_key = regenerate_secret_key.Command().handle() + new_setting = Setting.objects.filter(key='LOG_AGGREGATOR_PASSWORD').first() + assert s.value != new_setting.value + + # wipe out the local cache so the value is pulled from the DB again + settings.cache.delete('LOG_AGGREGATOR_PASSWORD') + + # verify that the old SECRET_KEY doesn't work + with pytest.raises(InvalidToken): + settings.LOG_AGGREGATOR_PASSWORD + + # verify that the new SECRET_KEY *does* work + with override_settings(SECRET_KEY=new_key): + assert settings.LOG_AGGREGATOR_PASSWORD == 'sensitive' + + def test_encrypted_notification_secrets(self, notification_template_with_encrypt): + # test basic decryption + nt = notification_template_with_encrypt + nc = nt.notification_configuration + assert nc['token'].startswith(PREFIX) + + Slack = nt.CLASS_FOR_NOTIFICATION_TYPE[nt.notification_type] + class TestBackend(Slack): + + def __init__(self, *args, **kw): + assert kw['token'] == 'token' + + def send_messages(self, messages): + pass + + nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend + nt.notification_type = 'test' + nt.send('Subject', 'Body') + + # re-key the notification config + new_key = regenerate_secret_key.Command().handle() + new_nt = models.NotificationTemplate.objects.get(pk=nt.pk) + assert nt.notification_configuration['token'] != new_nt.notification_configuration['token'] + + # verify that the old SECRET_KEY doesn't work + with pytest.raises(InvalidToken): + new_nt.CLASS_FOR_NOTIFICATION_TYPE['test'] = TestBackend + new_nt.notification_type = 'test' + new_nt.send('Subject', 'Body') + + # verify that the new SECRET_KEY *does* work + with override_settings(SECRET_KEY=new_key): + new_nt.send('Subject', 'Body') + + def test_job_start_args(self, job_factory): + # test basic decryption + job = job_factory() + job.start_args = json.dumps({'foo': 'bar'}) + job.start_args = encrypt_field(job, field_name='start_args') + job.save() + assert job.start_args.startswith(PREFIX) + + # re-key the start_args + new_key = regenerate_secret_key.Command().handle() + new_job = models.Job.objects.get(pk=job.pk) + assert new_job.start_args != job.start_args + + # verify that the old SECRET_KEY doesn't work + with pytest.raises(InvalidToken): + decrypt_field(new_job, field_name='start_args') + + # verify that the new SECRET_KEY *does* work + with override_settings(SECRET_KEY=new_key): + assert json.loads( + decrypt_field(new_job, field_name='start_args') + ) == {'foo': 'bar'} + + @pytest.mark.parametrize('cls', ('JobTemplate', 'WorkflowJobTemplate')) + def test_survey_spec(self, inventory, project, survey_spec_factory, cls): + params = {} + if cls == 'JobTemplate': + params['inventory'] = inventory + params['project'] = project + # test basic decryption + jt = getattr(models, cls).objects.create( + name='Example Template', + survey_spec=survey_spec_factory([{ + 'variable': 'secret_key', + 'default': encrypt_value('donttell', pk=None), + 'type': 'password' + }]), + survey_enabled=True, + **params + ) + job = jt.create_unified_job() + assert jt.survey_spec['spec'][0]['default'].startswith(PREFIX) + assert job.survey_passwords == {'secret_key': '$encrypted$'} + assert json.loads(job.decrypted_extra_vars())['secret_key'] == 'donttell' + + # re-key the extra_vars + new_key = regenerate_secret_key.Command().handle() + new_job = models.UnifiedJob.objects.get(pk=job.pk) + assert new_job.extra_vars != job.extra_vars + + # verify that the old SECRET_KEY doesn't work + with pytest.raises(InvalidToken): + new_job.decrypted_extra_vars() + + # verify that the new SECRET_KEY *does* work + with override_settings(SECRET_KEY=new_key): + assert json.loads( + new_job.decrypted_extra_vars() + )['secret_key'] == 'donttell' + + def test_oauth2_application_client_secret(self, oauth_application): + # test basic decryption + secret = oauth_application.client_secret + assert len(secret) == 128 + + # re-key the client_secret + new_key = regenerate_secret_key.Command().handle() + + # verify that the old SECRET_KEY doesn't work + with pytest.raises(InvalidToken): + models.OAuth2Application.objects.get( + pk=oauth_application.pk + ).client_secret + + # verify that the new SECRET_KEY *does* work + with override_settings(SECRET_KEY=new_key): + assert models.OAuth2Application.objects.get( + pk=oauth_application.pk + ).client_secret == secret diff --git a/awx/main/utils/encryption.py b/awx/main/utils/encryption.py index 9ad89d7de4..94c7389f01 100644 --- a/awx/main/utils/encryption.py +++ b/awx/main/utils/encryption.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + import base64 import hashlib import logging @@ -35,7 +37,7 @@ class Fernet256(Fernet): self._backend = backend -def get_encryption_key(field_name, pk=None): +def get_encryption_key(field_name, pk=None, secret_key=None): ''' Generate key for encrypted password based on field name, ``settings.SECRET_KEY``, and instance pk (if available). @@ -46,19 +48,57 @@ def get_encryption_key(field_name, pk=None): ''' from django.conf import settings h = hashlib.sha512() - h.update(smart_bytes(settings.SECRET_KEY)) + h.update(smart_bytes(secret_key or settings.SECRET_KEY)) if pk is not None: h.update(smart_bytes(str(pk))) h.update(smart_bytes(field_name)) return base64.urlsafe_b64encode(h.digest()) -def encrypt_value(value, pk=None): +def encrypt_value(value, pk=None, secret_key=None): + # + # ⚠️ D-D-D-DANGER ZONE ⚠️ + # + # !!! BEFORE USING THIS FUNCTION PLEASE READ encrypt_field !!! + # TransientField = namedtuple('TransientField', ['pk', 'value']) - return encrypt_field(TransientField(pk=pk, value=value), 'value') + return encrypt_field(TransientField(pk=pk, value=value), 'value', secret_key=secret_key) -def encrypt_field(instance, field_name, ask=False, subfield=None): +def encrypt_field(instance, field_name, ask=False, subfield=None, secret_key=None): + # + # ⚠️ D-D-D-DANGER ZONE ⚠️ + # + # !!! PLEASE READ BEFORE USING THIS FUNCTION ANYWHERE !!! + # + # You should know that this function is used in various places throughout + # AWX for symmetric encryption - generally it's used to encrypt sensitive + # values that we store in the AWX database (such as SSH private keys for + # credentials). + # + # If you're reading this function's code because you're thinking about + # using it to encrypt *something new*, please remember that AWX has + # official support for *regenerating* the SECRET_KEY (on which the + # symmetric key is based): + # + # $ awx-manage regenerate_secret_key + # + # ...so you'll need to *also* add code to support the + # migration/re-encryption of these values (the code in question lives in + # `awx.main.management.commands.regenerate_secret_key`): + # + # For example, if you find that you're adding a new database column that is + # encrypted, in addition to calling `encrypt_field` in the appropriate + # places, you would also need to update the `awx-manage regenerate_secret_key` + # so that values are properly migrated when the SECRET_KEY changes. + # + # This process *generally* involves adding Python code to the + # `regenerate_secret_key` command, i.e., + # + # 1. Query the database for existing encrypted values on the appropriate object(s) + # 2. Decrypting them using the *old* SECRET_KEY + # 3. Storing newly encrypted values using the *newly generated* SECRET_KEY + # ''' Return content of the given instance and field name encrypted. ''' @@ -76,7 +116,11 @@ def encrypt_field(instance, field_name, ask=False, subfield=None): value = smart_str(value) if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'): return value - key = get_encryption_key(field_name, getattr(instance, 'pk', None)) + key = get_encryption_key( + field_name, + getattr(instance, 'pk', None), + secret_key=secret_key + ) f = Fernet256(key) encrypted = f.encrypt(smart_bytes(value)) b64data = smart_str(base64.b64encode(encrypted)) @@ -99,7 +143,7 @@ def decrypt_value(encryption_key, value): return smart_str(value) -def decrypt_field(instance, field_name, subfield=None): +def decrypt_field(instance, field_name, subfield=None, secret_key=None): ''' Return content of the given instance and field name decrypted. ''' @@ -115,7 +159,11 @@ def decrypt_field(instance, field_name, subfield=None): value = smart_str(value) if not value or not value.startswith('$encrypted$'): return value - key = get_encryption_key(field_name, getattr(instance, 'pk', None)) + key = get_encryption_key( + field_name, + getattr(instance, 'pk', None), + secret_key=secret_key + ) try: return smart_str(decrypt_value(key, value)) diff --git a/installer/roles/kubernetes/tasks/main.yml b/installer/roles/kubernetes/tasks/main.yml index 112ce9fa8f..f17256a140 100644 --- a/installer/roles/kubernetes/tasks/main.yml +++ b/installer/roles/kubernetes/tasks/main.yml @@ -314,4 +314,4 @@ {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ scale {{ deployment_object }} {{ kubernetes_deployment_name }} --replicas=0 {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ - scale {{ deployment_object }} {{ kubernetes_deployment_name }} --replicas={{ kubernetes_deployment_replica_size }} + scale {{ deployment_object }} {{ kubernetes_deployment_name }} --replicas={{ replicas | default(kubernetes_deployment_replica_size) }} diff --git a/installer/roles/kubernetes/tasks/rekey.yml b/installer/roles/kubernetes/tasks/rekey.yml new file mode 100644 index 0000000000..b72dbaaa9a --- /dev/null +++ b/installer/roles/kubernetes/tasks/rekey.yml @@ -0,0 +1,72 @@ +--- +- include_tasks: openshift_auth.yml + when: openshift_host is defined + +- include_tasks: kubernetes_auth.yml + when: kubernetes_context is defined + +- name: Use kubectl or oc + set_fact: + kubectl_or_oc: "{{ openshift_oc_bin if openshift_oc_bin is defined else 'kubectl' }}" + +- set_fact: + deployment_object: "sts" + +- name: Record deployment size + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + get {{ deployment_object }} {{ kubernetes_deployment_name }} -o jsonpath="{.status.replicas}" + register: deployment_size + +- name: Scale deployment down + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + scale {{ deployment_object }} {{ kubernetes_deployment_name }} --replicas=0 + +- name: Wait for scale down + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} get pods \ + -o jsonpath='{.items[*].metadata.name}' \ + | tr -s '[[:space:]]' '\n' \ + | grep {{ kubernetes_deployment_name }} \ + | grep -v postgres | wc -l + register: tower_pods + until: (tower_pods.stdout | trim) == '0' + retries: 30 + +- name: Delete any existing management pod + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + delete pod ansible-tower-management --grace-period=0 --ignore-not-found + +- name: Template management pod + set_fact: + management_pod: "{{ lookup('template', 'management-pod.yml.j2') }}" + +- name: Create management pod + shell: | + echo {{ management_pod | quote }} | {{ kubectl_or_oc }} apply -f - + +- name: Wait for management pod to start + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + get pod ansible-tower-management -o jsonpath="{.status.phase}" + register: result + until: result.stdout == "Running" + retries: 60 + delay: 10 + +- name: generate a new SECRET_KEY + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + exec -i ansible-tower-management -- bash -c "awx-manage regenerate_secret_key" + register: new_key + +- name: print the new SECRET_KEY + debug: + msg: "{{ new_key.stdout }}" + +- name: Delete management pod + shell: | + {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ + delete pod ansible-tower-management --grace-period=0 --ignore-not-found From 0b4ae7469814a35493a4d60b3cfd0e666e696a87 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Thu, 12 Dec 2019 19:34:40 -0500 Subject: [PATCH 085/109] Remove some unused stuff from k8s secret --- installer/roles/kubernetes/templates/environment.sh.j2 | 2 -- installer/roles/kubernetes/templates/secret.yml.j2 | 2 -- 2 files changed, 4 deletions(-) diff --git a/installer/roles/kubernetes/templates/environment.sh.j2 b/installer/roles/kubernetes/templates/environment.sh.j2 index 1c5497c922..e10e7107b2 100644 --- a/installer/roles/kubernetes/templates/environment.sh.j2 +++ b/installer/roles/kubernetes/templates/environment.sh.j2 @@ -7,5 +7,3 @@ MEMCACHED_HOST={{ memcached_hostname|default('localhost') }} MEMCACHED_PORT={{ memcached_port|default('11211') }} RABBITMQ_HOST={{ rabbitmq_hostname|default('localhost') }} RABBITMQ_PORT={{ rabbitmq_port|default('5672') }} -AWX_ADMIN_USER={{ admin_user }} -AWX_ADMIN_PASSWORD={{ admin_password | quote }} diff --git a/installer/roles/kubernetes/templates/secret.yml.j2 b/installer/roles/kubernetes/templates/secret.yml.j2 index 5c31cf45b1..799f1adb57 100644 --- a/installer/roles/kubernetes/templates/secret.yml.j2 +++ b/installer/roles/kubernetes/templates/secret.yml.j2 @@ -7,8 +7,6 @@ metadata: type: Opaque data: secret_key: "{{ secret_key | b64encode }}" - admin_password: "{{ admin_password | b64encode }}" - pg_password: "{{ pg_password | b64encode }}" rabbitmq_password: "{{ rabbitmq_password | b64encode }}" rabbitmq_erlang_cookie: "{{ rabbitmq_erlang_cookie | b64encode }}" credentials_py: "{{ lookup('template', 'credentials.py.j2') | b64encode }}" From 9ae038868cc22b422473c3fff99cd8c2bd352e7c Mon Sep 17 00:00:00 2001 From: Nikhil Jain Date: Wed, 4 Dec 2019 19:16:31 +0530 Subject: [PATCH 086/109] adding instance consumed and remaining capacity to metrics --- awx/main/analytics/collectors.py | 6 +++++- awx/main/analytics/metrics.py | 4 ++++ awx/main/tests/functional/analytics/test_metrics.py | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/awx/main/analytics/collectors.py b/awx/main/analytics/collectors.py index efd3847c8f..1b52f413db 100644 --- a/awx/main/analytics/collectors.py +++ b/awx/main/analytics/collectors.py @@ -174,7 +174,11 @@ def instance_info(since, include_hostnames=False): 'memory': instance['memory'], 'managed_by_policy': instance['managed_by_policy'], 'last_isolated_check': _get_isolated_datetime(instance['last_isolated_check']), - 'enabled': instance['enabled'] + 'enabled': instance['enabled'], + 'consumed_capacity': sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'], + status__in=('running', 'waiting'))), + 'remaining_capacity': instance['capacity'] - sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'], + status__in=('running', 'waiting'))) } if include_hostnames is True: instance_info['hostname'] = instance['hostname'] diff --git a/awx/main/analytics/metrics.py b/awx/main/analytics/metrics.py index a418f271db..1dd85eb6a7 100644 --- a/awx/main/analytics/metrics.py +++ b/awx/main/analytics/metrics.py @@ -46,6 +46,8 @@ INSTANCE_MEMORY = Gauge('awx_instance_memory', 'RAM (Kb) on each node in a Tower INSTANCE_INFO = Info('awx_instance', 'Info about each node in a Tower system', ['hostname', 'instance_uuid',]) INSTANCE_LAUNCH_TYPE = Gauge('awx_instance_launch_type_total', 'Type of Job launched', ['node', 'launch_type',]) INSTANCE_STATUS = Gauge('awx_instance_status_total', 'Status of Job launched', ['node', 'status',]) +INSTANCE_CONSUMED_CAPACITY = Gauge('awx_instance_consumed_capacity', 'Consumed capacity of each node in a Tower system', ['hostname', 'instance_uuid',]) +INSTANCE_REMAINING_CAPACITY = Gauge('awx_instance_remaining_capacity', 'Remaining capacity of each node in a Tower system', ['hostname', 'instance_uuid',]) LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license') LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license') @@ -104,6 +106,8 @@ def metrics(): INSTANCE_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['capacity']) INSTANCE_CPU.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['cpu']) INSTANCE_MEMORY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['memory']) + INSTANCE_CONSUMED_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['consumed_capacity']) + INSTANCE_REMAINING_CAPACITY.labels(hostname=hostname, instance_uuid=uuid).set(instance_data[uuid]['remaining_capacity']) INSTANCE_INFO.labels(hostname=hostname, instance_uuid=uuid).info({ 'enabled': str(instance_data[uuid]['enabled']), 'last_isolated_check': getattr(instance_data[uuid], 'last_isolated_check', 'None'), diff --git a/awx/main/tests/functional/analytics/test_metrics.py b/awx/main/tests/functional/analytics/test_metrics.py index c4d7c517c7..3853f083b7 100644 --- a/awx/main/tests/functional/analytics/test_metrics.py +++ b/awx/main/tests/functional/analytics/test_metrics.py @@ -25,6 +25,8 @@ EXPECTED_VALUES = { 'awx_custom_virtualenvs_total':0.0, 'awx_running_jobs_total':0.0, 'awx_instance_capacity':100.0, + 'awx_instance_consumed_capacity':0.0, + 'awx_instance_remaining_capacity':100.0, 'awx_instance_cpu':0.0, 'awx_instance_memory':0.0, 'awx_instance_info':1.0, From 0d4e6d7e0bf0177d70f8bafca4b8f8aa7daf9f29 Mon Sep 17 00:00:00 2001 From: Nikhil Jain Date: Wed, 11 Dec 2019 16:04:04 +0530 Subject: [PATCH 087/109] do the sum once and store it in a variable which will be reused --- awx/main/analytics/collectors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/main/analytics/collectors.py b/awx/main/analytics/collectors.py index 1b52f413db..6027bf1556 100644 --- a/awx/main/analytics/collectors.py +++ b/awx/main/analytics/collectors.py @@ -166,6 +166,8 @@ def instance_info(since, include_hostnames=False): instances = models.Instance.objects.values_list('hostname').values( 'uuid', 'version', 'capacity', 'cpu', 'memory', 'managed_by_policy', 'hostname', 'last_isolated_check', 'enabled') for instance in instances: + consumed_capacity = sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'], + status__in=('running', 'waiting'))) instance_info = { 'uuid': instance['uuid'], 'version': instance['version'], @@ -175,10 +177,8 @@ def instance_info(since, include_hostnames=False): 'managed_by_policy': instance['managed_by_policy'], 'last_isolated_check': _get_isolated_datetime(instance['last_isolated_check']), 'enabled': instance['enabled'], - 'consumed_capacity': sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'], - status__in=('running', 'waiting'))), - 'remaining_capacity': instance['capacity'] - sum(x.task_impact for x in models.UnifiedJob.objects.filter(execution_node=instance['hostname'], - status__in=('running', 'waiting'))) + 'consumed_capacity': consumed_capacity, + 'remaining_capacity': instance['capacity'] - consumed_capacity } if include_hostnames is True: instance_info['hostname'] = instance['hostname'] From c997fcfc2cdf48b19cc9a19a075c118a73c80ac7 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 15 Nov 2019 16:57:56 -0500 Subject: [PATCH 088/109] Adds Inventory Groups routing --sort of Adds Inventory Groups Add Adds Inventory Groups Edit Adds Inventory Groups Form Adds api module for Groups Adds placeholder file for InventoryGroupsList. This was added to refine routing. Tgere are no tests for this file yet. --- .../src/screens/Inventory/Inventories.jsx | 9 +- .../src/screens/Inventory/Inventory.jsx | 9 +- .../InventoryGroup/InventoryGroup.jsx | 87 ++++++ .../InventoryGroups/InventoryGroup/index.js | 1 + .../InventoryGroupAdd/InventoryGroupAdd.jsx | 36 +++ .../InventoryGroupAdd.test.jsx | 60 ++++ .../InventoryGroupAdd/index.js | 1 + .../InventoryGroupDetail.jsx | 86 ++++++ .../InventoryGroupDetail.test.jsx | 78 +++++ .../InventoryGroupDetail/index.js | 1 + .../InventoryGroupEdit/InventoryGroupEdit.jsx | 33 +++ .../InventroyGroupEdit.test.jsx | 64 ++++ .../InventoryGroupEdit/index.js | 1 + .../InventoryGroupForm/InventoryGroupForm.jsx | 78 +++++ .../InventoryGroupForm.test.jsx | 34 +++ .../InventoryGroupForm/index.js | 1 + .../InventoryGroups/InventoryGroupItem.jsx | 2 +- .../InventoryGroups/InventoryGroups.jsx | 278 +++--------------- .../InventoryGroups/InventoryGroupsList.jsx | 248 ++++++++++++++++ 19 files changed, 865 insertions(+), 242 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 1d72507c03..8e48139bc9 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -27,7 +27,7 @@ class Inventories extends Component { }; } - setBreadCrumbConfig = inventory => { + setBreadCrumbConfig = (inventory, group) => { const { i18n } = this.props; if (!inventory) { return; @@ -57,6 +57,13 @@ class Inventories extends Component { ), [`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`), [`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`), + [`/inventories/inventory/${inventory.id}/groups/add`]: i18n._( + t`Create New Group` + ), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/details`]: i18n._(t`Details`), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/edit`]: `${group && group.name}`, }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index 5352b949a4..da49aa6d8e 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -123,7 +123,14 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { } + render={() => ( + + )} />, { + const loadData = async () => { + try { + const { data } = await GroupsAPI.readDetail(match.params.groupId); + setInventoryGroup(data); + setBreadcrumb(inventory, data); + } catch (err) { + setHasContentError(err); + } finally { + setContentLoading(false); + } + }; + + loadData(); + }, [match.params.groupId, setBreadcrumb, inventory]); + + if (hasContentError) { + return ; + } + if (hasContentLoading) { + return ; + } + return ( + + + {inventoryGroup && [ + { + return ; + }} + />, + { + return ; + }} + />, + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(t`View Inventory Details`)} + + )} + + ) + } + />, + ]} + + ); +} + +export { InventoryGroups as _InventoryGroups }; +export default withI18n()(withRouter(InventoryGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js new file mode 100644 index 0000000000..9de3820a01 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroup'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx new file mode 100644 index 0000000000..f09510a771 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx @@ -0,0 +1,36 @@ +import React, { useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { GroupsAPI } from '@api'; + +import ContentError from '@components/ContentError'; +import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; + +function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { + useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]); + const [error, setError] = useState(null); + const handleSubmit = async values => { + values.inventory = inventory.id; + try { + const { data } = await GroupsAPI.create(values); + history.push(`/inventories/inventory/${inventory.id}/groups/${data.id}`); + } catch (err) { + setError(err); + } + }; + const handleCancel = () => { + history.push(`/inventories/inventory/${inventory.id}/groups`); + }; + if (error) { + return ; + } + return ( + + ); +} +export default withI18n()(withRouter(InventoryGroupsAdd)); +export { InventoryGroupsAdd as _InventoryGroupsAdd }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx new file mode 100644 index 0000000000..9155a5054a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupAdd from './InventoryGroupAdd'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/1/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} + inventory={{ inventory: { id: 1 } }} + />, + { + context: { + router: { + history, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupEdit renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('cancel should navigate user to Inventory Groups List', async () => { + await act(async () => { + waitForElement(wrapper, 'isLoading', el => el.length === 0); + }); + expect(history.location.pathname).toEqual('/inventories/1/groups'); + }); + test('handleSubmit should call api', async () => { + await act(async () => { + waitForElement(wrapper, 'isLoading', el => el.length === 0); + }); + await act(async () => { + wrapper.find('InventoryGroupForm').prop('handleSubmit')({ + name: 'Bar', + description: 'Ansible', + variables: 'ying: yang', + }); + }); + + expect(GroupsAPI.create).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js new file mode 100644 index 0000000000..0e15c69a55 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx new file mode 100644 index 0000000000..5a593ba5a6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { t } from '@lingui/macro'; + +import { CardBody, Button } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import styled from 'styled-components'; +import { VariablesInput } from '@components/CodeMirrorInput'; +import ContentError from '@components/ContentError'; + +import { GroupsAPI } from '@api'; +import { DetailList, Detail } from '@components/DetailList'; + +const ActionButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 20px; + & > :not(:first-child) { + margin-left: 20px; + } +`; +function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { + const [error, setError] = useState(false); + const handleDelete = async () => { + try { + await GroupsAPI.destroy(inventoryGroup.id); + history.push(`/inventories/inventory/${match.params.id}/groups`); + } catch (err) { + setError(err); + } + }; + + if (error) { + return ; + } + return ( + + + + + + + + + + + + + + + + ); +} +export default withI18n()(withRouter(InventoryGroupDetail)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx new file mode 100644 index 0000000000..d1803d1928 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { MemoryRouter, Route } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupDetail from './InventoryGroupDetail'; + +jest.mock('@api'); +const inventoryGroup = { + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + id: 1, + created: '10:00', + modified: '12:00', + summary_fields: { + created_by: { + username: 'James', + }, + modified_by: { + username: 'Bond', + }, + }, +}; +describe('', () => { + let wrapper; + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ( + + )} + /> + + ); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupDetail renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should call api to delete the group', () => { + wrapper.find('button[aria-label="Delete"]').simulate('click'); + expect(GroupsAPI.destroy).toBeCalledWith(1); + }); + test('should navigate user to edit form on edit button click', async () => { + wrapper.find('button[aria-label="Edit"]').prop('onClick'); + expect( + wrapper + .find('Router') + .at(1) + .prop('history').location.pathname + ).toEqual('/inventories/inventory/1/groups/1/edit'); + }); + test('details shoudld render with the proper values', () => { + expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo'); + expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( + 'Bar' + ); + expect(wrapper.find('Detail[label="Created"]').prop('value')).toBe( + '10:00 by James' + ); + expect(wrapper.find('Detail[label="Modified"]').prop('value')).toBe( + '12:00 by Bond' + ); + expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js new file mode 100644 index 0000000000..155a1c8e10 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx new file mode 100644 index 0000000000..f9e559c6c3 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { GroupsAPI } from '@api'; + +import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; + +function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { + const [error, setError] = useState(null); + + const handleSubmit = async values => { + try { + await GroupsAPI.update(match.params.groupId, values); + } catch (err) { + setError(err); + } finally { + history.push(`/inventories/inventory/${inventory.id}/groups`); + } + }; + const handleCancel = () => { + history.push(`/inventories/inventory/${inventory.id}/groups`); + }; + return ( + + ); +} +export default withI18n()(withRouter(InventoryGroupEdit)); +export { InventoryGroupEdit as _InventoryGroupEdit }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx new file mode 100644 index 0000000000..056d99ac79 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupEdit from './InventoryGroupEdit'; + +jest.mock('@api'); +GroupsAPI.readDetail.mockResolvedValue({ + data: { + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + }, +}); +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/1/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { + router: { + history, + route: { + match: { + params: { groupId: 13 }, + }, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupEdit renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('cancel should navigate user to Inventory Groups List', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + expect(history.location.pathname).toEqual('/inventories/1/groups'); + }); + test('handleSubmit should call api', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('InventoryGroupForm').prop('handleSubmit')({ + name: 'Bar', + description: 'Ansible', + variables: 'ying: yang', + }); + expect(GroupsAPI.update).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js new file mode 100644 index 0000000000..75519c821b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupEdit'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx new file mode 100644 index 0000000000..0ae49c22d4 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { Formik } from 'formik'; +import { Form, Card, CardBody, CardHeader } from '@patternfly/react-core'; +import { t } from '@lingui/macro'; + +import CardCloseButton from '@components/CardCloseButton'; +import FormRow from '@components/FormRow'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { VariablesField } from '@components/CodeMirrorInput'; +import { required } from '@util/validators'; + +function InventoryGroupForm({ + i18n, + error, + group = {}, + handleSubmit, + handleCancel, + match, +}) { + const initialValues = { + name: group.name || '', + description: group.description || '', + variables: group.variables || '---', + }; + + return ( + + + + + + ( +
+ + + + + + + + + {error ?
error
: null} + + )} + /> +
+
+ ); +} + +export default withI18n()(withRouter(InventoryGroupForm)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx new file mode 100644 index 0000000000..2c3484e1b6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryGroupForm from './InventoryGroupForm'; + +const group = { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'ying: false', +}; +describe('', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts( + + ); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should render values for the fields that have them', () => { + expect(wrapper.length).toBe(1); + expect(wrapper.find("FormGroup[label='Name']").length).toBe(1); + expect(wrapper.find("FormGroup[label='Description']").length).toBe(1); + expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js new file mode 100644 index 0000000000..090b2c2f8a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupForm'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx index c8f6b78621..91f0b0c4f9 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx @@ -27,7 +27,7 @@ function InventoryGroupItem({ onSelect, }) { const labelId = `check-action-${group.id}`; - const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`; + const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; return ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx index 49f55cd4d8..0e3d82a8b0 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -1,250 +1,50 @@ -import React, { useState, useEffect } from 'react'; -import { TrashAltIcon } from '@patternfly/react-icons'; -import { withRouter } from 'react-router-dom'; +import React from 'react'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { getQSConfig, parseQueryString } from '@util/qs'; -import { InventoriesAPI, GroupsAPI } from '@api'; -import { Button, Tooltip } from '@patternfly/react-core'; -import AlertModal from '@components/AlertModal'; -import ErrorDetail from '@components/ErrorDetail'; -import DataListToolbar from '@components/DataListToolbar'; -import PaginatedDataList, { - ToolbarAddButton, -} from '@components/PaginatedDataList'; -import styled from 'styled-components'; -import InventoryGroupItem from './InventoryGroupItem'; -import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; -const QS_CONFIG = getQSConfig('host', { - page: 1, - page_size: 20, - order_by: 'name', -}); +import { Switch, Route, withRouter } from 'react-router-dom'; -const DeleteButton = styled(Button)` - padding: 5px 8px; +import InventoryGroupAdd from './InventoryGroupAdd/InventoryGroupAdd'; - &:hover { - background-color: #d9534f; - color: white; - } - - &[disabled] { - color: var(--pf-c-button--m-plain--Color); - pointer-events: initial; - cursor: not-allowed; - } -`; - -function cannotDelete(item) { - return !item.summary_fields.user_capabilities.delete; -} - -const useModal = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - - function toggleModal() { - setIsModalOpen(!isModalOpen); - } - - return { - isModalOpen, - toggleModal, - }; -}; - -function InventoryGroups({ i18n, location, match }) { - const [actions, setActions] = useState(null); - const [contentError, setContentError] = useState(null); - const [deletionError, setDeletionError] = useState(null); - const [groupCount, setGroupCount] = useState(0); - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [selected, setSelected] = useState([]); - const { isModalOpen, toggleModal } = useModal(); - - const inventoryId = match.params.id; - - const fetchGroups = (id, queryString) => { - const params = parseQueryString(QS_CONFIG, queryString); - return InventoriesAPI.readGroups(id, params); - }; - - useEffect(() => { - async function fetchData() { - try { - const [ - { - data: { count, results }, - }, - { - data: { actions: optionActions }, - }, - ] = await Promise.all([ - fetchGroups(inventoryId, location.search), - InventoriesAPI.readGroupsOptions(inventoryId), - ]); - - setGroups(results); - setGroupCount(count); - setActions(optionActions); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } - } - fetchData(); - }, [inventoryId, location]); - - const handleSelectAll = isSelected => { - setSelected(isSelected ? [...groups] : []); - }; - - const handleSelect = row => { - if (selected.some(s => s.id === row.id)) { - setSelected(selected.filter(s => s.id !== row.id)); - } else { - setSelected(selected.concat(row)); - } - }; - - const renderTooltip = () => { - const itemsUnableToDelete = selected - .filter(cannotDelete) - .map(item => item.name) - .join(', '); - - if (selected.some(cannotDelete)) { - return ( -
- {i18n._( - t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}` - )} -
- ); - } - if (selected.length) { - return i18n._(t`Delete`); - } - return i18n._(t`Select a row to delete`); - }; - - const handleDelete = async option => { - setIsLoading(true); - - try { - /* eslint-disable no-await-in-loop, no-restricted-syntax */ - /* Delete groups sequentially to avoid api integrity errors */ - for (const group of selected) { - if (option === 'delete') { - await GroupsAPI.destroy(+group.id); - } else if (option === 'promote') { - await InventoriesAPI.promoteGroup(inventoryId, +group.id); - } - } - /* eslint-enable no-await-in-loop, no-restricted-syntax */ - } catch (error) { - setDeletionError(error); - } - - toggleModal(); - - try { - const { - data: { count, results }, - } = await fetchGroups(inventoryId, location.search); - setGroups(results); - setGroupCount(count); - } catch (error) { - setContentError(error); - } - - setIsLoading(false); - }; - - const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = - selected.length > 0 && selected.length === groups.length; +import InventoryGroup from './InventoryGroup/InventoryGroup'; +import InventoryGroupsList from './InventoryGroupsList'; +function InventoryGroups({ setBreadcrumb, inventory, location, match }) { return ( - <> - ( - row.id === item.id)} - onSelect={() => handleSelect(item)} - /> - )} - renderToolbar={props => ( - -
- - - -
- , - canAdd && ( - - ), - ]} - /> - )} - emptyStateControls={ - canAdd && ( - + {[ + { + return ; + }} + />, + { + return ( + + ); + }} + />, + ( + - ) - } - /> - {deletionError && ( - setDeletionError(null)} - > - {i18n._(t`Failed to delete one or more groups.`)} - - - )} - - + )} + />, + ]} + ); } +export { InventoryGroups as _InventoryGroups }; export default withI18n()(withRouter(InventoryGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx new file mode 100644 index 0000000000..20f2578afe --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from 'react'; +import { TrashAltIcon } from '@patternfly/react-icons'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { InventoriesAPI, GroupsAPI } from '@api'; +import { Button, Tooltip } from '@patternfly/react-core'; +import AlertModal from '@components/AlertModal'; +import ErrorDetail from '@components/ErrorDetail'; +import DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList, { + ToolbarAddButton, +} from '@components/PaginatedDataList'; +import styled from 'styled-components'; +import InventoryGroupItem from './InventoryGroupItem'; +import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; + +const QS_CONFIG = getQSConfig('host', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +const DeleteButton = styled(Button)` + padding: 5px 8px; + + &:hover { + background-color: #d9534f; + color: white; + } + + &[disabled] { + color: var(--pf-c-button--m-plain--Color); + pointer-events: initial; + cursor: not-allowed; + } +`; + +function cannotDelete(item) { + return !item.summary_fields.user_capabilities.delete; +} + +const useModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + function toggleModal() { + setIsModalOpen(!isModalOpen); + } + + return { + isModalOpen, + toggleModal, + }; +}; + +function InventoryGroupsList({ i18n, location, match }) { + const [actions, setActions] = useState(null); + const [contentError, setContentError] = useState(null); + const [deletionError, setDeletionError] = useState(null); + const [groupCount, setGroupCount] = useState(0); + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selected, setSelected] = useState([]); + const { isModalOpen, toggleModal } = useModal(); + + const inventoryId = match.params.id; + const fetchGroups = (id, queryString) => { + const params = parseQueryString(QS_CONFIG, queryString); + return InventoriesAPI.readGroups(id, params); + }; + + useEffect(() => { + async function fetchData() { + try { + const [ + { + data: { count, results }, + }, + { + data: { actions: optionActions }, + }, + ] = await Promise.all([ + fetchGroups(inventoryId, location.search), + InventoriesAPI.readGroupsOptions(inventoryId), + ]); + + setGroups(results); + setGroupCount(count); + setActions(optionActions); + } catch (error) { + setContentError(error); + } finally { + setIsLoading(false); + } + } + fetchData(); + }, [inventoryId, location]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...groups] : []); + }; + + const handleSelect = row => { + if (selected.some(s => s.id === row.id)) { + setSelected(selected.filter(s => s.id !== row.id)); + } else { + setSelected(selected.concat(row)); + } + }; + + const renderTooltip = () => { + const itemsUnableToDelete = selected + .filter(cannotDelete) + .map(item => item.name) + .join(', '); + + if (selected.some(cannotDelete)) { + return ( +
+ {i18n._( + t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}` + )} +
+ ); + } + if (selected.length) { + return i18n._(t`Delete`); + } + return i18n._(t`Select a row to delete`); + }; + + const handleDelete = async option => { + setIsLoading(true); + + try { + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + /* Delete groups sequentially to avoid api integrity errors */ + for (const group of selected) { + if (option === 'delete') { + await GroupsAPI.destroy(+group.id); + } else if (option === 'promote') { + await InventoriesAPI.promoteGroup(inventoryId, +group.id); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + } catch (error) { + setDeletionError(error); + } + + toggleModal(); + + try { + const { + data: { count, results }, + } = await fetchGroups(inventoryId, location.search); + setGroups(results); + setGroupCount(count); + } catch (error) { + setContentError(error); + } + + setIsLoading(false); + }; + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const isAllSelected = + selected.length > 0 && selected.length === groups.length; + + return ( + <> + ( + row.id === item.id)} + onSelect={() => handleSelect(item)} + /> + )} + renderToolbar={props => ( + +
+ + + +
+ , + canAdd && ( + + ), + ]} + /> + )} + emptyStateControls={ + canAdd && ( + + ) + } + /> + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete one or more groups.`)} + + + )} + + + ); +} +export default withI18n()(withRouter(InventoryGroupsList)); From 3ea37e1c798c6d316746e7ac19e56a9ea17d2867 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 3 Dec 2019 15:49:36 -0500 Subject: [PATCH 089/109] Addresses PR issues Adds Delete Modal for deleting from Details view Adds test for delete modal Addresses styling for Variables label Removes X close button from form --- .../InventoryGroup/InventoryGroup.jsx | 14 ++- .../InventoryGroupDetail.jsx | 86 ++++++++++++++++--- .../InventoryGroupDetail.test.jsx | 18 ++-- .../InventoryGroupForm/InventoryGroupForm.jsx | 9 +- 4 files changed, 95 insertions(+), 32 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx index 9a4994c8fe..d665c19176 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx @@ -51,14 +51,24 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory }) { key="edit" path="/inventories/inventory/:id/groups/:groupId/edit" render={() => { - return ; + return ( + + ); }} />, { - return ; + return ( + + ); }} />, { try { await GroupsAPI.destroy(inventoryGroup.id); @@ -29,10 +33,42 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { setError(err); } }; - if (error) { return ; } + if (isDeleteModalOpen) { + return ( + setIsDeleteModalOpen(false)} + actions={[ + , + , + ]} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {inventoryGroup.name} +
+
+ ); + } return ( @@ -41,23 +77,47 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { label={i18n._(t`Description`)} value={inventoryGroup.description} /> + + } + /> - + {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} + + {inventoryGroup.summary_fields.created_by.username} + + + } /> + {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} + + {inventoryGroup.summary_fields.modified_by.username} + + + } /> @@ -75,7 +135,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx index d1803d1928..2a762aa435 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx @@ -13,14 +13,16 @@ const inventoryGroup = { description: 'Bar', variables: 'bizz: buzz', id: 1, - created: '10:00', - modified: '12:00', + created: '2019-12-02T15:58:16.276813Z', + modified: '2019-12-03T20:33:46.207654Z', summary_fields: { created_by: { username: 'James', + id: 13, }, modified_by: { username: 'Bond', + id: 14, }, }, }; @@ -49,8 +51,10 @@ describe('', () => { test('InventoryGroupDetail renders successfully', () => { expect(wrapper.length).toBe(1); }); - test('should call api to delete the group', () => { + test('should open delete modal and then call api to delete the group', () => { wrapper.find('button[aria-label="Delete"]').simulate('click'); + expect(wrapper.find('Modal').length).toBe(1); + wrapper.find('button[aria-label="confirm delete"]').simulate('click'); expect(GroupsAPI.destroy).toBeCalledWith(1); }); test('should navigate user to edit form on edit button click', async () => { @@ -67,12 +71,8 @@ describe('', () => { expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( 'Bar' ); - expect(wrapper.find('Detail[label="Created"]').prop('value')).toBe( - '10:00 by James' - ); - expect(wrapper.find('Detail[label="Modified"]').prop('value')).toBe( - '12:00 by Bond' - ); + expect(wrapper.find('Detail[label="Created"]').length).toBe(1); + expect(wrapper.find('Detail[label="Modified"]').length).toBe(1); expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz'); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx index 0ae49c22d4..f6dea49aee 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx @@ -2,10 +2,9 @@ import React from 'react'; import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { Formik } from 'formik'; -import { Form, Card, CardBody, CardHeader } from '@patternfly/react-core'; +import { Form, Card, CardBody } from '@patternfly/react-core'; import { t } from '@lingui/macro'; -import CardCloseButton from '@components/CardCloseButton'; import FormRow from '@components/FormRow'; import FormField from '@components/FormField'; import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; @@ -18,7 +17,6 @@ function InventoryGroupForm({ group = {}, handleSubmit, handleCancel, - match, }) { const initialValues = { name: group.name || '', @@ -28,11 +26,6 @@ function InventoryGroupForm({ return ( - - - Date: Mon, 9 Dec 2019 16:06:56 -0500 Subject: [PATCH 090/109] Adds Alert Modal, Breadcrumb, Nested Tabs and Refactors PR. --- .../src/components/RoutedTabs/RoutedTabs.jsx | 11 +- .../src/screens/Inventory/Inventories.jsx | 6 +- .../src/screens/Inventory/Inventory.jsx | 7 +- .../InventoryGroup/InventoryGroup.jsx | 141 +++++++++++------- .../InventoryGroup/InventoryGroup.test.jsx | 70 +++++++++ .../InventoryGroupAdd/InventoryGroupAdd.jsx | 17 +-- .../InventoryGroupAdd.test.jsx | 8 +- .../InventoryGroupDetail.jsx | 45 +++--- .../InventoryGroupDetail.test.jsx | 50 ++++--- .../InventoryGroupEdit/InventoryGroupEdit.jsx | 8 +- 10 files changed, 255 insertions(+), 108 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx diff --git a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx index 0e90017b22..964806f05e 100644 --- a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx +++ b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx @@ -3,6 +3,7 @@ import { shape, string, number, arrayOf } from 'prop-types'; import { Tab, Tabs as PFTabs } from '@patternfly/react-core'; import { withRouter } from 'react-router-dom'; import styled from 'styled-components'; +import { CaretLeftIcon } from '@patternfly/react-icons'; const Tabs = styled(PFTabs)` --pf-c-tabs__button--PaddingLeft: 20px; @@ -62,7 +63,15 @@ function RoutedTabs(props) { eventKey={tab.id} key={tab.id} link={tab.link} - title={tab.name} + title={ + tab.isNestedTabs ? ( + <> + {tab.name} + + ) : ( + tab.name + ) + } /> ))} diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 8e48139bc9..4253078486 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -61,9 +61,11 @@ class Inventories extends Component { t`Create New Group` ), [`/inventories/inventory/${inventory.id}/groups/${group && - group.id}/details`]: i18n._(t`Details`), + group.id}`]: `${group && group.name}`, [`/inventories/inventory/${inventory.id}/groups/${group && - group.id}/edit`]: `${group && group.name}`, + group.id}/details`]: i18n._(t`Group Details`), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/edit`]: i18n._(t`Edit Details`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index da49aa6d8e..f3e78d584b 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { ); - if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) { + if ( + location.pathname.endsWith('edit') || + location.pathname.endsWith('add') || + location.pathname.includes('groups/') + ) { cardHeader = null; } @@ -127,6 +131,7 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx index d665c19176..f5fbada3fe 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx @@ -1,18 +1,18 @@ import React, { useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import { withI18n } from '@lingui/react'; +import { CardHeader } from '@patternfly/react-core'; import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom'; import { GroupsAPI } from '@api'; - +import CardCloseButton from '@components/CardCloseButton'; +import RoutedTabs from '@components/RoutedTabs'; import ContentError from '@components/ContentError'; import ContentLoading from '@components/ContentLoading'; - import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit'; - import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; -function InventoryGroups({ i18n, match, setBreadcrumb, inventory }) { +function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { const [inventoryGroup, setInventoryGroup] = useState(null); const [hasContentLoading, setContentLoading] = useState(true); const [hasContentError, setHasContentError] = useState(false); @@ -32,64 +32,101 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory }) { loadData(); }, [match.params.groupId, setBreadcrumb, inventory]); - + const tabsArray = [ + { + name: i18n._(t`Return to Groups`), + link: `/inventories/inventory/${inventory.id}/groups`, + id: 99, + isNestedTabs: true, + }, + { + name: i18n._(t`Details`), + link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && + inventoryGroup.id}/details`, + id: 0, + }, + { + name: i18n._(t`RelatedGroups`), + link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && + inventoryGroup.id}/nested_groups`, + id: 1, + }, + { + name: i18n._(t`Hosts`), + link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && + inventoryGroup.id}/nested_hosts`, + id: 2, + }, + ]; if (hasContentError) { return ; } if (hasContentLoading) { return ; } - return ( - - + + - {inventoryGroup && [ - { - return ( - - ); - }} - />, - { - return ( - - ); - }} - />, + + ); + if ( + !history.location.pathname.includes('groups/') || + history.location.pathname.endsWith('edit') + ) { + cardHeader = null; + } + return ( + <> + {cardHeader} + + + {inventoryGroup && [ + { + return ( + + ); + }} + />, + { + return ; + }} + />, + ]} - !hasContentLoading && ( - - {match.params.id && ( - - {i18n._(t`View Inventory Details`)} - - )} - - ) - } - />, - ]} - + render={() => { + return ( + !hasContentLoading && ( + + {inventory && ( + + {i18n._(t`View Inventory Details`)} + + )} + + ) + ); + }} + /> + + ); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx new file mode 100644 index 0000000000..afec079a6d --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import InventoryGroup from './InventoryGroup'; + +jest.mock('@api'); +GroupsAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + summary_fields: { + created_by: { id: 1, name: 'Athena' }, + modified_by: { id: 1, name: 'Apollo' }, + }, + }, +}); +describe('', () => { + let wrapper; + let history; + const inventory = { id: 1, name: 'Foo' }; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + }, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('renders successfully', async () => { + await act(async () => { + waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe( + 1 + ); + }); + test('expect Return to Groups tab to exist', async () => { + await act(async () => { + waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe( + 1 + ); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx index f09510a771..248309b005 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx @@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { withRouter } from 'react-router-dom'; import { GroupsAPI } from '@api'; +import { Card } from '@patternfly/react-core'; -import ContentError from '@components/ContentError'; import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { @@ -21,15 +21,14 @@ function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { const handleCancel = () => { history.push(`/inventories/inventory/${inventory.id}/groups`); }; - if (error) { - return ; - } return ( - + + + ); } export default withI18n()(withRouter(InventoryGroupsAdd)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx index 9155a5054a..67ca98f99c 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx @@ -13,7 +13,7 @@ describe('', () => { let history; beforeEach(async () => { history = createMemoryHistory({ - initialEntries: ['/inventories/1/groups'], + initialEntries: ['/inventories/inventory/1/groups'], }); await act(async () => { wrapper = mountWithContexts( @@ -34,14 +34,16 @@ describe('', () => { afterEach(() => { wrapper.unmount(); }); - test('InventoryGroupEdit renders successfully', () => { + test('InventoryGroupAdd renders successfully', () => { expect(wrapper.length).toBe(1); }); test('cancel should navigate user to Inventory Groups List', async () => { await act(async () => { waitForElement(wrapper, 'isLoading', el => el.length === 0); }); - expect(history.location.pathname).toEqual('/inventories/1/groups'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups' + ); }); test('handleSubmit should call api', async () => { await act(async () => { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx index 0835e87c25..7244385471 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -5,14 +5,21 @@ import { CardBody, Button } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { withRouter, Link } from 'react-router-dom'; import styled from 'styled-components'; -import { VariablesInput } from '@components/CodeMirrorInput'; -import ContentError from '@components/ContentError'; +import { VariablesInput as CodeMirrorInput } from '@components/CodeMirrorInput'; +import ErrorDetail from '@components/ErrorDetail'; import AlertModal from '@components/AlertModal'; import { formatDateString } from '@util/dates'; import { GroupsAPI } from '@api'; import { DetailList, Detail } from '@components/DetailList'; +const VariablesInput = styled(CodeMirrorInput)` + .pf-c-form__label { + font-weight: 600; + font-size: 16px; + } + margin: 20px 0; +`; const ActionButtonWrapper = styled.div` display: flex; justify-content: flex-end; @@ -26,6 +33,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const handleDelete = async () => { + setIsDeleteModalOpen(false); try { await GroupsAPI.destroy(inventoryGroup.id); history.push(`/inventories/inventory/${match.params.id}/groups`); @@ -34,7 +42,17 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { } }; if (error) { - return ; + return ( + setError(false)} + > + {i18n._(t`Failed to delete group ${inventoryGroup.name}.`)} + + + ); } if (isDeleteModalOpen) { return ( @@ -77,21 +95,14 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { label={i18n._(t`Description`)} value={inventoryGroup.description} /> - - } - /> + ', () => { let wrapper; + let history; beforeEach(async () => { await act(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/edit'], + }); wrapper = mountWithContexts( - - ( - - )} - /> - + ( + + )} + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } ); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); @@ -51,20 +57,22 @@ describe('', () => { test('InventoryGroupDetail renders successfully', () => { expect(wrapper.length).toBe(1); }); - test('should open delete modal and then call api to delete the group', () => { - wrapper.find('button[aria-label="Delete"]').simulate('click'); + test('should open delete modal and then call api to delete the group', async () => { + await act(async () => { + wrapper.find('button[aria-label="Delete"]').simulate('click'); + }); + await waitForElement(wrapper, 'Modal', el => el.length === 1); expect(wrapper.find('Modal').length).toBe(1); - wrapper.find('button[aria-label="confirm delete"]').simulate('click'); + await act(async () => { + wrapper.find('button[aria-label="confirm delete"]').simulate('click'); + }); expect(GroupsAPI.destroy).toBeCalledWith(1); }); test('should navigate user to edit form on edit button click', async () => { wrapper.find('button[aria-label="Edit"]').prop('onClick'); - expect( - wrapper - .find('Router') - .at(1) - .prop('history').location.pathname - ).toEqual('/inventories/inventory/1/groups/1/edit'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups/1/edit' + ); }); test('details shoudld render with the proper values', () => { expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo'); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx index f9e559c6c3..42d2fcde2a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx @@ -14,11 +14,15 @@ function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { } catch (err) { setError(err); } finally { - history.push(`/inventories/inventory/${inventory.id}/groups`); + history.push( + `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}/details` + ); } }; const handleCancel = () => { - history.push(`/inventories/inventory/${inventory.id}/groups`); + history.push( + `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}/details` + ); }; return ( Date: Tue, 10 Dec 2019 15:08:59 -0500 Subject: [PATCH 091/109] Testing Improvements and Refactoring --- .../InventoryGroup/InventoryGroup.jsx | 44 +++--- .../InventoryGroup/InventoryGroup.test.jsx | 16 +- .../InventoryGroupAdd/InventoryGroupAdd.jsx | 6 +- .../InventoryGroupAdd.test.jsx | 25 ++- .../InventoryGroupDetail.jsx | 144 +++++++++--------- .../InventoryGroupDetail.test.jsx | 4 +- .../InventoryGroupEdit/InventoryGroupEdit.jsx | 11 +- .../InventroyGroupEdit.test.jsx | 25 ++- .../InventoryGroupForm.test.jsx | 1 - .../InventoryGroups/InventoryGroups.jsx | 53 +++---- .../InventoryGroups/InventoryGroups.test.jsx | 10 +- 11 files changed, 173 insertions(+), 166 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx index f5fbada3fe..b205182c04 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx @@ -14,8 +14,8 @@ import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { const [inventoryGroup, setInventoryGroup] = useState(null); - const [hasContentLoading, setContentLoading] = useState(true); - const [hasContentError, setHasContentError] = useState(false); + const [hasContentLoading, setHasContentLoading] = useState(true); + const [contentError, setHasContentError] = useState(null); useEffect(() => { const loadData = async () => { @@ -26,12 +26,18 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { } catch (err) { setHasContentError(err); } finally { - setContentLoading(false); + setHasContentLoading(false); } }; loadData(); - }, [match.params.groupId, setBreadcrumb, inventory]); + }, [ + history.location.pathname, + match.params.groupId, + inventory, + setBreadcrumb, + ]); + const tabsArray = [ { name: i18n._(t`Return to Groups`), @@ -46,7 +52,7 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { id: 0, }, { - name: i18n._(t`RelatedGroups`), + name: i18n._(t`Related Groups`), link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && inventoryGroup.id}/nested_groups`, id: 1, @@ -58,26 +64,28 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { id: 2, }, ]; - if (hasContentError) { - return ; + if (contentError) { + return ; } if (hasContentLoading) { return ; } - let cardHeader = hasContentLoading ? null : ( - - - - - ); + + let cardHeader = null; if ( - !history.location.pathname.includes('groups/') || - history.location.pathname.endsWith('edit') + history.location.pathname.includes('groups/') && + !history.location.pathname.endsWith('edit') ) { - cardHeader = null; + cardHeader = ( + + + + + ); } + return ( <> {cardHeader} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx index afec079a6d..4c1b50c95b 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx @@ -45,26 +45,20 @@ describe('', () => { } ); }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); }); afterEach(() => { wrapper.unmount(); }); test('renders successfully', async () => { - await act(async () => { - waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - }); expect(wrapper.length).toBe(1); - expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe( - 1 - ); }); - test('expect Return to Groups tab to exist', async () => { - await act(async () => { - waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - }); - expect(wrapper.length).toBe(1); + test('expect all tabs to exist, including Return to Groups', async () => { expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe( 1 ); + expect(wrapper.find('button[aria-label="Details"]').length).toBe(1); + expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1); + expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx index 248309b005..720e9d4b3c 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx @@ -7,8 +7,10 @@ import { Card } from '@patternfly/react-core'; import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { - useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]); const [error, setError] = useState(null); + + useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]); + const handleSubmit = async values => { values.inventory = inventory.id; try { @@ -18,9 +20,11 @@ function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { setError(err); } }; + const handleCancel = () => { history.push(`/inventories/inventory/${inventory.id}/groups`); }; + return ( ', () => { let history; beforeEach(async () => { history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/groups'], + initialEntries: ['/inventories/inventory/1/groups/add'], }); await act(async () => { wrapper = mountWithContexts( - {}} - inventory={{ inventory: { id: 1 } }} + ( + {}} inventory={{ id: 1 }} /> + )} />, { context: { - router: { - history, - }, + router: { history, route: { location: history.location } }, }, } ); @@ -38,17 +40,12 @@ describe('', () => { expect(wrapper.length).toBe(1); }); test('cancel should navigate user to Inventory Groups List', async () => { - await act(async () => { - waitForElement(wrapper, 'isLoading', el => el.length === 0); - }); + wrapper.find('button[aria-label="Cancel"]').simulate('click'); expect(history.location.pathname).toEqual( '/inventories/inventory/1/groups' ); }); test('handleSubmit should call api', async () => { - await act(async () => { - waitForElement(wrapper, 'isLoading', el => el.length === 0); - }); await act(async () => { wrapper.find('InventoryGroupForm').prop('handleSubmit')({ name: 'Bar', diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx index 7244385471..7b383e986f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -29,6 +29,9 @@ const ActionButtonWrapper = styled.div` } `; function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { + const { + summary_fields: { created_by, modified_by }, + } = inventoryGroup; const [error, setError] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -41,52 +44,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { setError(err); } }; - if (error) { - return ( - setError(false)} - > - {i18n._(t`Failed to delete group ${inventoryGroup.name}.`)} - - - ); - } - if (isDeleteModalOpen) { - return ( - setIsDeleteModalOpen(false)} - actions={[ - , - , - ]} - > - {i18n._(t`Are you sure you want to delete:`)} -
- {inventoryGroup.name} -
-
- ); - } + return ( @@ -104,32 +62,32 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { label={i18n._(t`Variables`)} /> - - {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} - - {inventoryGroup.summary_fields.created_by.username} - - - } - /> - - {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} - - {inventoryGroup.summary_fields.modified_by.username} - - - } - /> + {created_by && created_by.username && ( + + {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} + + {created_by.username} + + + } + /> + )} + {modified_by && modified_by.username && ( + + {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} + + {modified_by.username} + + + } + /> + )} , + , + ]} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {inventoryGroup.name} +
+ + )} + {error && ( + setError(false)} + > + {i18n._(t`Failed to delete group ${inventoryGroup.name}.`)} + + + )}
); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx index 8e82fea394..99a017ce32 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx @@ -33,7 +33,7 @@ describe('', () => { beforeEach(async () => { await act(async () => { history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/groups/1/edit'], + initialEntries: ['/inventories/inventory/1/groups/1/details'], }); wrapper = mountWithContexts( ', () => { expect(GroupsAPI.destroy).toBeCalledWith(1); }); test('should navigate user to edit form on edit button click', async () => { - wrapper.find('button[aria-label="Edit"]').prop('onClick'); + wrapper.find('button[aria-label="Edit"]').simulate('click'); expect(history.location.pathname).toEqual( '/inventories/inventory/1/groups/1/edit' ); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx index 42d2fcde2a..6ff0e58c58 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx @@ -11,19 +11,20 @@ function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { const handleSubmit = async values => { try { await GroupsAPI.update(match.params.groupId, values); + history.push( + `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}` + ); } catch (err) { setError(err); - } finally { - history.push( - `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}/details` - ); } }; + const handleCancel = () => { history.push( - `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}/details` + `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}` ); }; + return ( ', () => { let history; beforeEach(async () => { history = createMemoryHistory({ - initialEntries: ['/inventories/1/groups'], + initialEntries: ['/inventories/inventory/1/groups/2/edit'], }); await act(async () => { wrapper = mountWithContexts( - ( + {}} + inventory={{ id: 1 }} + inventoryGroup={{ id: 2 }} + /> + )} />, { context: { @@ -35,6 +42,7 @@ describe('', () => { match: { params: { groupId: 13 }, }, + location: history.location, }, }, }, @@ -49,11 +57,12 @@ describe('', () => { expect(wrapper.length).toBe(1); }); test('cancel should navigate user to Inventory Groups List', async () => { - await waitForElement(wrapper, 'isLoading', el => el.length === 0); - expect(history.location.pathname).toEqual('/inventories/1/groups'); + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups/2' + ); }); test('handleSubmit should call api', async () => { - await waitForElement(wrapper, 'isLoading', el => el.length === 0); wrapper.find('InventoryGroupForm').prop('handleSubmit')({ name: 'Bar', description: 'Ansible', diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx index 2c3484e1b6..ebf459f76f 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx @@ -26,7 +26,6 @@ describe('', () => { expect(wrapper.length).toBe(1); }); test('should render values for the fields that have them', () => { - expect(wrapper.length).toBe(1); expect(wrapper.find("FormGroup[label='Name']").length).toBe(1); expect(wrapper.find("FormGroup[label='Description']").length).toBe(1); expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx index 0e3d82a8b0..e35e2dedb7 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -11,37 +11,32 @@ import InventoryGroupsList from './InventoryGroupsList'; function InventoryGroups({ setBreadcrumb, inventory, location, match }) { return ( - {[ - { - return ; - }} - />, - { - return ( - - ); - }} - />, - ( - { + return ( + - )} - />, - ]} + ); + }} + /> + ( + + )} + /> + { + return ; + }} + /> ); } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx index 2b5a7340c0..8c60d8bfbd 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx @@ -4,7 +4,7 @@ import { Route } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { InventoriesAPI, GroupsAPI } from '@api'; -import InventoryGroups from './InventoryGroups'; +import InventoryGroupsList from './InventoryGroupsList'; jest.mock('@api'); @@ -50,7 +50,7 @@ const mockGroups = [ }, ]; -describe('', () => { +describe('', () => { let wrapper; beforeEach(async () => { @@ -75,7 +75,7 @@ describe('', () => { wrapper = mountWithContexts( } + component={() => } />, { context: { @@ -88,7 +88,7 @@ describe('', () => { }); test('initially renders successfully', () => { - expect(wrapper.find('InventoryGroups').length).toBe(1); + expect(wrapper.find('InventoryGroupsList').length).toBe(1); }); test('should fetch groups from api and render them in the list', async () => { @@ -147,7 +147,7 @@ describe('', () => { Promise.reject(new Error()) ); await act(async () => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts(); }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); }); From 210f9577b0d57cec0b58b92dd474ccea2e16bdc4 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 11 Dec 2019 14:32:47 -0500 Subject: [PATCH 092/109] Fixed filename typo --- .../{InventroyGroupEdit.test.jsx => InventoryGroupEdit.test.jsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/{InventroyGroupEdit.test.jsx => InventoryGroupEdit.test.jsx} (100%) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventroyGroupEdit.test.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.test.jsx From 1942be7dc3c5afa0eebc1ddb627eb1bc36498f0c Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 12 Dec 2019 11:26:35 -0500 Subject: [PATCH 093/109] Checks for modified and create in Inv Group Deets Also includes refactoring for css over style prop and removed some unnecessary loading checks --- .../InventoryGroup/InventoryGroup.jsx | 16 +++-- .../InventoryGroupDetail.jsx | 60 +++++++++++-------- .../InventoryGroups/InventoryGroupsList.jsx | 2 +- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx index b205182c04..db03a1ecf2 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx @@ -121,15 +121,13 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { path="*" render={() => { return ( - !hasContentLoading && ( - - {inventory && ( - - {i18n._(t`View Inventory Details`)} - - )} - - ) + + {inventory && ( + + {i18n._(t`View Inventory Details`)} + + )} + ); }} /> diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx index 7b383e986f..20d99538b8 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -31,6 +31,8 @@ const ActionButtonWrapper = styled.div` function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { const { summary_fields: { created_by, modified_by }, + created, + modified, } = inventoryGroup; const [error, setError] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -45,8 +47,36 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { } }; + let createdBy = ''; + if (created) { + if (created_by && created_by.username) { + createdBy = ( + + {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} + {created_by.username} + + ); + } else { + createdBy = i18n._(t`${formatDateString(inventoryGroup.created)} by`); + } + } + + let modifiedBy = ''; + if (modified) { + if (modified_by && modified_by.username) { + modifiedBy = ( + + {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} + {modified_by.username} + + ); + } else { + modifiedBy = i18n._(t`${formatDateString(inventoryGroup.modified)} by`); + } + } + return ( - + - {created_by && created_by.username && ( - - {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} - - {created_by.username} - - - } - /> - )} - {modified_by && modified_by.username && ( - - {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} - - {modified_by.username} - - - } - /> + {createdBy && } + {modifiedBy && ( + )} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx index 20f2578afe..1840c3815c 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -16,7 +16,7 @@ import styled from 'styled-components'; import InventoryGroupItem from './InventoryGroupItem'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; -const QS_CONFIG = getQSConfig('host', { +const QS_CONFIG = getQSConfig('group', { page: 1, page_size: 20, order_by: 'name', From ef5ce0b08207587add3071e05b5cf33ab7d2d551 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 12 Dec 2019 15:42:36 -0500 Subject: [PATCH 094/109] Flattens Inventory File Structure Remove --- awx/ui_next/src/index.jsx | 4 ++-- .../{InventoryGroups => }/InventoryGroup/InventoryGroup.jsx | 0 .../InventoryGroup/InventoryGroup.test.jsx | 0 .../Inventory/{InventoryGroups => }/InventoryGroup/index.js | 0 .../InventoryGroupAdd/InventoryGroupAdd.jsx | 0 .../InventoryGroupAdd/InventoryGroupAdd.test.jsx | 0 .../{InventoryGroups => }/InventoryGroupAdd/index.js | 0 .../InventoryGroupDetail/InventoryGroupDetail.jsx | 4 ++-- .../InventoryGroupDetail/InventoryGroupDetail.test.jsx | 0 .../{InventoryGroups => }/InventoryGroupDetail/index.js | 0 .../InventoryGroupEdit/InventoryGroupEdit.jsx | 0 .../InventoryGroupEdit/InventoryGroupEdit.test.jsx | 0 .../{InventoryGroups => }/InventoryGroupEdit/index.js | 0 .../InventoryGroupForm/InventoryGroupForm.jsx | 0 .../InventoryGroupForm/InventoryGroupForm.test.jsx | 0 .../{InventoryGroups => }/InventoryGroupForm/index.js | 0 .../src/screens/Inventory/InventoryGroups/InventoryGroups.jsx | 4 ++-- 17 files changed, 6 insertions(+), 6 deletions(-) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroup/InventoryGroup.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroup/InventoryGroup.test.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroup/index.js (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupAdd/InventoryGroupAdd.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupAdd/InventoryGroupAdd.test.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupAdd/index.js (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupDetail/InventoryGroupDetail.jsx (96%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupDetail/InventoryGroupDetail.test.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupDetail/index.js (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupEdit/InventoryGroupEdit.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupEdit/InventoryGroupEdit.test.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupEdit/index.js (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupForm/InventoryGroupForm.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupForm/InventoryGroupForm.test.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroups => }/InventoryGroupForm/index.js (100%) diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx index 9227e6e7e3..06d5245d81 100644 --- a/awx/ui_next/src/index.jsx +++ b/awx/ui_next/src/index.jsx @@ -15,7 +15,7 @@ import CredentialTypes from '@screens/CredentialType'; import Dashboard from '@screens/Dashboard'; import Hosts from '@screens/Host'; import InstanceGroups from '@screens/InstanceGroup'; -import Inventories from '@screens/Inventory'; +import Inventory from '@screens/Inventory'; import InventoryScripts from '@screens/InventoryScript'; import { Jobs } from '@screens/Job'; import Login from '@screens/Login'; @@ -139,7 +139,7 @@ export function main(render) { { title: i18n._(t`Inventories`), path: '/inventories', - component: Inventories, + component: Inventory, }, { title: i18n._(t`Hosts`), diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/InventoryGroup.test.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroup/index.js similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroup/index.js rename to awx/ui_next/src/screens/Inventory/InventoryGroup/index.js diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/InventoryGroupAdd.test.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/index.js similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupAdd/index.js rename to awx/ui_next/src/screens/Inventory/InventoryGroupAdd/index.js diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx similarity index 96% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx index 20d99538b8..28b662e2ee 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -57,7 +57,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { ); } else { - createdBy = i18n._(t`${formatDateString(inventoryGroup.created)} by`); + createdBy = formatDateString(inventoryGroup.created); } } @@ -71,7 +71,7 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { ); } else { - modifiedBy = i18n._(t`${formatDateString(inventoryGroup.modified)} by`); + modifiedBy = formatDateString(inventoryGroup.modified); } } diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/InventoryGroupDetail.test.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/index.js similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupDetail/index.js rename to awx/ui_next/src/screens/Inventory/InventoryGroupDetail/index.js diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/InventoryGroupEdit.test.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/index.js similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupEdit/index.js rename to awx/ui_next/src/screens/Inventory/InventoryGroupEdit/index.js diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/InventoryGroupForm.test.jsx rename to awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.test.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupForm/index.js similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupForm/index.js rename to awx/ui_next/src/screens/Inventory/InventoryGroupForm/index.js diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx index e35e2dedb7..2917f3f96d 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -3,9 +3,9 @@ import { withI18n } from '@lingui/react'; import { Switch, Route, withRouter } from 'react-router-dom'; -import InventoryGroupAdd from './InventoryGroupAdd/InventoryGroupAdd'; +import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd'; -import InventoryGroup from './InventoryGroup/InventoryGroup'; +import InventoryGroup from '../InventoryGroup/InventoryGroup'; import InventoryGroupsList from './InventoryGroupsList'; function InventoryGroups({ setBreadcrumb, inventory, location, match }) { From 4b62d77015bb3b7c634025ef455314a3899b8124 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 13 Dec 2019 10:05:17 -0500 Subject: [PATCH 095/109] Moves inventoryGroupForm into shared directory Updates InventoryGroups tests Adds ContentError functionalist to catch a case where a use might navigate to an Inventory that isn't associated to the shown inventoryGroup. --- .../Inventory/InventoryEdit/InventoryEdit.jsx | 8 +- .../InventoryGroup/InventoryGroup.jsx | 37 ++- .../InventoryGroup/InventoryGroup.test.jsx | 9 +- .../InventoryGroupAdd/InventoryGroupAdd.jsx | 2 +- .../InventoryGroupDetail.jsx | 12 +- .../InventoryGroupEdit/InventoryGroupEdit.jsx | 2 +- .../Inventory/InventoryGroupForm/index.js | 1 - .../InventoryGroups/InventoryGroups.test.jsx | 224 +++--------------- .../InventoryGroupsList.test.jsx | 217 +++++++++++++++++ .../InventoryGroupForm.jsx | 0 .../InventoryGroupForm.test.jsx | 0 11 files changed, 297 insertions(+), 215 deletions(-) delete mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroupForm/index.js create mode 100644 awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx rename awx/ui_next/src/screens/Inventory/{InventoryGroupForm => shared}/InventoryGroupForm.jsx (100%) rename awx/ui_next/src/screens/Inventory/{InventoryGroupForm => shared}/InventoryGroupForm.test.jsx (100%) diff --git a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx index 131787ae95..2ec78aef4a 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryEdit/InventoryEdit.jsx @@ -15,7 +15,7 @@ import { getAddedAndRemoved } from '../../../util/lists'; function InventoryEdit({ history, i18n, inventory }) { const [error, setError] = useState(null); const [associatedInstanceGroups, setInstanceGroups] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [contentLoading, setContentLoading] = useState(true); const [credentialTypeId, setCredentialTypeId] = useState(null); useEffect(() => { @@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) { } catch (err) { setError(err); } finally { - setIsLoading(false); + setContentLoading(false); } }; loadData(); - }, [inventory.id, isLoading, inventory, credentialTypeId]); + }, [inventory.id, contentLoading, inventory, credentialTypeId]); const handleCancel = () => { history.push('/inventories'); @@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) { history.push(`${url}`); } }; - if (isLoading) { + if (contentLoading) { return ; } if (error) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx index db03a1ecf2..1c510f4fe6 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx @@ -14,8 +14,8 @@ import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { const [inventoryGroup, setInventoryGroup] = useState(null); - const [hasContentLoading, setHasContentLoading] = useState(true); - const [contentError, setHasContentError] = useState(null); + const [contentLoading, setContentLoading] = useState(true); + const [contentError, setContentError] = useState(null); useEffect(() => { const loadData = async () => { @@ -24,9 +24,9 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { setInventoryGroup(data); setBreadcrumb(inventory, data); } catch (err) { - setHasContentError(err); + setContentError(err); } finally { - setHasContentLoading(false); + setContentLoading(false); } }; @@ -64,12 +64,32 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { id: 2, }, ]; + + // In cases where a user manipulates the url such that they try to navigate to a Inventory Group + // that is not associated with the Inventory Id in the Url this Content Error is thrown. + // Inventory Groups have a 1: 1 relationship to Inventories thus their Ids must corrolate. + + if (contentLoading) { + return ; + } + + if ( + inventoryGroup.summary_fields.inventory.id !== parseInt(match.params.id, 10) + ) { + return ( + + {inventoryGroup && ( + + {i18n._(t`View Inventory Groups`)} + + )} + + ); + } + if (contentError) { return ; } - if (hasContentLoading) { - return ; - } let cardHeader = null; if ( @@ -80,12 +100,11 @@ function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { ); } - return ( <> {cardHeader} diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx index 4c1b50c95b..6273de12d8 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { GroupsAPI } from '@api'; +import { Route } from 'react-router-dom'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; @@ -14,6 +15,7 @@ GroupsAPI.readDetail.mockResolvedValue({ description: 'Bar', variables: 'bizz: buzz', summary_fields: { + inventory: { id: 1 }, created_by: { id: 1, name: 'Athena' }, modified_by: { id: 1, name: 'Apollo' }, }, @@ -29,7 +31,12 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} />, + ( + {}} inventory={inventory} /> + )} + />, { context: { router: { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx index 720e9d4b3c..eff8a2fe10 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx @@ -4,7 +4,7 @@ import { withRouter } from 'react-router-dom'; import { GroupsAPI } from '@api'; import { Card } from '@patternfly/react-core'; -import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; +import InventoryGroupForm from '../shared/InventoryGroupForm'; function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { const [error, setError] = useState(null); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx index 28b662e2ee..8cf97cffc0 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -33,6 +33,9 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { summary_fields: { created_by, modified_by }, created, modified, + name, + description, + variables, } = inventoryGroup; const [error, setError] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -78,16 +81,13 @@ function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { return ( - - + + diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx index 6ff0e58c58..230314ce7c 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx @@ -3,7 +3,7 @@ import { withI18n } from '@lingui/react'; import { withRouter } from 'react-router-dom'; import { GroupsAPI } from '@api'; -import InventoryGroupForm from '../InventoryGroupForm/InventoryGroupForm'; +import InventoryGroupForm from '../shared/InventoryGroupForm'; function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { const [error, setError] = useState(null); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupForm/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupForm/index.js deleted file mode 100644 index 090b2c2f8a..0000000000 --- a/awx/ui_next/src/screens/Inventory/InventoryGroupForm/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './InventoryGroupForm'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx index 8c60d8bfbd..d5a3665247 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx @@ -1,81 +1,25 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; import { Route } from 'react-router-dom'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import { InventoriesAPI, GroupsAPI } from '@api'; -import InventoryGroupsList from './InventoryGroupsList'; +import InventoryGroups from './InventoryGroups'; -jest.mock('@api'); - -const mockGroups = [ - { - id: 1, - type: 'group', - name: 'foo', - inventory: 1, - url: '/api/v2/groups/1', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, - }, - }, - { - id: 2, - type: 'group', - name: 'bar', - inventory: 1, - url: '/api/v2/groups/2', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, - }, - }, - { - id: 3, - type: 'group', - name: 'baz', - inventory: 1, - url: '/api/v2/groups/3', - summary_fields: { - user_capabilities: { - delete: false, - edit: false, - }, - }, - }, -]; - -describe('', () => { - let wrapper; - - beforeEach(async () => { - InventoriesAPI.readGroups.mockResolvedValue({ - data: { - count: mockGroups.length, - results: mockGroups, - }, - }); - InventoriesAPI.readGroupsOptions.mockResolvedValue({ - data: { - actions: { - GET: {}, - POST: {}, - }, - }, - }); +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; const history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/3/groups'], + initialEntries: ['/inventories/inventory/1/groups'], }); + const inventory = { id: 1, name: 'Foo' }; + await act(async () => { wrapper = mountWithContexts( } + component={() => ( + {}} inventory={inventory} /> + )} />, { context: { @@ -84,134 +28,30 @@ describe('', () => { } ); }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); - }); - - test('initially renders successfully', () => { + expect(wrapper.length).toBe(1); expect(wrapper.find('InventoryGroupsList').length).toBe(1); }); - - test('should fetch groups from api and render them in the list', async () => { - expect(InventoriesAPI.readGroups).toHaveBeenCalled(); - expect(wrapper.find('InventoryGroupItem').length).toBe(3); - }); - - test('should check and uncheck the row item', async () => { - expect( - wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked - ).toBe(false); - + test('test that InventoryGroupsAdd renders', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/add'], + }); + const inventory = { id: 1, name: 'Foo' }; + let wrapper; await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( - true - ); - }); - wrapper.update(); - expect( - wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked - ).toBe(true); - - await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( - false - ); - }); - wrapper.update(); - expect( - wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked - ).toBe(false); - }); - - test('should check all row items when select all is checked', async () => { - wrapper.find('PFDataListCheck').forEach(el => { - expect(el.props().checked).toBe(false); - }); - await act(async () => { - wrapper.find('Checkbox#select-all').invoke('onChange')(true); - }); - wrapper.update(); - wrapper.find('PFDataListCheck').forEach(el => { - expect(el.props().checked).toBe(true); - }); - await act(async () => { - wrapper.find('Checkbox#select-all').invoke('onChange')(false); - }); - wrapper.update(); - wrapper.find('PFDataListCheck').forEach(el => { - expect(el.props().checked).toBe(false); - }); - }); - - test('should show content error when api throws error on initial render', async () => { - InventoriesAPI.readGroupsOptions.mockImplementation(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); - }); - - 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.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); - }); - await waitForElement( - wrapper, - 'InventoryGroupsDeleteModal', - el => el.props().isModalOpen === true - ); - await act(async () => { - wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') - .invoke('onClick')(); - }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); - }); - - test('should show error modal when group is not successfully deleted from api', async () => { - GroupsAPI.destroy.mockRejectedValue( - new Error({ - response: { - config: { - method: 'delete', - url: '/api/v2/groups/1', + wrapper = mountWithContexts( + ( + {}} inventory={inventory} /> + )} + />, + { + context: { + router: { history, route: { location: history.location } }, }, - data: 'An error occurred', - }, - }) - ); - await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); - }); - await waitForElement( - wrapper, - 'InventoryGroupsDeleteModal', - el => el.props().isModalOpen === true - ); - await act(async () => { - wrapper.find('Radio[id="radio-delete"]').invoke('onChange')(); - }); - wrapper.update(); - await act(async () => { - wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') - .invoke('onClick')(); - }); - await waitForElement(wrapper, { title: 'Error!', variant: 'danger' }); - await act(async () => { - wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + } + ); }); + expect(wrapper.find('InventoryGroupsAdd').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx new file mode 100644 index 0000000000..8c60d8bfbd --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { InventoriesAPI, GroupsAPI } from '@api'; +import InventoryGroupsList from './InventoryGroupsList'; + +jest.mock('@api'); + +const mockGroups = [ + { + id: 1, + type: 'group', + name: 'foo', + inventory: 1, + url: '/api/v2/groups/1', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 2, + type: 'group', + name: 'bar', + inventory: 1, + url: '/api/v2/groups/2', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 3, + type: 'group', + name: 'baz', + inventory: 1, + url: '/api/v2/groups/3', + summary_fields: { + user_capabilities: { + delete: false, + edit: false, + }, + }, + }, +]; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + InventoriesAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/3/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('InventoryGroupsList').length).toBe(1); + }); + + test('should fetch groups from api and render them in the list', async () => { + expect(InventoriesAPI.readGroups).toHaveBeenCalled(); + expect(wrapper.find('InventoryGroupItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + expect( + wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked + ).toBe(true); + + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( + false + ); + }); + wrapper.update(); + expect( + wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readGroupsOptions.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + 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.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); + }); + await waitForElement( + wrapper, + 'InventoryGroupsDeleteModal', + el => el.props().isModalOpen === true + ); + await act(async () => { + wrapper + .find('ModalBoxFooter Button[aria-label="Delete"]') + .invoke('onClick')(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + test('should show error modal when group is not successfully deleted from api', async () => { + GroupsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/groups/1', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); + }); + await waitForElement( + wrapper, + 'InventoryGroupsDeleteModal', + el => el.props().isModalOpen === true + ); + await act(async () => { + wrapper.find('Radio[id="radio-delete"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('ModalBoxFooter Button[aria-label="Delete"]') + .invoke('onClick')(); + }); + await waitForElement(wrapper, { title: 'Error!', variant: 'danger' }); + await act(async () => { + wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.jsx rename to awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx similarity index 100% rename from awx/ui_next/src/screens/Inventory/InventoryGroupForm/InventoryGroupForm.test.jsx rename to awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx From ac9bf1afcf4d3e1ce8019b59788b625f91e61c84 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Mon, 25 Nov 2019 10:55:18 -0500 Subject: [PATCH 096/109] Don't error on a trailing slash, just fix it up --- awxkit/awxkit/api/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/awxkit/awxkit/api/client.py b/awxkit/awxkit/api/client.py index 22ba138425..7844a7fa11 100644 --- a/awxkit/awxkit/api/client.py +++ b/awxkit/awxkit/api/client.py @@ -74,9 +74,11 @@ class Connection(object): raise ConnectionException(message="Unknown request method: {0}".format(method)) use_endpoint = relative_endpoint - if self.server.endswith('/') and use_endpoint.startswith('/'): - raise RuntimeError('AWX URL given with trailing slash, remove slash.') - url = '{0.server}{1}'.format(self, use_endpoint) + if self.server.endswith('/'): + self.server = self.server[:-1] + if use_endpoint.startswith('/'): + use_endpoint = use_endpoint[1:] + url = '/'.join([self.server, use_endpoint]) kwargs = dict(verify=self.verify, params=query_parameters, json=json, data=data, hooks=dict(response=log_elapsed)) From 259e53f59d46f3541b0e8ce82d03e9d31269bb28 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 13 Dec 2019 14:20:52 -0500 Subject: [PATCH 097/109] Fixes failing zuul test --- .../InventoryGroups/InventoryGroups.test.jsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx index d5a3665247..935ef7bb04 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Route } from 'react-router-dom'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import InventoryGroups from './InventoryGroups'; +jest.mock('@api'); + describe('', () => { test('initially renders successfully', async () => { let wrapper; @@ -15,12 +16,8 @@ describe('', () => { await act(async () => { wrapper = mountWithContexts( - ( - {}} inventory={inventory} /> - )} - />, + {}} inventory={inventory} />, + { context: { router: { history, route: { location: history.location } }, @@ -39,12 +36,7 @@ describe('', () => { let wrapper; await act(async () => { wrapper = mountWithContexts( - ( - {}} inventory={inventory} /> - )} - />, + {}} inventory={inventory} />, { context: { router: { history, route: { location: history.location } }, From 9744b897378a13f7f8e4106fb15263028e1f299e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 13 Dec 2019 17:14:42 -0500 Subject: [PATCH 098/109] Use job details status to test status --- .../client/features/output/details.component.js | 2 +- .../client/features/output/details.partial.html | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index de76ee669e..8f1c8ef361 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -39,7 +39,7 @@ function getStatusDetails (jobStatus) { value = choices[unmapped]; } - return { label, icon, value }; + return { unmapped, label, icon, value }; } function getStartDetails (started) { diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 7264059b49..a067906886 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -12,9 +12,9 @@ class="List-actionButton List-actionButton--delete" data-placement="top" ng-click="vm.cancelJob()" - ng-show="vm.status.value === 'Pending' || - vm.status.value === 'Waiting' || - vm.status.value === 'Running'" + ng-show="vm.status.unmapped === 'pending' || + vm.status.unmapped === 'waiting' || + vm.status.unmapped === 'running'" aw-tool-tip="{{:: vm.strings.get('tooltips.CANCEL') }}" data-original-title="" title=""> @@ -27,11 +27,11 @@ data-placement="top" ng-click="vm.deleteJob()" ng-show="vm.canDelete && ( - vm.status.value === 'New' || - vm.status.value === 'Successful' || - vm.status.value === 'Failed' || - vm.status.value === 'Error' || - vm.status.value === 'Canceled')" + vm.status.unmapped === 'new' || + vm.status.unmapped === 'successful' || + vm.status.unmapped === 'failed' || + vm.status.unmapped === 'error' || + vm.status.unmapped === 'canceled')" aw-tool-tip="{{:: vm.strings.get('tooltips.DELETE') }}" data-original-title="" title=""> From 5433af6716dd82cf206a7ebec8b9d6e0a4592889 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 4 Nov 2019 22:28:49 -0500 Subject: [PATCH 099/109] Reduce API response times by caching migration flag --- awx/main/apps.py | 9 +++++++++ awx/main/middleware.py | 14 +++++++++----- awx/main/tasks.py | 6 ++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/awx/main/apps.py b/awx/main/apps.py index b45b3c20f2..3f7704f2e6 100644 --- a/awx/main/apps.py +++ b/awx/main/apps.py @@ -1,8 +1,17 @@ from django.apps import AppConfig +from django.db.models.signals import pre_migrate from django.utils.translation import ugettext_lazy as _ +def raise_migration_flag(**kwargs): + from awx.main.tasks import set_migration_flag + set_migration_flag.delay() + + class MainConfig(AppConfig): name = 'awx.main' verbose_name = _('Main') + + def ready(self): + pre_migrate.connect(raise_migration_flag, sender=self) diff --git a/awx/main/middleware.py b/awx/main/middleware.py index b1a03c9a38..3f9c7de56d 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -18,6 +18,7 @@ from django.db import IntegrityError, connection from django.utils.functional import curry from django.shortcuts import get_object_or_404, redirect from django.apps import apps +from django.core.cache import cache from django.utils.deprecation import MiddlewareMixin from django.utils.translation import ugettext_lazy as _ from django.urls import reverse, resolve @@ -213,8 +214,11 @@ class URLModificationMiddleware(MiddlewareMixin): class MigrationRanCheckMiddleware(MiddlewareMixin): def process_request(self, request): - executor = MigrationExecutor(connection) - plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) - if bool(plan) and \ - getattr(resolve(request.path), 'url_name', '') != 'migrations_notran': - return redirect(reverse("ui:migrations_notran")) + if cache.get('migration_in_progress', False): + executor = MigrationExecutor(connection) + plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) + if not bool(plan): + logger.info('Detected that migration finished, migration page taken down.') + cache.delete('migration_in_progress') + elif getattr(resolve(request.path), 'url_name', '') != 'migrations_notran': + return redirect(reverse("ui:migrations_notran")) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 9b263f550d..7fabe6308d 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -263,6 +263,12 @@ def apply_cluster_membership_policies(): logger.debug('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute)) +@task(queue='tower_broadcast_all', exchange_type='fanout') +def set_migration_flag(): + logger.debug('Received migration-in-progress signal, will serve redirect.') + cache.set('migration_in_progress', True) + + @task(queue='tower_broadcast_all', exchange_type='fanout') def handle_setting_changes(setting_keys): orig_len = len(setting_keys) From a0910eb6de3c4713199e73c43f003c48c2895f9f Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 11 Nov 2019 23:11:27 -0500 Subject: [PATCH 100/109] Apply migration flag check to task manager --- awx/main/middleware.py | 17 ++++++----------- awx/main/scheduler/tasks.py | 4 ++++ awx/main/utils/db.py | 26 ++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/awx/main/middleware.py b/awx/main/middleware.py index 3f9c7de56d..147baf3ef8 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -13,18 +13,17 @@ import urllib.parse from django.conf import settings from django.contrib.auth.models import User from django.db.models.signals import post_save -from django.db.migrations.executor import MigrationExecutor -from django.db import IntegrityError, connection +from django.db import IntegrityError from django.utils.functional import curry from django.shortcuts import get_object_or_404, redirect from django.apps import apps -from django.core.cache import cache from django.utils.deprecation import MiddlewareMixin from django.utils.translation import ugettext_lazy as _ from django.urls import reverse, resolve from awx.main.models import ActivityStream from awx.main.utils.named_url_graph import generate_graph, GraphNode +from awx.main.utils.db import migration_in_progress_check_or_relase from awx.conf import fields, register @@ -214,11 +213,7 @@ class URLModificationMiddleware(MiddlewareMixin): class MigrationRanCheckMiddleware(MiddlewareMixin): def process_request(self, request): - if cache.get('migration_in_progress', False): - executor = MigrationExecutor(connection) - plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) - if not bool(plan): - logger.info('Detected that migration finished, migration page taken down.') - cache.delete('migration_in_progress') - elif getattr(resolve(request.path), 'url_name', '') != 'migrations_notran': - return redirect(reverse("ui:migrations_notran")) + if migration_in_progress_check_or_relase(): + if getattr(resolve(request.path), 'url_name', '') == 'migrations_notran': + return + return redirect(reverse("ui:migrations_notran")) diff --git a/awx/main/scheduler/tasks.py b/awx/main/scheduler/tasks.py index c0d3dd842e..c695bd08e1 100644 --- a/awx/main/scheduler/tasks.py +++ b/awx/main/scheduler/tasks.py @@ -5,11 +5,15 @@ import logging # AWX from awx.main.scheduler import TaskManager from awx.main.dispatch.publish import task +from awx.main.utils.db import migration_in_progress_check_or_relase logger = logging.getLogger('awx.main.scheduler') @task() def run_task_manager(): + if migration_in_progress_check_or_relase(): + logger.debug("Not running task manager because migration is in progress.") + return logger.debug("Running Tower task manager.") TaskManager().schedule() diff --git a/awx/main/utils/db.py b/awx/main/utils/db.py index f91f2d7b65..88e08ad55f 100644 --- a/awx/main/utils/db.py +++ b/awx/main/utils/db.py @@ -1,8 +1,16 @@ # Copyright (c) 2017 Ansible by Red Hat # All Rights Reserved. +import logging from itertools import chain +from django.core.cache import cache +from django.db.migrations.executor import MigrationExecutor +from django.db import connection + + +logger = logging.getLogger('awx.main.utils.db') + def get_all_field_names(model): # Implements compatibility with _meta.get_all_field_names @@ -14,3 +22,21 @@ def get_all_field_names(model): # GenericForeignKey from the results. if not (field.many_to_one and field.related_model is None) ))) + + +def migration_in_progress_check_or_relase(): + '''A memcache flag is raised (set to True) to inform cluster + that a migration is ongoing see main.apps.MainConfig.ready + if the flag is True then the flag is removed on this instance if + models-db consistency is observed + effective value of migration flag is returned + ''' + migration_in_progress = cache.get('migration_in_progress', False) + if migration_in_progress: + executor = MigrationExecutor(connection) + plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) + if not bool(plan): + logger.info('Detected that migration finished, migration flag taken down.') + cache.delete('migration_in_progress') + migration_in_progress = False + return migration_in_progress From 227a90006e9e441f3a4e2164b7b00d0c9455e149 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 16 Dec 2019 07:44:14 -0500 Subject: [PATCH 101/109] Update webpack to 4.41.2 --- awx/ui_next/package-lock.json | 1192 +++++++++++++++++---------------- awx/ui_next/package.json | 2 +- 2 files changed, 614 insertions(+), 580 deletions(-) diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 0a802c8e9a..98727707e3 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -2096,175 +2096,179 @@ "dev": true }, "@webassemblyjs/ast": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.10.tgz", - "integrity": "sha512-wTUeaByYN2EA6qVqhbgavtGc7fLTOx0glG2IBsFlrFG51uXIGlYBTyIZMf4SPLo3v1bgV/7lBN3l7Z0R6Hswew==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", + "integrity": "sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ==", "dev": true, "requires": { - "@webassemblyjs/helper-module-context": "1.7.10", - "@webassemblyjs/helper-wasm-bytecode": "1.7.10", - "@webassemblyjs/wast-parser": "1.7.10" + "@webassemblyjs/helper-module-context": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/wast-parser": "1.8.5" } }, "@webassemblyjs/floating-point-hex-parser": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.7.10.tgz", - "integrity": "sha512-gMsGbI6I3p/P1xL2UxqhNh1ga2HCsx5VBB2i5VvJFAaqAjd2PBTRULc3BpTydabUQEGlaZCzEUQhLoLG7TvEYQ==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz", + "integrity": "sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ==", "dev": true }, "@webassemblyjs/helper-api-error": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.7.10.tgz", - "integrity": "sha512-DoYRlPWtuw3yd5BOr9XhtrmB6X1enYF0/54yNvQWGXZEPDF5PJVNI7zQ7gkcKfTESzp8bIBWailaFXEK/jjCsw==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz", + "integrity": "sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA==", "dev": true }, "@webassemblyjs/helper-buffer": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.7.10.tgz", - "integrity": "sha512-+RMU3dt/dPh4EpVX4u5jxsOlw22tp3zjqE0m3ftU2tsYxnPULb4cyHlgaNd2KoWuwasCQqn8Mhr+TTdbtj3LlA==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz", + "integrity": "sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q==", "dev": true }, "@webassemblyjs/helper-code-frame": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.7.10.tgz", - "integrity": "sha512-UiytbpKAULOEab2hUZK2ywXen4gWJVrgxtwY3Kn+eZaaSWaRM8z/7dAXRSoamhKFiBh1uaqxzE/XD9BLlug3gw==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz", + "integrity": "sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ==", "dev": true, "requires": { - "@webassemblyjs/wast-printer": "1.7.10" + "@webassemblyjs/wast-printer": "1.8.5" } }, "@webassemblyjs/helper-fsm": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.7.10.tgz", - "integrity": "sha512-w2vDtUK9xeSRtt5+RnnlRCI7wHEvLjF0XdnxJpgx+LJOvklTZPqWkuy/NhwHSLP19sm9H8dWxKeReMR7sCkGZA==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz", + "integrity": "sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow==", "dev": true }, "@webassemblyjs/helper-module-context": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.7.10.tgz", - "integrity": "sha512-yE5x/LzZ3XdPdREmJijxzfrf+BDRewvO0zl8kvORgSWmxpRrkqY39KZSq6TSgIWBxkK4SrzlS3BsMCv2s1FpsQ==", - "dev": true + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz", + "integrity": "sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.8.5", + "mamacro": "^0.0.3" + } }, "@webassemblyjs/helper-wasm-bytecode": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.7.10.tgz", - "integrity": "sha512-u5qy4SJ/OrxKxZqJ9N3qH4ZQgHaAzsopsYwLvoWJY6Q33r8PhT3VPyNMaJ7ZFoqzBnZlCcS/0f4Sp8WBxylXfg==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz", + "integrity": "sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ==", "dev": true }, "@webassemblyjs/helper-wasm-section": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.7.10.tgz", - "integrity": "sha512-Ecvww6sCkcjatcyctUrn22neSJHLN/TTzolMGG/N7S9rpbsTZ8c6Bl98GpSpV77EvzNijiNRHBG0+JO99qKz6g==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz", + "integrity": "sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/helper-buffer": "1.7.10", - "@webassemblyjs/helper-wasm-bytecode": "1.7.10", - "@webassemblyjs/wasm-gen": "1.7.10" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-buffer": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/wasm-gen": "1.8.5" } }, "@webassemblyjs/ieee754": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.7.10.tgz", - "integrity": "sha512-HRcWcY+YWt4+s/CvQn+vnSPfRaD4KkuzQFt5MNaELXXHSjelHlSEA8ZcqT69q0GTIuLWZ6JaoKar4yWHVpZHsQ==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz", + "integrity": "sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g==", "dev": true, "requires": { "@xtuc/ieee754": "^1.2.0" } }, "@webassemblyjs/leb128": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.7.10.tgz", - "integrity": "sha512-og8MciYlA8hvzCLR71hCuZKPbVBfLQeHv7ImKZ4nlyxrYbG7uJHYtHiHu6OV9SqrGuD03H/HtXC4Bgdjfm9FHw==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.8.5.tgz", + "integrity": "sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A==", "dev": true, "requires": { - "@xtuc/long": "4.2.1" + "@xtuc/long": "4.2.2" } }, "@webassemblyjs/utf8": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.7.10.tgz", - "integrity": "sha512-Ng6Pxv6siyZp635xCSnH3mKmIFgqWPCcGdoo0GBYgyGdxu7cUj4agV7Uu1a8REP66UYUFXJLudeGgd4RvuJAnQ==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.8.5.tgz", + "integrity": "sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw==", "dev": true }, "@webassemblyjs/wasm-edit": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.7.10.tgz", - "integrity": "sha512-e9RZFQlb+ZuYcKRcW9yl+mqX/Ycj9+3/+ppDI8nEE/NCY6FoK8f3dKBcfubYV/HZn44b+ND4hjh+4BYBt+sDnA==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz", + "integrity": "sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/helper-buffer": "1.7.10", - "@webassemblyjs/helper-wasm-bytecode": "1.7.10", - "@webassemblyjs/helper-wasm-section": "1.7.10", - "@webassemblyjs/wasm-gen": "1.7.10", - "@webassemblyjs/wasm-opt": "1.7.10", - "@webassemblyjs/wasm-parser": "1.7.10", - "@webassemblyjs/wast-printer": "1.7.10" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-buffer": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/helper-wasm-section": "1.8.5", + "@webassemblyjs/wasm-gen": "1.8.5", + "@webassemblyjs/wasm-opt": "1.8.5", + "@webassemblyjs/wasm-parser": "1.8.5", + "@webassemblyjs/wast-printer": "1.8.5" } }, "@webassemblyjs/wasm-gen": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.7.10.tgz", - "integrity": "sha512-M0lb6cO2Y0PzDye/L39PqwV+jvO+2YxEG5ax+7dgq7EwXdAlpOMx1jxyXJTScQoeTpzOPIb+fLgX/IkLF8h2yw==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz", + "integrity": "sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/helper-wasm-bytecode": "1.7.10", - "@webassemblyjs/ieee754": "1.7.10", - "@webassemblyjs/leb128": "1.7.10", - "@webassemblyjs/utf8": "1.7.10" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/ieee754": "1.8.5", + "@webassemblyjs/leb128": "1.8.5", + "@webassemblyjs/utf8": "1.8.5" } }, "@webassemblyjs/wasm-opt": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.7.10.tgz", - "integrity": "sha512-R66IHGCdicgF5ZliN10yn5HaC7vwYAqrSVJGjtJJQp5+QNPBye6heWdVH/at40uh0uoaDN/UVUfXK0gvuUqtVg==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz", + "integrity": "sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/helper-buffer": "1.7.10", - "@webassemblyjs/wasm-gen": "1.7.10", - "@webassemblyjs/wasm-parser": "1.7.10" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-buffer": "1.8.5", + "@webassemblyjs/wasm-gen": "1.8.5", + "@webassemblyjs/wasm-parser": "1.8.5" } }, "@webassemblyjs/wasm-parser": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.7.10.tgz", - "integrity": "sha512-AEv8mkXVK63n/iDR3T693EzoGPnNAwKwT3iHmKJNBrrALAhhEjuPzo/lTE4U7LquEwyvg5nneSNdTdgrBaGJcA==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz", + "integrity": "sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/helper-api-error": "1.7.10", - "@webassemblyjs/helper-wasm-bytecode": "1.7.10", - "@webassemblyjs/ieee754": "1.7.10", - "@webassemblyjs/leb128": "1.7.10", - "@webassemblyjs/utf8": "1.7.10" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-api-error": "1.8.5", + "@webassemblyjs/helper-wasm-bytecode": "1.8.5", + "@webassemblyjs/ieee754": "1.8.5", + "@webassemblyjs/leb128": "1.8.5", + "@webassemblyjs/utf8": "1.8.5" } }, "@webassemblyjs/wast-parser": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.7.10.tgz", - "integrity": "sha512-YTPEtOBljkCL0VjDp4sHe22dAYSm3ZwdJ9+2NTGdtC7ayNvuip1wAhaAS8Zt9Q6SW9E5Jf5PX7YE3XWlrzR9cw==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz", + "integrity": "sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/floating-point-hex-parser": "1.7.10", - "@webassemblyjs/helper-api-error": "1.7.10", - "@webassemblyjs/helper-code-frame": "1.7.10", - "@webassemblyjs/helper-fsm": "1.7.10", - "@xtuc/long": "4.2.1" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/floating-point-hex-parser": "1.8.5", + "@webassemblyjs/helper-api-error": "1.8.5", + "@webassemblyjs/helper-code-frame": "1.8.5", + "@webassemblyjs/helper-fsm": "1.8.5", + "@xtuc/long": "4.2.2" } }, "@webassemblyjs/wast-printer": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.7.10.tgz", - "integrity": "sha512-mJ3QKWtCchL1vhU/kZlJnLPuQZnlDOdZsyP0bbLWPGdYsQDnSBvyTLhzwBA3QAMlzEL9V4JHygEmK6/OTEyytA==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz", + "integrity": "sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/wast-parser": "1.7.10", - "@xtuc/long": "4.2.1" + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/wast-parser": "1.8.5", + "@xtuc/long": "4.2.2" } }, "@xtuc/ieee754": { @@ -2274,9 +2278,9 @@ "dev": true }, "@xtuc/long": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.1.tgz", - "integrity": "sha512-FZdkNBDqBRHKQ2MEbSC17xnPFOhZxeJ2YGSfr2BKf3sujG49Qe3bB+rGCwQfIaA7WHnGeGkSijX4FuBCdrzW/g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, "abab": { @@ -2305,15 +2309,6 @@ "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", "dev": true }, - "acorn-dynamic-import": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-3.0.0.tgz", - "integrity": "sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg==", - "dev": true, - "requires": { - "acorn": "^5.0.0" - } - }, "acorn-globals": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.0.tgz", @@ -4061,9 +4056,9 @@ } }, "bluebird": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.2.tgz", - "integrity": "sha512-dhHTWMI7kMx5whMQntl7Vr9C6BvV10lFXDAasnqnrMYhXVCzzk6IO9Fo2L75jXHT07WrOngL1WDXOp+yYS91Yg==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, "bn.js": { @@ -4339,31 +4334,77 @@ "dev": true }, "cacache": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz", - "integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==", + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.3.tgz", + "integrity": "sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw==", "dev": true, "requires": { - "bluebird": "^3.5.1", - "chownr": "^1.0.1", - "glob": "^7.1.2", - "graceful-fs": "^4.1.11", - "lru-cache": "^4.1.1", - "mississippi": "^2.0.0", + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", "mkdirp": "^0.5.1", "move-concurrently": "^1.0.1", "promise-inflight": "^1.0.1", - "rimraf": "^2.6.2", - "ssri": "^5.2.4", - "unique-filename": "^1.1.0", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", "y18n": "^4.0.0" }, "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true } } }, @@ -4647,15 +4688,15 @@ } }, "chownr": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", - "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz", + "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==", "dev": true }, "chrome-trace-event": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz", - "integrity": "sha512-xDbVgyfDTT2piup/h8dK/y4QZfJRSa73bw1WZ8b4XM1o7fsFubUVGYcE+1ANtOzJJELGpYoG2961z0Z6OAld9A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz", + "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==", "dev": true, "requires": { "tslib": "^1.9.0" @@ -4956,7 +4997,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -5327,9 +5368,9 @@ } }, "cyclist": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", - "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", + "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=", "dev": true }, "d3": { @@ -6050,9 +6091,9 @@ } }, "duplexify": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.6.1.tgz", - "integrity": "sha512-vM58DwdnKmty+FSPzT14K9JXb90H+j5emaR4KYbr2KTIz00WHGbWOe5ghQTx233ZCLZtrGDALzKwcjEtSt35mA==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", "dev": true, "requires": { "end-of-stream": "^1.0.0", @@ -6069,7 +6110,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -7373,6 +7414,12 @@ } } }, + "figgy-pudding": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", + "integrity": "sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w==", + "dev": true + }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", @@ -7488,13 +7535,13 @@ } }, "flush-write-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.0.3.tgz", - "integrity": "sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", "dev": true, "requires": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.4" + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" }, "dependencies": { "isarray": { @@ -7505,7 +7552,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -7649,7 +7696,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -7721,7 +7768,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -7742,12 +7790,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7762,17 +7812,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -7889,7 +7942,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -7901,6 +7955,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -7915,6 +7970,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -7922,12 +7978,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -7946,6 +8004,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -8026,7 +8085,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -8038,6 +8098,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -8123,7 +8184,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -8159,6 +8221,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8178,6 +8241,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -8221,12 +8285,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -8447,9 +8513,9 @@ "dev": true }, "handlebars": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.2.tgz", - "integrity": "sha512-cIv17+GhL8pHHnRJzGu2wwcthL5sb8uDKBHvZ2Dtu5s1YNt0ljbzKbamnc+gr69y7bzwQiBdr5+hOpRd5pnOdg==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", + "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -9269,6 +9335,12 @@ "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", "dev": true }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -11396,9 +11468,9 @@ } }, "loader-runner": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.1.tgz", - "integrity": "sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.4.0.tgz", + "integrity": "sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==", "dev": true }, "loader-utils": { @@ -11587,6 +11659,12 @@ "tmpl": "1.0.x" } }, + "mamacro": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/mamacro/-/mamacro-0.0.3.tgz", + "integrity": "sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA==", + "dev": true + }, "map-age-cleaner": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.2.tgz", @@ -11924,9 +12002,9 @@ "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "mississippi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-2.0.0.tgz", - "integrity": "sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", + "integrity": "sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==", "dev": true, "requires": { "concat-stream": "^1.5.0", @@ -11935,7 +12013,7 @@ "flush-write-stream": "^1.0.0", "from2": "^2.1.0", "parallel-transform": "^1.1.0", - "pump": "^2.0.1", + "pump": "^3.0.0", "pumpify": "^1.3.3", "stream-each": "^1.1.0", "through2": "^2.0.0" @@ -11949,7 +12027,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -11972,19 +12050,19 @@ } }, "through2": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", - "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "dev": true, "requires": { - "readable-stream": "^2.1.5", + "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "xtend": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", - "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true } } @@ -12982,12 +13060,12 @@ "dev": true }, "parallel-transform": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", - "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", "dev": true, "requires": { - "cyclist": "~0.2.2", + "cyclist": "^1.0.1", "inherits": "^2.0.3", "readable-stream": "^2.1.5" }, @@ -13000,7 +13078,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { @@ -13520,9 +13598,9 @@ } }, "pump": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", - "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", "dev": true, "requires": { "end-of-stream": "^1.1.0", @@ -13538,6 +13616,18 @@ "duplexify": "^3.6.0", "inherits": "^2.0.3", "pump": "^2.0.0" + }, + "dependencies": { + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + } } }, "punycode": { @@ -14846,9 +14936,9 @@ } }, "serialize-javascript": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.5.0.tgz", - "integrity": "sha512-Ga8c8NjAAp46Br4+0oZ2WxJCwIzwP60Gq1YPgU+39PiTVxyed/iKE/zyZI6+UlVYH5Q4PaQdHhcegIFPZTUfoQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz", + "integrity": "sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==", "dev": true }, "serve-index": { @@ -15381,12 +15471,12 @@ } }, "ssri": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-5.3.0.tgz", - "integrity": "sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz", + "integrity": "sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==", "dev": true, "requires": { - "safe-buffer": "^5.1.1" + "figgy-pudding": "^3.5.1" } }, "stack-utils": { @@ -15572,9 +15662,9 @@ } }, "stream-shift": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", - "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, "string-length": { @@ -15830,6 +15920,139 @@ "inherits": "2" } }, + "terser": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.2.tgz", + "integrity": "sha512-Uufrsvhj9O1ikwgITGsZ5EZS6qPokUOkCegS7fYOdGTv+OA90vndUbU6PEjr5ePqHfNUbGyMO7xyIZv2MhsALQ==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.12" + }, + "dependencies": { + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "source-map-support": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", + "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + } + } + }, + "terser-webpack-plugin": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz", + "integrity": "sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA==", + "dev": true, + "requires": { + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", + "is-wsl": "^1.1.0", + "schema-utils": "^1.0.0", + "serialize-javascript": "^2.1.2", + "source-map": "^0.6.1", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", + "worker-farm": "^1.7.0" + }, + "dependencies": { + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + } + }, + "p-limit": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.1.tgz", + "integrity": "sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + } + } + }, "test-exclude": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.2.tgz", @@ -16251,74 +16474,6 @@ } } }, - "uglifyjs-webpack-plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.3.0.tgz", - "integrity": "sha512-ovHIch0AMlxjD/97j9AYovZxG5wnHOPkL7T1GKochBADp/Zwc44pEWNqpKl1Loupp1WhFg7SlYmHZRUfdAacgw==", - "dev": true, - "requires": { - "cacache": "^10.0.4", - "find-cache-dir": "^1.0.0", - "schema-utils": "^0.4.5", - "serialize-javascript": "^1.4.0", - "source-map": "^0.6.1", - "uglify-es": "^3.3.4", - "webpack-sources": "^1.1.0", - "worker-farm": "^1.5.2" - }, - "dependencies": { - "ajv": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.4.tgz", - "integrity": "sha512-4Wyjt8+t6YszqaXnLDfMmG/8AlO5Zbcsy3ATHncCzjW/NoPzAId8AK6749Ybjmdt+kUY1gP60fCu46oDxPv/mg==", - "dev": true, - "requires": { - "fast-deep-equal": "^2.0.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "commander": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.13.0.tgz", - "integrity": "sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==", - "dev": true - }, - "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "schema-utils": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", - "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", - "dev": true, - "requires": { - "ajv": "^6.1.0", - "ajv-keywords": "^3.1.0" - } - }, - "uglify-es": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz", - "integrity": "sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ==", - "dev": true, - "requires": { - "commander": "~2.13.0", - "source-map": "~0.6.1" - } - } - } - }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", @@ -16377,9 +16532,9 @@ } }, "unique-slug": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.1.tgz", - "integrity": "sha512-n9cU6+gITaVu7VGj1Z8feKMmfAjEAQGhwD9fE3zvpRRa0wEIx8ODYkVGfSc94M2OX00tUFV8wH3zYbm1I8mxFg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", "dev": true, "requires": { "imurmurhash": "^0.1.4" @@ -16650,41 +16805,46 @@ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" }, "webpack": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.23.1.tgz", - "integrity": "sha512-iE5Cu4rGEDk7ONRjisTOjVHv3dDtcFfwitSxT7evtYj/rANJpt1OuC/Kozh1pBa99AUBr1L/LsaNB+D9Xz3CEg==", + "version": "4.41.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.41.2.tgz", + "integrity": "sha512-Zhw69edTGfbz9/8JJoyRQ/pq8FYUoY0diOXqW0T6yhgdhCv6wr0hra5DwwWexNRns2Z2+gsnrNcbe9hbGBgk/A==", "dev": true, "requires": { - "@webassemblyjs/ast": "1.7.10", - "@webassemblyjs/helper-module-context": "1.7.10", - "@webassemblyjs/wasm-edit": "1.7.10", - "@webassemblyjs/wasm-parser": "1.7.10", - "acorn": "^5.6.2", - "acorn-dynamic-import": "^3.0.0", - "ajv": "^6.1.0", - "ajv-keywords": "^3.1.0", - "chrome-trace-event": "^1.0.0", + "@webassemblyjs/ast": "1.8.5", + "@webassemblyjs/helper-module-context": "1.8.5", + "@webassemblyjs/wasm-edit": "1.8.5", + "@webassemblyjs/wasm-parser": "1.8.5", + "acorn": "^6.2.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^4.1.0", - "eslint-scope": "^4.0.0", + "eslint-scope": "^4.0.3", "json-parse-better-errors": "^1.0.2", - "loader-runner": "^2.3.0", - "loader-utils": "^1.1.0", - "memory-fs": "~0.4.1", - "micromatch": "^3.1.8", - "mkdirp": "~0.5.0", - "neo-async": "^2.5.0", - "node-libs-browser": "^2.0.0", - "schema-utils": "^0.4.4", - "tapable": "^1.1.0", - "uglifyjs-webpack-plugin": "^1.2.4", - "watchpack": "^1.5.0", - "webpack-sources": "^1.3.0" + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.1", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", + "schema-utils": "^1.0.0", + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.1", + "watchpack": "^1.6.0", + "webpack-sources": "^1.4.1" }, "dependencies": { + "acorn": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", + "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", + "dev": true + }, "ajv": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.4.tgz", - "integrity": "sha512-4Wyjt8+t6YszqaXnLDfMmG/8AlO5Zbcsy3ATHncCzjW/NoPzAId8AK6749Ybjmdt+kUY1gP60fCu46oDxPv/mg==", + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", "dev": true, "requires": { "fast-deep-equal": "^2.0.1", @@ -16693,195 +16853,33 @@ "uri-js": "^4.2.2" } }, - "arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "ajv-keywords": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.4.1.tgz", + "integrity": "sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==", "dev": true }, - "array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", "dev": true }, - "braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "eslint-scope": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.3.tgz", + "integrity": "sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==", "dev": true, "requires": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" } }, - "expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", - "dev": true, - "requires": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", - "dev": true, - "requires": { - "is-descriptor": "^0.1.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - }, - "is-accessor-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", - "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-data-descriptor": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", - "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "is-descriptor": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", - "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^0.1.6", - "is-data-descriptor": "^0.1.4", - "kind-of": "^5.0.0" - } - }, - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } - } - }, - "extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", - "dev": true, - "requires": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "dependencies": { - "is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "requires": { - "is-plain-object": "^2.0.4" - } - } - } - }, - "extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "requires": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "dependencies": { - "define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", - "dev": true, - "requires": { - "is-descriptor": "^1.0.0" - } - }, - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } + "events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz", + "integrity": "sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==", + "dev": true }, "fast-deep-equal": { "version": "2.0.1", @@ -16889,82 +16887,10 @@ "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", "dev": true }, - "fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", - "dev": true, - "requires": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "dependencies": { - "extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", - "dev": true, - "requires": { - "is-extendable": "^0.1.0" - } - } - } - }, - "is-accessor-descriptor": { + "isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", - "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-data-descriptor": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", - "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", - "dev": true, - "requires": { - "kind-of": "^6.0.0" - } - }, - "is-descriptor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", - "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", - "dev": true, - "requires": { - "is-accessor-descriptor": "^1.0.0", - "is-data-descriptor": "^1.0.0", - "kind-of": "^6.0.2" - } - }, - "is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", - "dev": true, - "requires": { - "kind-of": "^3.0.2" - }, - "dependencies": { - "kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", - "dev": true, - "requires": { - "is-buffer": "^1.1.5" - } - } - } - }, - "isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, "json-schema-traverse": { @@ -16973,42 +16899,150 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, - "kind-of": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", - "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "loader-utils": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.2.3.tgz", + "integrity": "sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^2.0.0", + "json5": "^1.0.1" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, - "micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", + "dev": true + }, + "node-libs-browser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", + "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==", "dev": true, "requires": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.1", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "^1.0.1" } }, - "schema-utils": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", - "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "requires": { - "ajv": "^6.1.0", - "ajv-keywords": "^3.1.0" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "dev": true + } + } + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + }, + "util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true } } }, @@ -17554,9 +17588,9 @@ } }, "webpack-sources": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", - "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", "dev": true, "requires": { "source-list-map": "^2.0.0", @@ -17637,9 +17671,9 @@ "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=" }, "worker-farm": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.6.0.tgz", - "integrity": "sha512-6w+3tHbM87WnSWnENBUvA2pxJPLhQUg5LKwUQHq3r+XPhIM+Gh2R5ycbwPCyuGbNg+lPgdcnQUhuC02kJCvffQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.7.0.tgz", + "integrity": "sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==", "dev": true, "requires": { "errno": "~0.1.7" diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index 8ae0a4d2be..e6eb2eaaab 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -52,7 +52,7 @@ "react-hot-loader": "^4.3.3", "sass-loader": "^7.1.0", "style-loader": "^0.23.0", - "webpack": "^4.23.1", + "webpack": "^4.41.2", "webpack-cli": "^3.0.8", "webpack-dev-server": "^3.1.14" }, From 230933744c211e27ba9b8eba9330bcedb15d6894 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 16 Dec 2019 08:15:17 -0500 Subject: [PATCH 102/109] Fix nav user links --- .../components/PageHeaderToolbar/PageHeaderToolbar.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx b/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx index d1b88f5c23..1adce89e4e 100644 --- a/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx +++ b/awx/ui_next/src/components/PageHeaderToolbar/PageHeaderToolbar.jsx @@ -111,7 +111,14 @@ class PageHeaderToolbar extends Component { } dropdownItems={[ - + {i18n._(t`User Details`)} , Date: Mon, 16 Dec 2019 08:28:53 -0500 Subject: [PATCH 103/109] Fix resource access list user links --- .../ResourceAccessListItem.jsx | 4 ++-- .../ResourceAccessListItem.test.jsx.snap | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx index bc1b5ccb60..b3cf120056 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessListItem.jsx @@ -79,10 +79,10 @@ class ResourceAccessListItem extends React.Component { {accessRecord.username && ( - {accessRecord.url ? ( + {accessRecord.id ? ( {accessRecord.username} diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap index 6e14ea7633..3abeda4fb2 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap @@ -58,7 +58,7 @@ exports[` initially renders succesfully 1`] = ` @@ -114,7 +114,7 @@ exports[` initially renders succesfully 1`] = ` @@ -193,7 +193,7 @@ exports[` initially renders succesfully 1`] = ` @@ -260,7 +260,7 @@ exports[` initially renders succesfully 1`] = ` @@ -308,7 +308,7 @@ exports[` initially renders succesfully 1`] = ` forwardedRef={null} to={ Object { - "pathname": "/bar", + "pathname": "/users/2/details", } } > @@ -316,18 +316,18 @@ exports[` initially renders succesfully 1`] = ` className="sc-bdVaJa fqQVUT" to={ Object { - "pathname": "/bar", + "pathname": "/users/2/details", } } > jane From 9ed2534ac5a821eb779f7227adcaaab3fe01d722 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 13 Dec 2019 17:30:52 -0500 Subject: [PATCH 104/109] Don't reload panel on tab change --- awx/ui_next/src/screens/Template/Template.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx index 688c63018a..8f65007f16 100644 --- a/awx/ui_next/src/screens/Template/Template.jsx +++ b/awx/ui_next/src/screens/Template/Template.jsx @@ -120,7 +120,7 @@ class Template extends Component { tab.id = n; }); - let cardHeader = hasContentLoading ? null : ( + let cardHeader = ( From 014520ee2bfdde6fb96775ac9a4788198977b887 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 11 Dec 2019 08:41:05 -0500 Subject: [PATCH 105/109] Initialize list with none selected --- .../src/screens/Inventory/InventoryList/InventoryList.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index 59e63a9b35..7fb42eb4bc 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -146,7 +146,8 @@ class InventoriesList extends Component { const { match, i18n } = this.props; const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = selected.length === inventories.length; + const isAllSelected = + selected.length === inventories.length && selected.length !== 0; const addButton = ( Date: Mon, 16 Dec 2019 13:43:42 -0500 Subject: [PATCH 106/109] Prevent running jobs from blocking project updates A running job that has a project update will block that update from running. This fix removes the block. Adds a functional test that sets up a job in "running" state, and starts a project update that is in "pending" state. Assert that the task manager and dependency graph .is_job_blocked methods both return False. issue #5153 --- awx/main/scheduler/dependency_graph.py | 9 ++----- .../task_management/test_scheduler.py | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/awx/main/scheduler/dependency_graph.py b/awx/main/scheduler/dependency_graph.py index bbe2e71eba..2d180bbced 100644 --- a/awx/main/scheduler/dependency_graph.py +++ b/awx/main/scheduler/dependency_graph.py @@ -15,7 +15,6 @@ class DependencyGraph(object): INVENTORY_UPDATES = 'inventory_updates' JOB_TEMPLATE_JOBS = 'job_template_jobs' - JOB_PROJECT_IDS = 'job_project_ids' JOB_INVENTORY_IDS = 'job_inventory_ids' SYSTEM_JOB = 'system_job' @@ -41,8 +40,6 @@ class DependencyGraph(object): Track runnable job related project and inventory to ensure updates don't run while a job needing those resources is running. ''' - # project_id -> True / False - self.data[self.JOB_PROJECT_IDS] = {} # inventory_id -> True / False self.data[self.JOB_INVENTORY_IDS] = {} @@ -66,7 +63,7 @@ class DependencyGraph(object): def get_now(self): return tz_now() - + def mark_system_job(self): self.data[self.SYSTEM_JOB] = False @@ -81,15 +78,13 @@ class DependencyGraph(object): def mark_job_template_job(self, job): self.data[self.JOB_INVENTORY_IDS][job.inventory_id] = False - self.data[self.JOB_PROJECT_IDS][job.project_id] = False self.data[self.JOB_TEMPLATE_JOBS][job.job_template_id] = False def mark_workflow_job(self, job): self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS][job.workflow_job_template_id] = False def can_project_update_run(self, job): - return self.data[self.JOB_PROJECT_IDS].get(job.project_id, True) and \ - self.data[self.PROJECT_UPDATES].get(job.project_id, True) + return self.data[self.PROJECT_UPDATES].get(job.project_id, True) def can_inventory_update_run(self, job): return self.data[self.JOB_INVENTORY_IDS].get(job.inventory_source.inventory_id, True) and \ diff --git a/awx/main/tests/functional/task_management/test_scheduler.py b/awx/main/tests/functional/task_management/test_scheduler.py index 6a554380be..f76e998355 100644 --- a/awx/main/tests/functional/task_management/test_scheduler.py +++ b/awx/main/tests/functional/task_management/test_scheduler.py @@ -4,6 +4,7 @@ import json from datetime import timedelta from awx.main.scheduler import TaskManager +from awx.main.scheduler.dependency_graph import DependencyGraph from awx.main.utils import encrypt_field from awx.main.models import WorkflowJobTemplate, JobTemplate @@ -326,3 +327,29 @@ def test_shared_dependencies_launch(default_instance_group, job_template_factory iu = [x for x in ii.inventory_updates.all()] assert len(pu) == 1 assert len(iu) == 1 + + +@pytest.mark.django_db +def test_job_not_blocking_project_update(default_instance_group, job_template_factory): + objects = job_template_factory('jt', organization='org1', project='proj', + inventory='inv', credential='cred', + jobs=["job"]) + job = objects.jobs["job"] + job.instance_group = default_instance_group + job.status = "running" + job.save() + + with mock.patch("awx.main.scheduler.TaskManager.start_task"): + task_manager = TaskManager() + task_manager._schedule() + + proj = objects.project + project_update = proj.create_project_update() + project_update.instance_group = default_instance_group + project_update.status = "pending" + project_update.save() + assert not task_manager.is_job_blocked(project_update) + + dependency_graph = DependencyGraph(None) + dependency_graph.add_job(job) + assert not dependency_graph.is_job_blocked(project_update) From b23856f126d4156ca3120635b2718cdeb55f0074 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Tue, 17 Dec 2019 11:38:32 -0500 Subject: [PATCH 107/109] Bump VERSION to 9.1.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 37ad5c8b19..47da986f86 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.0.1 +9.1.0 From bd8643d59999d661ef14fee90f0fb45c0d690e6c Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Tue, 17 Dec 2019 13:39:49 -0500 Subject: [PATCH 108/109] Set default value for create_preload_data in image_build role This caused our AWX release workflow to blow up --- installer/roles/image_build/defaults/main.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 installer/roles/image_build/defaults/main.yml diff --git a/installer/roles/image_build/defaults/main.yml b/installer/roles/image_build/defaults/main.yml new file mode 100644 index 0000000000..3b56dcd4e4 --- /dev/null +++ b/installer/roles/image_build/defaults/main.yml @@ -0,0 +1,2 @@ +--- +create_preload_data: true From 3c0fd37a4d2b0c158dd2e91715ce02f3b3ec5133 Mon Sep 17 00:00:00 2001 From: Graham Mainwaring Date: Tue, 17 Dec 2019 14:39:35 -0500 Subject: [PATCH 109/109] Set a default value for LOGIN_REDIRECT_URL --- awx/api/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/conf.py b/awx/api/conf.py index b2cb2a641c..493eed6981 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -67,6 +67,7 @@ register( field_class=fields.CharField, allow_blank=True, required=False, + default='', label=_('Login redirect override URL'), help_text=_('URL to which unauthorized users will be redirected to log in. ' 'If blank, users will be sent to the Tower login page.'),