mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 10:00:01 -03:30
Adds Inventory labels (#11558)
* Adds inventory labels end point * Adds label field to inventory form
This commit is contained in:
parent
1de9dddd21
commit
326d12382f
@ -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 = (
|
||||
|
||||
@ -20,6 +20,7 @@ from awx.api.views import (
|
||||
InventoryAccessList,
|
||||
InventoryObjectRolesList,
|
||||
InventoryInstanceGroupsList,
|
||||
InventoryLabelList,
|
||||
InventoryCopy,
|
||||
)
|
||||
|
||||
@ -41,6 +42,7 @@ urls = [
|
||||
url(r'^(?P<pk>[0-9]+)/access_list/$', InventoryAccessList.as_view(), name='inventory_access_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/object_roles/$', InventoryObjectRolesList.as_view(), name='inventory_object_roles_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/instance_groups/$', InventoryInstanceGroupsList.as_view(), name='inventory_instance_groups_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/labels/$', InventoryLabelList.as_view(), name='inventory_label_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'),
|
||||
]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
18
awx/main/migrations/0157_inventory_labels.py
Normal file
18
awx/main/migrations/0157_inventory_labels.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
@ -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' },
|
||||
1
awx/ui/src/components/LabelSelect/index.js
Normal file
1
awx/ui/src/components/LabelSelect/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './LabelSelect';
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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('<InventoryAdd />', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
history = createMemoryHistory({ initialEntries: ['/inventories'] });
|
||||
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
|
||||
InventoriesAPI.create.mockResolvedValue({ data: { id: 13 } });
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InventoryAdd />, {
|
||||
@ -40,12 +41,19 @@ describe('<InventoryAdd />', () => {
|
||||
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,
|
||||
|
||||
@ -101,6 +101,25 @@ function InventoryDetail({ inventory }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inventory.summary_fields.labels &&
|
||||
inventory.summary_fields.labels?.results?.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={t`Labels`}
|
||||
value={
|
||||
<ChipGroup
|
||||
numChips={5}
|
||||
totalChips={inventory.summary_fields.labels.results.length}
|
||||
>
|
||||
{inventory.summary_fields.labels.results.map((l) => (
|
||||
<Chip key={l.id} isReadOnly>
|
||||
{l.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<VariablesDetail
|
||||
label={t`Variables`}
|
||||
value={inventory.variables}
|
||||
|
||||
@ -4,6 +4,7 @@ import { object } from 'prop-types';
|
||||
|
||||
import { CardBody } from 'components/Card';
|
||||
import { InventoriesAPI } from 'api';
|
||||
import { getAddedAndRemoved } from 'util/lists';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import useIsMounted from 'hooks/useIsMounted';
|
||||
import InventoryForm from '../shared/InventoryForm';
|
||||
@ -58,6 +59,7 @@ function InventoryEdit({ inventory }) {
|
||||
instanceGroups,
|
||||
associatedInstanceGroups
|
||||
);
|
||||
await submitLabels(values.organization.id, values.labels);
|
||||
|
||||
const url =
|
||||
history.location.pathname.search('smart') > -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 <ContentLoading />;
|
||||
}
|
||||
|
||||
@ -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('<InventoryEdit />', () => {
|
||||
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('<InventoryEdit />', () => {
|
||||
{ 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,
|
||||
|
||||
@ -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 <ContentError error={contentError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@ -61,6 +70,24 @@ function InventoryFormFields({ inventory }) {
|
||||
fieldName="instanceGroups"
|
||||
/>
|
||||
<FormFullWidthLayout>
|
||||
<FormGroup
|
||||
label={t`Labels`}
|
||||
labelIcon={
|
||||
<Popover
|
||||
content={t`Optional labels that describe this inventory,
|
||||
such as 'dev' or 'test'. Labels can be used to group and filter
|
||||
inventories and completed jobs.`}
|
||||
/>
|
||||
}
|
||||
fieldId="inventory-labels"
|
||||
>
|
||||
<LabelSelect
|
||||
value={labelsField.value}
|
||||
onChange={(labels) => labelsHelpers.setValue(labels)}
|
||||
onError={setContentError}
|
||||
createText={t`Create`}
|
||||
/>
|
||||
</FormGroup>
|
||||
<VariablesField
|
||||
tooltip={t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the Ansible Tower documentation for example syntax`}
|
||||
id="inventory-variables"
|
||||
@ -88,8 +115,8 @@ function InventoryForm({
|
||||
(inventory.summary_fields && inventory.summary_fields.organization) ||
|
||||
null,
|
||||
instanceGroups: instanceGroups || [],
|
||||
labels: inventory?.summary_fields?.labels?.results || [],
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { LabelsAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
@ -25,6 +26,12 @@ const inventory = {
|
||||
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',
|
||||
@ -57,6 +64,10 @@ describe('<InventoryForm />', () => {
|
||||
beforeAll(async () => {
|
||||
onCancel = jest.fn();
|
||||
onSubmit = jest.fn();
|
||||
LabelsAPI.read.mockReturnValue({
|
||||
data: inventory.summary_fields.labels,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InventoryForm
|
||||
@ -79,7 +90,9 @@ describe('<InventoryForm />', () => {
|
||||
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('<InventoryForm />', () => {
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user