From 326d12382ff9d488b24874df34f5b202e61bd9e3 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 14 Feb 2022 15:14:08 -0500 Subject: [PATCH] Adds Inventory labels (#11558) * Adds inventory labels end point * Adds label field to inventory form --- awx/api/serializers.py | 39 ++++++++------- awx/api/urls/inventory.py | 2 + awx/api/views/__init__.py | 1 + awx/api/views/inventory.py | 49 +++++++++++++++---- awx/main/access.py | 7 ++- awx/main/migrations/0157_inventory_labels.py | 18 +++++++ awx/main/models/inventory.py | 6 +++ awx/main/models/label.py | 12 ++--- awx/main/tests/unit/models/test_label.py | 33 ++++++++----- awx/ui/src/api/models/Inventories.js | 14 ++++++ .../LabelSelect}/LabelSelect.js | 2 +- .../LabelSelect}/LabelSelect.test.js | 2 +- awx/ui/src/components/LabelSelect/index.js | 1 + .../Inventory/InventoryAdd/InventoryAdd.js | 10 ++++ .../InventoryAdd/InventoryAdd.test.js | 10 +++- .../InventoryDetail/InventoryDetail.js | 19 +++++++ .../Inventory/InventoryEdit/InventoryEdit.js | 22 +++++++++ .../InventoryEdit/InventoryEdit.test.js | 36 +++++++++++++- .../screens/Inventory/shared/InventoryForm.js | 33 +++++++++++-- .../Inventory/shared/InventoryForm.test.js | 23 ++++++++- .../Template/shared/JobTemplateForm.js | 2 +- .../shared/WorkflowJobTemplateForm.js | 2 +- 22 files changed, 285 insertions(+), 58 deletions(-) create mode 100644 awx/main/migrations/0157_inventory_labels.py rename awx/ui/src/{screens/Template/shared => components/LabelSelect}/LabelSelect.js (98%) rename awx/ui/src/{screens/Template/shared => components/LabelSelect}/LabelSelect.test.js (98%) create mode 100644 awx/ui/src/components/LabelSelect/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b03be324a0..ff8e654f55 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1643,7 +1643,25 @@ class BaseSerializerWithVariables(BaseSerializer): return vars_validate_or_raise(value) -class InventorySerializer(BaseSerializerWithVariables): +class LabelsListMixin(object): + def _summary_field_labels(self, obj): + label_list = [{'id': x.id, 'name': x.name} for x in obj.labels.all()[:10]] + if has_model_field_prefetched(obj, 'labels'): + label_ct = len(obj.labels.all()) + else: + if len(label_list) < 10: + label_ct = len(label_list) + else: + label_ct = obj.labels.count() + return {'count': label_ct, 'results': label_list} + + def get_summary_fields(self, obj): + res = super(LabelsListMixin, self).get_summary_fields(obj) + res['labels'] = self._summary_field_labels(obj) + return res + + +class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): show_capabilities = ['edit', 'delete', 'adhoc', 'copy'] capabilities_prefetch = ['admin', 'adhoc', {'copy': 'organization.inventory_admin'}] @@ -1684,6 +1702,7 @@ class InventorySerializer(BaseSerializerWithVariables): object_roles=self.reverse('api:inventory_object_roles_list', kwargs={'pk': obj.pk}), instance_groups=self.reverse('api:inventory_instance_groups_list', kwargs={'pk': obj.pk}), copy=self.reverse('api:inventory_copy', kwargs={'pk': obj.pk}), + labels=self.reverse('api:inventory_label_list', kwargs={'pk': obj.pk}), ) ) if obj.organization: @@ -2753,24 +2772,6 @@ class OrganizationCredentialSerializerCreate(CredentialSerializerCreate): fields = ('*', '-user', '-team') -class LabelsListMixin(object): - def _summary_field_labels(self, obj): - label_list = [{'id': x.id, 'name': x.name} for x in obj.labels.all()[:10]] - if has_model_field_prefetched(obj, 'labels'): - label_ct = len(obj.labels.all()) - else: - if len(label_list) < 10: - label_ct = len(label_list) - else: - label_ct = obj.labels.count() - return {'count': label_ct, 'results': label_list} - - def get_summary_fields(self, obj): - res = super(LabelsListMixin, self).get_summary_fields(obj) - res['labels'] = self._summary_field_labels(obj) - return res - - class JobOptionsSerializer(LabelsListMixin, BaseSerializer): class Meta: fields = ( diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index c2f67ab457..d323be9450 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -20,6 +20,7 @@ from awx.api.views import ( InventoryAccessList, InventoryObjectRolesList, InventoryInstanceGroupsList, + InventoryLabelList, InventoryCopy, ) @@ -41,6 +42,7 @@ urls = [ url(r'^(?P[0-9]+)/access_list/$', InventoryAccessList.as_view(), name='inventory_access_list'), url(r'^(?P[0-9]+)/object_roles/$', InventoryObjectRolesList.as_view(), name='inventory_object_roles_list'), url(r'^(?P[0-9]+)/instance_groups/$', InventoryInstanceGroupsList.as_view(), name='inventory_instance_groups_list'), + url(r'^(?P[0-9]+)/labels/$', InventoryLabelList.as_view(), name='inventory_label_list'), url(r'^(?P[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'), ] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 7993a25de6..51ab4c9dd2 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -157,6 +157,7 @@ from awx.api.views.inventory import ( # noqa InventoryAccessList, InventoryObjectRolesList, InventoryJobTemplateList, + InventoryLabelList, InventoryCopy, ) from awx.api.views.mesh_visualizer import MeshVisualizer # noqa diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 7a46ce3511..dfa7204f80 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -16,17 +16,21 @@ from rest_framework.response import Response from rest_framework import status # AWX -from awx.main.models import ( - ActivityStream, - Inventory, - JobTemplate, - Role, - User, - InstanceGroup, - InventoryUpdateEvent, - InventoryUpdate, +from awx.main.models import ActivityStream, Inventory, JobTemplate, Role, User, InstanceGroup, InventoryUpdateEvent, InventoryUpdate + +from awx.main.models.label import Label + +from awx.api.generics import ( + ListCreateAPIView, + RetrieveUpdateDestroyAPIView, + SubListAPIView, + SubListAttachDetachAPIView, + ResourceAccessList, + CopyAPIView, + DeleteLastUnattachLabelMixin, + SubListCreateAttachDetachAPIView, ) -from awx.api.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView, SubListAPIView, SubListAttachDetachAPIView, ResourceAccessList, CopyAPIView + from awx.api.serializers import ( InventorySerializer, @@ -35,6 +39,7 @@ from awx.api.serializers import ( InstanceGroupSerializer, InventoryUpdateEventSerializer, JobTemplateSerializer, + LabelSerializer, ) from awx.api.views.mixin import RelatedJobsPreventDeleteMixin, ControlledByScmMixin @@ -152,6 +157,30 @@ class InventoryJobTemplateList(SubListAPIView): return qs.filter(inventory=parent) +class InventoryLabelList(DeleteLastUnattachLabelMixin, SubListCreateAttachDetachAPIView, SubListAPIView): + + model = Label + serializer_class = LabelSerializer + parent_model = Inventory + relationship = 'labels' + + def post(self, request, *args, **kwargs): + # If a label already exists in the database, attach it instead of erroring out + # that it already exists + if 'id' not in request.data and 'name' in request.data and 'organization' in request.data: + existing = Label.objects.filter(name=request.data['name'], organization_id=request.data['organization']) + if existing.exists(): + existing = existing[0] + request.data['id'] = existing.id + del request.data['name'] + del request.data['organization'] + if Label.objects.filter(inventory_labels=self.kwargs['pk']).count() > 100: + return Response( + dict(msg=_('Maximum number of labels for {} reached.'.format(self.parent_model._meta.verbose_name_raw))), status=status.HTTP_400_BAD_REQUEST + ) + return super(InventoryLabelList, self).post(request, *args, **kwargs) + + class InventoryCopy(CopyAPIView): model = Inventory diff --git a/awx/main/access.py b/awx/main/access.py index c256aa63a3..06b560b9ae 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -853,7 +853,12 @@ class InventoryAccess(BaseAccess): """ model = Inventory - prefetch_related = ('created_by', 'modified_by', 'organization') + prefetch_related = ( + 'created_by', + 'modified_by', + 'organization', + Prefetch('labels', queryset=Label.objects.all().order_by('name')), + ) def filtered_queryset(self, allowed=None, ad_hoc=None): return self.model.accessible_objects(self.user, 'read_role') diff --git a/awx/main/migrations/0157_inventory_labels.py b/awx/main/migrations/0157_inventory_labels.py new file mode 100644 index 0000000000..f121ba6b2c --- /dev/null +++ b/awx/main/migrations/0157_inventory_labels.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2022-01-18 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0156_capture_mesh_topology'), + ] + + operations = [ + migrations.AddField( + model_name='inventory', + name='labels', + field=models.ManyToManyField(blank=True, help_text='Labels associated with this inventory.', related_name='inventory_labels', to='main.Label'), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 50d50cc005..0cac6602e0 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -170,6 +170,12 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): editable=False, help_text=_('Flag indicating the inventory is being deleted.'), ) + labels = models.ManyToManyField( + "Label", + blank=True, + related_name='inventory_labels', + help_text=_('Labels associated with this inventory.'), + ) def get_absolute_url(self, request=None): return reverse('api:inventory_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/models/label.py b/awx/main/models/label.py index 2a3d26776d..18bdb2b025 100644 --- a/awx/main/models/label.py +++ b/awx/main/models/label.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from awx.api.versioning import reverse from awx.main.models.base import CommonModelNameNotUnique from awx.main.models.unified_jobs import UnifiedJobTemplate, UnifiedJob +from awx.main.models.inventory import Inventory __all__ = ('Label',) @@ -35,15 +36,14 @@ class Label(CommonModelNameNotUnique): @staticmethod def get_orphaned_labels(): - return Label.objects.filter(organization=None, unifiedjobtemplate_labels__isnull=True) + return Label.objects.filter(organization=None, unifiedjobtemplate_labels__isnull=True, inventory_labels__isnull=True) def is_detached(self): - return bool(Label.objects.filter(id=self.id, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True).count()) + return Label.objects.filter(id=self.id, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True, inventory_labels__isnull=True).exists() def is_candidate_for_detach(self): + c1 = UnifiedJob.objects.filter(labels__in=[self.id]).count() c2 = UnifiedJobTemplate.objects.filter(labels__in=[self.id]).count() - if (c1 + c2 - 1) == 0: - return True - else: - return False + c3 = Inventory.objects.filter(labels__in=[self.id]).count() + return (c1 + c2 + c3 - 1) == 0 diff --git a/awx/main/tests/unit/models/test_label.py b/awx/main/tests/unit/models/test_label.py index c9565bf55e..0d5b5b76c0 100644 --- a/awx/main/tests/unit/models/test_label.py +++ b/awx/main/tests/unit/models/test_label.py @@ -3,6 +3,7 @@ from unittest import mock from awx.main.models.label import Label from awx.main.models.unified_jobs import UnifiedJobTemplate, UnifiedJob +from awx.main.models.inventory import Inventory mock_query_set = mock.MagicMock() @@ -10,43 +11,45 @@ mock_query_set = mock.MagicMock() mock_objects = mock.MagicMock(filter=mock.MagicMock(return_value=mock_query_set)) +@pytest.mark.django_db @mock.patch('awx.main.models.label.Label.objects', mock_objects) class TestLabelFilterMocked: def test_get_orphaned_labels(self, mocker): ret = Label.get_orphaned_labels() assert mock_query_set == ret - Label.objects.filter.assert_called_with(organization=None, unifiedjobtemplate_labels__isnull=True) + Label.objects.filter.assert_called_with(organization=None, unifiedjobtemplate_labels__isnull=True, inventory_labels__isnull=True) def test_is_detached(self, mocker): - mock_query_set.count.return_value = 1 + mock_query_set.exists.return_value = True label = Label(id=37) ret = label.is_detached() assert ret is True - Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True) - mock_query_set.count.assert_called_with() + Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True, inventory_labels__isnull=True) + mock_query_set.exists.assert_called_with() def test_is_detached_not(self, mocker): - mock_query_set.count.return_value = 0 + mock_query_set.exists.return_value = False label = Label(id=37) ret = label.is_detached() assert ret is False - Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True) - mock_query_set.count.assert_called_with() + Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True, inventory_labels__isnull=True) + mock_query_set.exists.assert_called_with() @pytest.mark.parametrize( - "jt_count,j_count,expected", + "jt_count,j_count,inv_count,expected", [ - (1, 0, True), - (0, 1, True), - (1, 1, False), + (1, 0, 0, True), + (0, 1, 0, True), + (0, 0, 1, True), + (1, 1, 1, False), ], ) - def test_is_candidate_for_detach(self, mocker, jt_count, j_count, expected): + def test_is_candidate_for_detach(self, mocker, jt_count, j_count, inv_count, expected): mock_job_qs = mocker.MagicMock() mock_job_qs.count = mocker.MagicMock(return_value=j_count) mocker.patch.object(UnifiedJob, 'objects', mocker.MagicMock(filter=mocker.MagicMock(return_value=mock_job_qs))) @@ -55,12 +58,18 @@ class TestLabelFilterMocked: mock_jt_qs.count = mocker.MagicMock(return_value=jt_count) mocker.patch.object(UnifiedJobTemplate, 'objects', mocker.MagicMock(filter=mocker.MagicMock(return_value=mock_jt_qs))) + mock_inv_qs = mocker.MagicMock() + mock_inv_qs.count = mocker.MagicMock(return_value=inv_count) + mocker.patch.object(Inventory, 'objects', mocker.MagicMock(filter=mocker.MagicMock(return_value=mock_inv_qs))) + label = Label(id=37) ret = label.is_candidate_for_detach() UnifiedJob.objects.filter.assert_called_with(labels__in=[label.id]) UnifiedJobTemplate.objects.filter.assert_called_with(labels__in=[label.id]) + Inventory.objects.filter.assert_called_with(labels__in=[label.id]) mock_job_qs.count.assert_called_with() mock_jt_qs.count.assert_called_with() + mock_inv_qs.count.assert_called_with() assert ret is expected diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index d7bd16efce..fd1653045f 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -116,6 +116,20 @@ class Inventories extends InstanceGroupsMixin(Base) { values ); } + + associateLabel(id, label, orgId) { + return this.http.post(`${this.baseUrl}${id}/labels/`, { + name: label.name, + organization: orgId, + }); + } + + disassociateLabel(id, label) { + return this.http.post(`${this.baseUrl}${id}/labels/`, { + id: label.id, + disassociate: true, + }); + } } export default Inventories; diff --git a/awx/ui/src/screens/Template/shared/LabelSelect.js b/awx/ui/src/components/LabelSelect/LabelSelect.js similarity index 98% rename from awx/ui/src/screens/Template/shared/LabelSelect.js rename to awx/ui/src/components/LabelSelect/LabelSelect.js index ca23fab987..f7c93ffccd 100644 --- a/awx/ui/src/screens/Template/shared/LabelSelect.js +++ b/awx/ui/src/components/LabelSelect/LabelSelect.js @@ -3,8 +3,8 @@ import { func, arrayOf, number, shape, string, oneOfType } from 'prop-types'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { t } from '@lingui/macro'; import { LabelsAPI } from 'api'; -import { useSyncedSelectValue } from 'components/MultiSelect'; import useIsMounted from 'hooks/useIsMounted'; +import { useSyncedSelectValue } from '../MultiSelect'; async function loadLabelOptions(setLabels, onError, isMounted) { if (!isMounted.current) { diff --git a/awx/ui/src/screens/Template/shared/LabelSelect.test.js b/awx/ui/src/components/LabelSelect/LabelSelect.test.js similarity index 98% rename from awx/ui/src/screens/Template/shared/LabelSelect.test.js rename to awx/ui/src/components/LabelSelect/LabelSelect.test.js index dc7f8066df..6e45148abd 100644 --- a/awx/ui/src/screens/Template/shared/LabelSelect.test.js +++ b/awx/ui/src/components/LabelSelect/LabelSelect.test.js @@ -4,7 +4,7 @@ import { mount } from 'enzyme'; import { LabelsAPI } from 'api'; import LabelSelect from './LabelSelect'; -jest.mock('../../../api'); +jest.mock('../../api'); const options = [ { id: 1, name: 'one' }, diff --git a/awx/ui/src/components/LabelSelect/index.js b/awx/ui/src/components/LabelSelect/index.js new file mode 100644 index 0000000000..6c25c35d86 --- /dev/null +++ b/awx/ui/src/components/LabelSelect/index.js @@ -0,0 +1 @@ +export { default } from './LabelSelect'; diff --git a/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.js b/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.js index 2ca9b0a38c..edbfd54f5f 100644 --- a/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.js +++ b/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.js @@ -14,6 +14,14 @@ function InventoryAdd() { history.push('/inventories'); }; + async function submitLabels(inventoryId, orgId, labels = []) { + const associationPromises = labels.map((label) => + InventoriesAPI.associateLabel(inventoryId, label, orgId) + ); + + return Promise.all([...associationPromises]); + } + const handleSubmit = async (values) => { const { instanceGroups, organization, ...remainingValues } = values; try { @@ -25,6 +33,8 @@ function InventoryAdd() { }); /* eslint-disable no-await-in-loop, no-restricted-syntax */ // Resolve Promises sequentially to maintain order and avoid race condition + + await submitLabels(inventoryId, values.organization?.id, values.labels); for (const group of instanceGroups) { await InventoriesAPI.associateInstanceGroup(inventoryId, group.id); } diff --git a/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.test.js b/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.test.js index 3efbae7ff3..be66e131db 100644 --- a/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.test.js +++ b/awx/ui/src/screens/Inventory/InventoryAdd/InventoryAdd.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { InventoriesAPI } from 'api'; +import { LabelsAPI, InventoriesAPI } from 'api'; import { mountWithContexts, waitForElement, @@ -17,6 +17,7 @@ describe('', () => { beforeEach(async () => { history = createMemoryHistory({ initialEntries: ['/inventories'] }); + LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); InventoriesAPI.create.mockResolvedValue({ data: { id: 13 } }); await act(async () => { wrapper = mountWithContexts(, { @@ -40,12 +41,19 @@ describe('', () => { name: 'new Foo', organization: { id: 2 }, instanceGroups, + labels: [{ name: 'label' }], }); }); expect(InventoriesAPI.create).toHaveBeenCalledWith({ name: 'new Foo', organization: 2, + labels: [{ name: 'label' }], }); + expect(InventoriesAPI.associateLabel).toBeCalledWith( + 13, + { name: 'label' }, + 2 + ); instanceGroups.map((IG) => expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith( 13, diff --git a/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js b/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js index f239fb6df9..03691e690f 100644 --- a/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js +++ b/awx/ui/src/screens/Inventory/InventoryDetail/InventoryDetail.js @@ -101,6 +101,25 @@ function InventoryDetail({ inventory }) { } /> )} + {inventory.summary_fields.labels && + inventory.summary_fields.labels?.results?.length > 0 && ( + + {inventory.summary_fields.labels.results.map((l) => ( + + {l.name} + + ))} + + } + /> + )} -1 @@ -69,6 +71,26 @@ function InventoryEdit({ inventory }) { } }; + const submitLabels = async (orgId, labels = []) => { + const { added, removed } = getAddedAndRemoved( + inventory.summary_fields.labels.results, + labels + ); + + const disassociationPromises = removed.map((label) => + InventoriesAPI.disassociateLabel(inventory.id, label) + ); + const associationPromises = added.map((label) => + InventoriesAPI.associateLabel(inventory.id, label, orgId) + ); + + const results = await Promise.all([ + ...disassociationPromises, + ...associationPromises, + ]); + return results; + }; + if (contentLoading) { return ; } diff --git a/awx/ui/src/screens/Inventory/InventoryEdit/InventoryEdit.test.js b/awx/ui/src/screens/Inventory/InventoryEdit/InventoryEdit.test.js index c27a9115e3..ad76ceedd6 100644 --- a/awx/ui/src/screens/Inventory/InventoryEdit/InventoryEdit.test.js +++ b/awx/ui/src/screens/Inventory/InventoryEdit/InventoryEdit.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; -import { InventoriesAPI } from 'api'; +import { LabelsAPI, InventoriesAPI } from 'api'; import { mountWithContexts, waitForElement, @@ -27,6 +27,12 @@ const mockInventory = { copy: true, adhoc: true, }, + labels: { + results: [ + { name: 'Sushi', id: 1 }, + { name: 'Major', id: 2 }, + ], + }, }, created: '2019-10-04T16:56:48.025455Z', modified: '2019-10-04T16:56:48.025468Z', @@ -59,6 +65,15 @@ describe('', () => { let history; beforeEach(async () => { + LabelsAPI.read.mockResolvedValue({ + data: { + results: [ + { name: 'Sushi', id: 1 }, + { name: 'Major', id: 2 }, + ], + }, + }); + InventoriesAPI.readInstanceGroups.mockResolvedValue({ data: { results: associatedInstanceGroups, @@ -96,14 +111,33 @@ describe('', () => { { name: 'Bizz', id: 2 }, { name: 'Buzz', id: 3 }, ]; + const labels = [{ name: 'label' }, { name: 'Major', id: 2 }]; await act(async () => { wrapper.find('InventoryForm').prop('onSubmit')({ name: 'Foo', id: 13, organization: { id: 1 }, instanceGroups, + labels, }); }); + + expect(InventoriesAPI.update).toHaveBeenCalledWith(1, { + id: 13, + labels: [{ name: 'label' }, { name: 'Major', id: 2 }], + name: 'Foo', + organization: 1, + }); + + expect(InventoriesAPI.associateLabel).toBeCalledWith( + 1, + { name: 'label' }, + 1 + ); + expect(InventoriesAPI.disassociateLabel).toBeCalledWith(1, { + name: 'Sushi', + id: 1, + }); expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledWith( mockInventory.id, instanceGroups, diff --git a/awx/ui/src/screens/Inventory/shared/InventoryForm.js b/awx/ui/src/screens/Inventory/shared/InventoryForm.js index 5d837af139..25ada203a9 100644 --- a/awx/ui/src/screens/Inventory/shared/InventoryForm.js +++ b/awx/ui/src/screens/Inventory/shared/InventoryForm.js @@ -1,22 +1,27 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { t } from '@lingui/macro'; import { func, shape } from 'prop-types'; -import { Form } from '@patternfly/react-core'; +import { Form, FormGroup } from '@patternfly/react-core'; import { VariablesField } from 'components/CodeEditor'; +import Popover from 'components/Popover'; import FormField, { FormSubmitError } from 'components/FormField'; import FormActionGroup from 'components/FormActionGroup'; import { required } from 'util/validators'; +import LabelSelect from 'components/LabelSelect'; import InstanceGroupsLookup from 'components/Lookup/InstanceGroupsLookup'; import OrganizationLookup from 'components/Lookup/OrganizationLookup'; +import ContentError from 'components/ContentError'; import { FormColumnLayout, FormFullWidthLayout } from 'components/FormLayout'; function InventoryFormFields({ inventory }) { + const [contentError, setContentError] = useState(false); const { setFieldValue, setFieldTouched } = useFormikContext(); const [organizationField, organizationMeta, organizationHelpers] = useField('organization'); const [instanceGroupsField, , instanceGroupsHelpers] = useField('instanceGroups'); + const [labelsField, , labelsHelpers] = useField('labels'); const handleOrganizationUpdate = useCallback( (value) => { setFieldValue('organization', value); @@ -25,6 +30,10 @@ function InventoryFormFields({ inventory }) { [setFieldValue, setFieldTouched] ); + if (contentError) { + return ; + } + return ( <> + + } + fieldId="inventory-labels" + > + labelsHelpers.setValue(labels)} + onError={setContentError} + createText={t`Create`} + /> + ', () => { beforeAll(async () => { onCancel = jest.fn(); onSubmit = jest.fn(); + LabelsAPI.read.mockReturnValue({ + data: inventory.summary_fields.labels, + }); + await act(async () => { wrapper = mountWithContexts( ', () => { expect(wrapper.length).toBe(1); }); - test('should display form fields properly', () => { + test('should display form fields properly', async () => { + await waitForElement(wrapper, 'InventoryForm', (el) => el.length > 0); + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); @@ -115,4 +128,12 @@ describe('', () => { wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); expect(onCancel).toBeCalled(); }); + + test('should render LabelsSelect', async () => { + const select = wrapper.find('LabelSelect'); + expect(select).toHaveLength(1); + expect(select.prop('value')).toEqual( + inventory.summary_fields.labels.results + ); + }); }); diff --git a/awx/ui/src/screens/Template/shared/JobTemplateForm.js b/awx/ui/src/screens/Template/shared/JobTemplateForm.js index ffd7585b64..5c15d3adb1 100644 --- a/awx/ui/src/screens/Template/shared/JobTemplateForm.js +++ b/awx/ui/src/screens/Template/shared/JobTemplateForm.js @@ -42,7 +42,7 @@ import { import Popover from 'components/Popover'; import { JobTemplatesAPI } from 'api'; import useIsMounted from 'hooks/useIsMounted'; -import LabelSelect from './LabelSelect'; +import LabelSelect from 'components/LabelSelect'; import PlaybookSelect from './PlaybookSelect'; import WebhookSubForm from './WebhookSubForm'; diff --git a/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js b/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js index 0c427629e1..960b3172be 100644 --- a/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js +++ b/awx/ui/src/screens/Template/shared/WorkflowJobTemplateForm.js @@ -26,7 +26,7 @@ import ContentError from 'components/ContentError'; import CheckboxField from 'components/FormField/CheckboxField'; import Popover from 'components/Popover'; import { WorkFlowJobTemplate } from 'types'; -import LabelSelect from './LabelSelect'; +import LabelSelect from 'components/LabelSelect'; import WebhookSubForm from './WebhookSubForm'; const urlOrigin = window.location.origin;