Merge pull request #4367 from keithjgrant/4232-single-select-lookup

Single select lookup

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-07-29 17:42:12 +00:00
committed by GitHub
19 changed files with 434 additions and 77 deletions

View File

@@ -1,5 +1,6 @@
import Config from './models/Config'; import Config from './models/Config';
import InstanceGroups from './models/InstanceGroups'; import InstanceGroups from './models/InstanceGroups';
import Inventories from './models/Inventories';
import JobTemplates from './models/JobTemplates'; import JobTemplates from './models/JobTemplates';
import Jobs from './models/Jobs'; import Jobs from './models/Jobs';
import Me from './models/Me'; import Me from './models/Me';
@@ -13,6 +14,7 @@ import WorkflowJobTemplates from './models/WorkflowJobTemplates';
const ConfigAPI = new Config(); const ConfigAPI = new Config();
const InstanceGroupsAPI = new InstanceGroups(); const InstanceGroupsAPI = new InstanceGroups();
const InventoriesAPI = new Inventories();
const JobTemplatesAPI = new JobTemplates(); const JobTemplatesAPI = new JobTemplates();
const JobsAPI = new Jobs(); const JobsAPI = new Jobs();
const MeAPI = new Me(); const MeAPI = new Me();
@@ -27,6 +29,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
export { export {
ConfigAPI, ConfigAPI,
InstanceGroupsAPI, InstanceGroupsAPI,
InventoriesAPI,
JobTemplatesAPI, JobTemplatesAPI,
JobsAPI, JobsAPI,
MeAPI, MeAPI,

View File

@@ -0,0 +1,10 @@
import Base from '../Base';
class Inventories extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/inventories/';
}
}
export default Inventories;

View File

@@ -97,7 +97,7 @@
width: 600px; width: 600px;
.pf-c-modal-box__body { .pf-c-modal-box__body {
overflow: visible; overflow: auto;
} }
.pf-c-modal-box__footer > .pf-c-button:not(:last-child) { .pf-c-modal-box__footer > .pf-c-button:not(:last-child) {
@@ -155,11 +155,6 @@
// and bem style, as well as moved into component-based scss files // and bem style, as well as moved into component-based scss files
// //
.awx-lookup .pf-c-form-control {
--pf-c-form-control--Height: 90px;
overflow-y: auto;
}
.at-c-listCardBody { .at-c-listCardBody {
--pf-c-card__footer--PaddingX: 0; --pf-c-card__footer--PaddingX: 0;
--pf-c-card__footer--PaddingY: 0; --pf-c-card__footer--PaddingY: 0;

View File

@@ -107,6 +107,7 @@ class SelectResourceStep extends React.Component {
itemId={item.id} itemId={item.id}
key={item.id} key={item.id}
name={item[displayKey]} name={item[displayKey]}
label={item[displayKey]}
onSelect={() => onRowClick(item)} onSelect={() => onRowClick(item)}
/> />
)} )}

View File

@@ -7,42 +7,57 @@ import {
DataListCheck, DataListCheck,
DataListCell, DataListCell,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import DataListRadio from '@components/DataListRadio';
import VerticalSeparator from '../VerticalSeparator'; import VerticalSeparator from '../VerticalSeparator';
const CheckboxListItem = ({ itemId, name, isSelected, onSelect }) => ( const CheckboxListItem = ({
<DataListItem key={itemId} aria-labelledby={`check-action-item-${itemId}`}> itemId,
<DataListItemRow> name,
<DataListCheck label,
id={`selected-${itemId}`} isSelected,
checked={isSelected} onSelect,
onChange={onSelect} isRadio,
aria-labelledby={`check-action-item-${itemId}`} }) => {
value={itemId} const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
/> return (
<DataListItemCells <DataListItem key={itemId} aria-labelledby={`check-action-item-${itemId}`}>
dataListCells={[ <DataListItemRow>
<DataListCell key="divider" className="pf-c-data-list__cell--divider"> <CheckboxRadio
<VerticalSeparator /> id={`selected-${itemId}`}
</DataListCell>, checked={isSelected}
<DataListCell key="name"> onChange={onSelect}
<label aria-labelledby={`check-action-item-${itemId}`}
id={`check-action-item-${itemId}`} name={name}
htmlFor={`selected-${itemId}`} value={itemId}
className="check-action-item" />
<DataListItemCells
dataListCells={[
<DataListCell
key="divider"
className="pf-c-data-list__cell--divider"
> >
<b>{name}</b> <VerticalSeparator />
</label> </DataListCell>,
</DataListCell>, <DataListCell key="name">
]} <label
/> id={`check-action-item-${itemId}`}
</DataListItemRow> htmlFor={`selected-${itemId}`}
</DataListItem> className="check-action-item"
); >
<b>{label}</b>
</label>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
};
CheckboxListItem.propTypes = { CheckboxListItem.propTypes = {
itemId: PropTypes.number.isRequired, itemId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
}; };

View File

@@ -9,6 +9,7 @@ describe('CheckboxListItem', () => {
<CheckboxListItem <CheckboxListItem
itemId={1} itemId={1}
name="Buzz" name="Buzz"
label="Buzz"
isSelected={false} isSelected={false}
onSelect={() => {}} onSelect={() => {}}
/> />

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import { string, bool, func } from 'prop-types';
function DataListRadio({
className = '',
onChange,
isValid = true,
isDisabled = false,
isChecked = null,
checked = null,
...props
}) {
return (
<div className={`pf-c-data-list__item-control ${className}`}>
<div className="pf-c-data-list__check">
<input
{...props}
type="radio"
onChange={event => onChange(event.currentTarget.checked, event)}
aria-invalid={!isValid}
disabled={isDisabled}
checked={isChecked || checked}
/>
</div>
</div>
);
}
DataListRadio.propTypes = {
className: string,
isValid: bool,
isDisabled: bool,
isChecked: bool,
checked: bool,
onChange: func,
'aria-labelledby': string,
};
DataListRadio.defaultProps = {
className: '',
isValid: true,
isDisabled: false,
isChecked: false,
checked: false,
onChange: () => {},
'aria-labelledby': '',
};
export default DataListRadio;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import DataListRadio from './DataListRadio';
describe('DataListRadio', () => {
test('should call onChange', () => {
const onChange = jest.fn();
const wrapper = mountWithContexts(<DataListRadio onChange={onChange} />);
wrapper.find('input[type="radio"]').prop('onChange')({
currentTarget: { checked: true },
});
expect(onChange).toHaveBeenCalledWith(true, {
currentTarget: { checked: true },
});
});
test('should pass props to correct children', () => {
const onChange = jest.fn();
const wrapper = mountWithContexts(
<DataListRadio
onChange={onChange}
className="foo"
isValid
isDisabled
checked
/>
);
const div = wrapper.find('.pf-c-data-list__item-control');
const input = wrapper.find('input[type="radio"]');
expect(div.prop('className')).toEqual('pf-c-data-list__item-control foo');
expect(input.prop('disabled')).toBe(true);
expect(input.prop('checked')).toBe(true);
expect(input.prop('aria-invalid')).toBe(false);
});
});

View File

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

View File

@@ -1,15 +1,24 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import {
string,
bool,
arrayOf,
func,
number,
oneOfType,
shape,
} from 'prop-types';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { SearchIcon } from '@patternfly/react-icons'; import { SearchIcon } from '@patternfly/react-icons';
import { import {
Button, Button,
ButtonVariant, ButtonVariant,
InputGroup, InputGroup as PFInputGroup,
Modal, Modal as PFModal,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components';
import PaginatedDataList from '../PaginatedDataList'; import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
@@ -18,13 +27,31 @@ import SelectedList from '../SelectedList';
import { ChipGroup, Chip } from '../Chip'; import { ChipGroup, Chip } from '../Chip';
import { getQSConfig, parseNamespacedQueryString } from '../../util/qs'; import { getQSConfig, parseNamespacedQueryString } from '../../util/qs';
const InputGroup = styled(PFInputGroup)`
${props =>
props.multiple &&
`
--pf-c-form-control--Height: 90px;
overflow-y: auto;
`}
`;
const Modal = styled(PFModal)`
--pf-c-modal-box--body--MinHeight: 460px;
`;
class Lookup extends React.Component { class Lookup extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.assertCorrectValueType();
let lookupSelectedItems = [];
if (props.value) {
lookupSelectedItems = props.multiple ? [...props.value] : [props.value];
}
this.state = { this.state = {
isModalOpen: false, isModalOpen: false,
lookupSelectedItems: [...props.value] || [], lookupSelectedItems,
results: [], results: [],
count: 0, count: 0,
error: null, error: null,
@@ -51,6 +78,18 @@ class Lookup extends React.Component {
} }
} }
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');
}
}
async getData() { async getData() {
const { const {
getItems, getItems,
@@ -73,7 +112,7 @@ class Lookup extends React.Component {
} }
toggleSelected(row) { toggleSelected(row) {
const { name, onLookupSave } = this.props; const { name, onLookupSave, multiple } = this.props;
const { const {
lookupSelectedItems: updatedSelectedItems, lookupSelectedItems: updatedSelectedItems,
isModalOpen, isModalOpen,
@@ -83,13 +122,17 @@ class Lookup extends React.Component {
selectedRow => selectedRow.id === row.id selectedRow => selectedRow.id === row.id
); );
if (selectedIndex > -1) { if (multiple) {
updatedSelectedItems.splice(selectedIndex, 1); if (selectedIndex > -1) {
this.setState({ lookupSelectedItems: updatedSelectedItems }); updatedSelectedItems.splice(selectedIndex, 1);
this.setState({ lookupSelectedItems: updatedSelectedItems });
} else {
this.setState(prevState => ({
lookupSelectedItems: [...prevState.lookupSelectedItems, row],
}));
}
} else { } else {
this.setState(prevState => ({ this.setState({ lookupSelectedItems: [row] });
lookupSelectedItems: [...prevState.lookupSelectedItems, row],
}));
} }
// Updates the selected items from parent state // Updates the selected items from parent state
@@ -102,12 +145,16 @@ class Lookup extends React.Component {
handleModalToggle() { handleModalToggle() {
const { isModalOpen } = this.state; const { isModalOpen } = this.state;
const { value } = this.props; const { value, multiple } = this.props;
// Resets the selected items from parent state whenever modal is opened // Resets the selected items from parent state whenever modal is opened
// This handles the case where the user closes/cancels the modal and // This handles the case where the user closes/cancels the modal and
// opens it again // opens it again
if (!isModalOpen) { if (!isModalOpen) {
this.setState({ lookupSelectedItems: [...value] }); let lookupSelectedItems = [];
if (value) {
lookupSelectedItems = multiple ? [...value] : [value];
}
this.setState({ lookupSelectedItems });
} }
this.setState(prevState => ({ this.setState(prevState => ({
isModalOpen: !prevState.isModalOpen, isModalOpen: !prevState.isModalOpen,
@@ -115,9 +162,12 @@ class Lookup extends React.Component {
} }
saveModal() { saveModal() {
const { onLookupSave, name } = this.props; const { onLookupSave, name, multiple } = this.props;
const { lookupSelectedItems } = this.state; const { lookupSelectedItems } = this.state;
onLookupSave(lookupSelectedItems, name); const value = multiple
? lookupSelectedItems
: lookupSelectedItems[0] || null;
onLookupSave(value, name);
this.handleModalToggle(); this.handleModalToggle();
} }
@@ -129,14 +179,28 @@ class Lookup extends React.Component {
results, results,
count, count,
} = this.state; } = this.state;
const { id, lookupHeader, value, columns, i18n } = this.props; const {
id,
lookupHeader,
value,
columns,
multiple,
name,
required,
i18n,
} = this.props;
const header = lookupHeader || i18n._(t`items`); const header = lookupHeader || i18n._(t`items`);
const canDelete = !required || (multiple && value.length > 1);
const chips = value ? ( const chips = value ? (
<ChipGroup> <ChipGroup>
{value.map(chip => ( {(multiple ? value : [value]).map(chip => (
<Chip key={chip.id} onClick={() => this.toggleSelected(chip)}> <Chip
key={chip.id}
onClick={() => this.toggleSelected(chip)}
isReadOnly={!canDelete}
>
{chip.name} {chip.name}
</Chip> </Chip>
))} ))}
@@ -145,7 +209,7 @@ class Lookup extends React.Component {
return ( return (
<Fragment> <Fragment>
<InputGroup className="awx-lookup"> <InputGroup>
<Button <Button
aria-label="Search" aria-label="Search"
id={id} id={id}
@@ -189,9 +253,11 @@ class Lookup extends React.Component {
<CheckboxListItem <CheckboxListItem
key={item.id} key={item.id}
itemId={item.id} itemId={item.id}
name={item.name} name={multiple ? item.name : name}
label={item.name}
isSelected={lookupSelectedItems.some(i => i.id === item.id)} isSelected={lookupSelectedItems.some(i => i.id === item.id)}
onSelect={() => this.toggleSelected(item)} onSelect={() => this.toggleSelected(item)}
isRadio={!multiple}
/> />
)} )}
renderToolbar={props => <DataListToolbar {...props} fillWidth />} renderToolbar={props => <DataListToolbar {...props} fillWidth />}
@@ -203,6 +269,7 @@ class Lookup extends React.Component {
selected={lookupSelectedItems} selected={lookupSelectedItems}
showOverflowAfter={5} showOverflowAfter={5}
onRemove={this.toggleSelected} onRemove={this.toggleSelected}
isReadOnly={!canDelete}
/> />
)} )}
{error ? <div>error</div> : ''} {error ? <div>error</div> : ''}
@@ -212,20 +279,29 @@ class Lookup extends React.Component {
} }
} }
const Item = shape({
id: number.isRequired,
});
Lookup.propTypes = { Lookup.propTypes = {
id: PropTypes.string, id: string,
getItems: PropTypes.func.isRequired, getItems: func.isRequired,
lookupHeader: PropTypes.string, lookupHeader: string,
name: PropTypes.string, // TODO: delete, unused ? name: string,
onLookupSave: PropTypes.func.isRequired, onLookupSave: func.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired, value: oneOfType([Item, arrayOf(Item)]),
sortedColumnKey: PropTypes.string.isRequired, sortedColumnKey: string.isRequired,
multiple: bool,
required: bool,
}; };
Lookup.defaultProps = { Lookup.defaultProps = {
id: 'lookup-search', id: 'lookup-search',
lookupHeader: null, lookupHeader: null,
name: null, name: null,
value: null,
multiple: false,
required: false,
}; };
export { Lookup as _Lookup }; export { Lookup as _Lookup };

View File

@@ -17,6 +17,7 @@ describe('<Lookup />', () => {
getItems={() => {}} getItems={() => {}}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
); );
}); });
@@ -33,6 +34,7 @@ describe('<Lookup />', () => {
})} })}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
).find('Lookup'); ).find('Lookup');
@@ -57,6 +59,7 @@ describe('<Lookup />', () => {
getItems={() => {}} getItems={() => {}}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
).find('Lookup'); ).find('Lookup');
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
@@ -90,6 +93,7 @@ describe('<Lookup />', () => {
getItems={() => ({ data })} getItems={() => ({ data })}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
); );
setImmediate(() => { setImmediate(() => {
@@ -118,6 +122,7 @@ describe('<Lookup />', () => {
getItems={() => ({ data })} getItems={() => ({ data })}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
); );
const removeIcon = wrapper.find('button[aria-label="close"]').first(); const removeIcon = wrapper.find('button[aria-label="close"]').first();
@@ -136,6 +141,7 @@ describe('<Lookup />', () => {
getItems={() => {}} getItems={() => {}}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
).find('Lookup'); ).find('Lookup');
const chip = wrapper.find('.pf-c-chip'); const chip = wrapper.find('.pf-c-chip');
@@ -152,6 +158,7 @@ describe('<Lookup />', () => {
getItems={() => {}} getItems={() => {}}
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
).find('Lookup'); ).find('Lookup');
wrapper.instance().toggleSelected({ wrapper.instance().toggleSelected({
@@ -182,6 +189,7 @@ describe('<Lookup />', () => {
onLookupSave={onLookupSaveFn} onLookupSave={onLookupSaveFn}
getItems={() => {}} getItems={() => {}}
sortedColumnKey="name" sortedColumnKey="name"
multiple
/> />
).find('Lookup'); ).find('Lookup');
wrapper.instance().toggleSelected({ wrapper.instance().toggleSelected({
@@ -206,7 +214,46 @@ describe('<Lookup />', () => {
); );
}); });
test('should call callback with selected single item', () => {
mockData = { name: 'foo', id: 1, isChecked: false, url: 'https://foo' };
const onLookupSaveFn = jest.fn();
const wrapper = mountWithContexts(
<Lookup
lookupHeader="Foo Bar"
name="fooBar"
value={mockData}
onLookupSave={onLookupSaveFn}
getItems={() => ({
data: {
results: [mockData],
count: 1,
},
})}
sortedColumnKey="name"
/>
);
wrapper
.find('Lookup')
.instance()
.toggleSelected({
id: 1,
name: 'foo',
});
wrapper
.find('Lookup')
.instance()
.saveModal();
expect(onLookupSaveFn).toHaveBeenCalledWith(
{
id: 1,
name: 'foo',
},
'fooBar'
);
});
test('should re-fetch data when URL params change', async () => { test('should re-fetch data when URL params change', async () => {
mockData = [{ name: 'foo', id: 1, isChecked: false }];
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/organizations/add'], initialEntries: ['/organizations/add'],
}); });
@@ -220,6 +267,7 @@ describe('<Lookup />', () => {
columns={mockColumns} columns={mockColumns}
sortedColumnKey="name" sortedColumnKey="name"
getItems={getItems} getItems={getItems}
multiple
handleHttpError={() => {}} handleHttpError={() => {}}
location={{ history }} location={{ history }}
i18n={{ _: val => val.toString() }} i18n={{ _: val => val.toString() }}

View File

@@ -35,6 +35,7 @@ class InstanceGroupsLookup extends React.Component {
value={value} value={value}
onLookupSave={onChange} onLookupSave={onChange}
getItems={getInstanceGroups} getItems={getInstanceGroups}
multiple
columns={[ columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true }, { name: i18n._(t`Name`), key: 'name', isSortable: true },
{ {

View File

@@ -29,9 +29,7 @@ describe('<JobTemplateAdd />', () => {
expect(wrapper.find('input#template-description').text()).toBe( expect(wrapper.find('input#template-description').text()).toBe(
defaultProps.description defaultProps.description
); );
expect(wrapper.find('input#template-inventory').text()).toBe( expect(wrapper.find('InventoriesLookup').prop('value')).toBe(null);
defaultProps.inventory
);
expect(wrapper.find('AnsibleSelect[name="job_type"]').props().value).toBe( expect(wrapper.find('AnsibleSelect[name="job_type"]').props().value).toBe(
defaultProps.job_type defaultProps.job_type
); );

View File

@@ -0,0 +1,75 @@
import React, { Fragment } from 'react';
import { string, func, bool } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon } from '@patternfly/react-icons';
import { InventoriesAPI } from '@api';
import Lookup from '@components/Lookup';
import { Inventory } from '../../../types';
const getInventories = async params => InventoriesAPI.read(params);
class InventoriesLookup extends React.Component {
render() {
const { value, tooltip, onChange, required, i18n } = this.props;
return (
<FormGroup
label={
<Fragment>
{i18n._(t`Inventories`)}{' '}
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
</Fragment>
}
fieldId="inventories-lookup"
>
<Lookup
id="inventories-lookup"
lookupHeader={i18n._(t`Inventories`)}
name="inventories"
value={value}
onLookupSave={onChange}
getItems={getInventories}
required={required}
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
sortedColumnKey="name"
/>
</FormGroup>
);
}
}
InventoriesLookup.propTypes = {
value: Inventory,
tooltip: string,
onChange: func.isRequired,
required: bool,
};
InventoriesLookup.defaultProps = {
value: null,
tooltip: '',
required: false,
};
export default withI18n()(InventoriesLookup);

View File

@@ -13,6 +13,7 @@ import FormRow from '@components/FormRow';
import { required } from '@util/validators'; import { required } from '@util/validators';
import styled from 'styled-components'; import styled from 'styled-components';
import { JobTemplate } from '@types'; import { JobTemplate } from '@types';
import InventoriesLookup from './InventoriesLookup';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px; margin-left: 10px;
@@ -33,11 +34,23 @@ class JobTemplateForm extends Component {
job_type: 'run', job_type: 'run',
project: '', project: '',
playbook: '', playbook: '',
summary_fields: {
inventory: null,
},
}, },
}; };
constructor(props) {
super(props);
this.state = {
inventory: props.template.summary_fields.inventory,
};
}
render() { render() {
const { handleCancel, handleSubmit, i18n, template } = this.props; const { handleCancel, handleSubmit, i18n, template } = this.props;
const { inventory } = this.state;
const jobTypeOptions = [ const jobTypeOptions = [
{ {
@@ -105,15 +118,21 @@ class JobTemplateForm extends Component {
</FormGroup> </FormGroup>
)} )}
/> />
<FormField <Field
id="template-inventory"
name="inventory" name="inventory"
type="number"
label={i18n._(t`Inventory`)}
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
isRequired
validate={required(null, i18n)} validate={required(null, i18n)}
render={({ form }) => (
<InventoriesLookup
value={inventory}
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
onChange={value => {
form.setFieldValue('inventory', value.id);
this.setState({ inventory: value });
}}
required
/>
)}
/> />
<FormField <FormField
id="template-project" id="template-project"

View File

@@ -15,6 +15,12 @@ describe('<JobTemplateForm />', () => {
project: 3, project: 3,
playbook: 'Baz', playbook: 'Baz',
type: 'job_template', type: 'job_template',
summary_fields: {
inventory: {
id: 2,
name: 'foo',
},
},
}; };
afterEach(() => { afterEach(() => {
@@ -31,7 +37,7 @@ describe('<JobTemplateForm />', () => {
); );
}); });
test('should update form values on input changes', () => { test('should update form values on input changes', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<JobTemplateForm <JobTemplateForm
template={mockData} template={mockData}
@@ -53,8 +59,9 @@ describe('<JobTemplateForm />', () => {
target: { value: 'new job type', name: 'job_type' }, target: { value: 'new job type', name: 'job_type' },
}); });
expect(form.state('values').job_type).toEqual('new job type'); expect(form.state('values').job_type).toEqual('new job type');
wrapper.find('input#template-inventory').simulate('change', { wrapper.find('InventoriesLookup').prop('onChange')({
target: { value: 3, name: 'inventory' }, id: 3,
name: 'inventory',
}); });
expect(form.state('values').inventory).toEqual(3); expect(form.state('values').inventory).toEqual(3);
wrapper.find('input#template-project').simulate('change', { wrapper.find('input#template-project').simulate('change', {

View File

@@ -78,7 +78,18 @@ export const Project = shape({
export const Inventory = shape({ export const Inventory = shape({
id: number.isRequired, id: number.isRequired,
name: string.isRequired, name: string,
description: string,
groups_with_active_failures: number,
has_active_failures: bool,
has_inventory_sources: bool,
hosts_with_active_failures: number,
inventory_sources_with_failures: number,
kind: string,
organization_id: number,
total_groups: number,
total_hosts: number,
total_inventory_sources: number,
}); });
export const InstanceGroup = shape({ export const InstanceGroup = shape({

View File

@@ -1,7 +1,16 @@
// TODO: switch to using Lingui i18n for pluralization
export function pluralize(str) { export function pluralize(str) {
return str[str.length - 1] === 's' ? `${str}es` : `${str}s`; const lastChar = str[str.length - 1];
if (lastChar === 's') {
return `${str}es`;
}
if (lastChar === 'y') {
return `${str.substr(0, str.length - 1)}ies`;
}
return `${str}s`;
} }
// TODO: switch to using Lingui i18n for articles
export function getArticle(str) { export function getArticle(str) {
const first = str[0]; const first = str[0];
if ('aeiou'.includes(first)) { if ('aeiou'.includes(first)) {

View File

@@ -9,6 +9,10 @@ describe('string utils', () => {
test('should add an "es"', () => { test('should add an "es"', () => {
expect(pluralize('class')).toEqual('classes'); expect(pluralize('class')).toEqual('classes');
}); });
test('should handle word ending in y', () => {
expect(pluralize('inventory')).toEqual('inventories');
});
}); });
describe('getArticle', () => { describe('getArticle', () => {