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
22 changed files with 285 additions and 58 deletions

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;