mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 23:07:42 -02:30
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:
@@ -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,
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/Inventories.js
Normal file
10
awx/ui_next/src/api/models/Inventories.js
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ describe('CheckboxListItem', () => {
|
|||||||
<CheckboxListItem
|
<CheckboxListItem
|
||||||
itemId={1}
|
itemId={1}
|
||||||
name="Buzz"
|
name="Buzz"
|
||||||
|
label="Buzz"
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
47
awx/ui_next/src/components/DataListRadio/DataListRadio.jsx
Normal file
47
awx/ui_next/src/components/DataListRadio/DataListRadio.jsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/components/DataListRadio/index.js
Normal file
1
awx/ui_next/src/components/DataListRadio/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './DataListRadio';
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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() }}
|
||||||
|
|||||||
@@ -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 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user