Adds Inventory labels (#11558)

* Adds inventory labels end point

* Adds label field to inventory form
This commit is contained in:
Alex Corey 2022-02-14 15:14:08 -05:00 committed by GitHub
parent 1de9dddd21
commit 326d12382f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 285 additions and 58 deletions

View File

@ -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 = (

View File

@ -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'),
]

View File

@ -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

View File

@ -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

View File

@ -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')

View 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'),
),
]

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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) {

View File

@ -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' },

View File

@ -0,0 +1 @@
export { default } from './LabelSelect';

View File

@ -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);
}

View File

@ -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,

View File

@ -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}

View File

@ -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 />;
}

View File

@ -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,

View File

@ -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}

View File

@ -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
);
});
});

View File

@ -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';

View File

@ -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;