Merge pull request #5408 from keithjgrant/5065-lookup-c

Lookup refactor

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
             https://github.com/jakemcdermott
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-12-10 22:01:50 +00:00
committed by GitHub
37 changed files with 1882 additions and 1276 deletions

View File

@@ -25,7 +25,7 @@ class AnsibleSelect extends React.Component {
} }
render() { render() {
const { id, data, i18n, isValid, onBlur, value } = this.props; const { id, data, i18n, isValid, onBlur, value, className } = this.props;
return ( return (
<FormSelect <FormSelect
@@ -35,13 +35,14 @@ class AnsibleSelect extends React.Component {
onBlur={onBlur} onBlur={onBlur}
aria-label={i18n._(t`Select Input`)} aria-label={i18n._(t`Select Input`)}
isValid={isValid} isValid={isValid}
className={className}
> >
{data.map(datum => ( {data.map(option => (
<FormSelectOption <FormSelectOption
key={datum.key} key={option.key}
value={datum.value} value={option.value}
label={datum.label} label={option.label}
isDisabled={datum.isDisabled} isDisabled={option.isDisabled}
/> />
))} ))}
</FormSelect> </FormSelect>
@@ -49,19 +50,28 @@ class AnsibleSelect extends React.Component {
} }
} }
const Option = shape({
key: oneOfType([string, number]).isRequired,
value: oneOfType([string, number]).isRequired,
label: string.isRequired,
isDisabled: bool,
});
AnsibleSelect.defaultProps = { AnsibleSelect.defaultProps = {
data: [], data: [],
isValid: true, isValid: true,
onBlur: () => {}, onBlur: () => {},
className: '',
}; };
AnsibleSelect.propTypes = { AnsibleSelect.propTypes = {
data: arrayOf(shape()), data: arrayOf(Option),
id: string.isRequired, id: string.isRequired,
isValid: bool, isValid: bool,
onBlur: func, onBlur: func,
onChange: func.isRequired, onChange: func.isRequired,
value: oneOfType([string, number]).isRequired, value: oneOfType([string, number]).isRequired,
className: string,
}; };
export { AnsibleSelect as _AnsibleSelect }; export { AnsibleSelect as _AnsibleSelect };

View File

@@ -16,6 +16,7 @@ const CheckboxListItem = ({
label, label,
isSelected, isSelected,
onSelect, onSelect,
onDeselect,
isRadio, isRadio,
}) => { }) => {
const CheckboxRadio = isRadio ? DataListRadio : DataListCheck; const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
@@ -25,7 +26,7 @@ const CheckboxListItem = ({
<CheckboxRadio <CheckboxRadio
id={`selected-${itemId}`} id={`selected-${itemId}`}
checked={isSelected} checked={isSelected}
onChange={onSelect} onChange={isSelected ? onDeselect : onSelect}
aria-labelledby={`check-action-item-${itemId}`} aria-labelledby={`check-action-item-${itemId}`}
name={name} name={name}
value={itemId} value={itemId}
@@ -60,6 +61,7 @@ CheckboxListItem.propTypes = {
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
}; };
export default CheckboxListItem; export default CheckboxListItem;

View File

@@ -12,6 +12,7 @@ describe('CheckboxListItem', () => {
label="Buzz" label="Buzz"
isSelected={false} isSelected={false}
onSelect={() => {}} onSelect={() => {}}
onDeselect={() => {}}
/> />
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);

View File

@@ -4,8 +4,8 @@ import { withI18n } from '@lingui/react';
import { EmptyState, EmptyStateBody } from '@patternfly/react-core'; import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
// TODO: Better loading state - skeleton lines / spinner, etc. // TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ i18n }) => ( const ContentLoading = ({ className, i18n }) => (
<EmptyState> <EmptyState className={className}>
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody> <EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
</EmptyState> </EmptyState>
); );

View File

@@ -1,11 +1,20 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { bool, func, number, string, oneOfType } from 'prop-types'; import { bool, func, number, string, oneOfType } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { CredentialsAPI } from '@api'; import { CredentialsAPI } from '@api';
import { Credential } from '@types'; import { Credential } from '@types';
import { mergeParams } from '@util/qs'; import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup'; import Lookup from '@components/Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
page_size: 5,
order_by: 'name',
});
function CredentialLookup({ function CredentialLookup({
helperTextInvalid, helperTextInvalid,
@@ -16,11 +25,28 @@ function CredentialLookup({
required, required,
credentialTypeId, credentialTypeId,
value, value,
history,
}) { }) {
const getCredentials = async params => const [credentials, setCredentials] = useState([]);
CredentialsAPI.read( const [count, setCount] = useState(0);
mergeParams(params, { credential_type: credentialTypeId }) const [error, setError] = useState(null);
);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await CredentialsAPI.read(
mergeParams(params, { credential_type: credentialTypeId })
);
setCredentials(data.results);
setCount(data.count);
} catch (err) {
if (setError) {
setError(err);
}
}
})();
}, [credentialTypeId, history.location.search]);
return ( return (
<FormGroup <FormGroup
@@ -32,15 +58,26 @@ function CredentialLookup({
> >
<Lookup <Lookup
id="credential" id="credential"
lookupHeader={label} header={label}
name="credential"
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onLookupSave={onChange} onChange={onChange}
getItems={getCredentials}
required={required} required={required}
sortedColumnKey="name" qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={credentials}
optionCount={count}
header={label}
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/> />
<LookupErrorMessage error={error} />
</FormGroup> </FormGroup>
); );
} }
@@ -65,4 +102,4 @@ CredentialLookup.defaultProps = {
}; };
export { CredentialLookup as _CredentialLookup }; export { CredentialLookup as _CredentialLookup };
export default withI18n()(CredentialLookup); export default withI18n()(withRouter(CredentialLookup));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import CredentialLookup, { _CredentialLookup } from './CredentialLookup'; import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
import { CredentialsAPI } from '@api'; import { CredentialsAPI } from '@api';
@@ -9,19 +10,48 @@ describe('CredentialLookup', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = mountWithContexts( CredentialsAPI.read.mockResolvedValueOnce({
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} /> data: {
); results: [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
],
count: 5,
},
});
}); });
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
wrapper.unmount();
}); });
test('initially renders successfully', () => { test('should render successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
expect(wrapper.find('CredentialLookup')).toHaveLength(1); expect(wrapper.find('CredentialLookup')).toHaveLength(1);
}); });
test('should fetch credentials', () => {
test('should fetch credentials', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
expect(CredentialsAPI.read).toHaveBeenCalledWith({ expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type: 1, credential_type: 1,
@@ -30,11 +60,31 @@ describe('CredentialLookup', () => {
page_size: 5, page_size: 5,
}); });
}); });
test('should display label', () => {
test('should display label', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
const title = wrapper.find('FormGroup .pf-c-form__label-text'); const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Foo'); expect(title.text()).toEqual('Foo');
}); });
test('should define default value for function props', () => {
test('should define default value for function props', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialLookup
credentialTypeId={1}
label="Foo"
onChange={() => {}}
/>
);
});
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow(); expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
}); });

View File

@@ -1,48 +1,69 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import { arrayOf, string, func, object, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { InstanceGroupsAPI } from '@api'; import { InstanceGroupsAPI } from '@api';
import Lookup from '@components/Lookup'; import { getQSConfig, parseQueryString } from '@util/qs';
import { FieldTooltip } from '@components/FormField';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` const QS_CONFIG = getQSConfig('instance_groups', {
margin-left: 10px; page: 1,
`; page_size: 5,
order_by: 'name',
});
const getInstanceGroups = async params => InstanceGroupsAPI.read(params); function InstanceGroupsLookup(props) {
const {
value,
onChange,
tooltip,
className,
required,
history,
i18n,
} = props;
const [instanceGroups, setInstanceGroups] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
class InstanceGroupsLookup extends React.Component { useEffect(() => {
render() { (async () => {
const { value, tooltip, onChange, className, i18n } = this.props; const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await InstanceGroupsAPI.read(params);
setInstanceGroups(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
/* return (
Wrapping <div> added to workaround PF bug: <FormGroup
https://github.com/patternfly/patternfly-react/issues/2855 className={className}
*/ label={i18n._(t`Instance Groups`)}
return ( fieldId="org-instance-groups"
<div className={className}> >
<FormGroup {tooltip && <FieldTooltip content={tooltip} />}
label={i18n._(t`Instance Groups`)} <Lookup
fieldId="org-instance-groups" id="org-instance-groups"
> header={i18n._(t`Instance Groups`)}
{tooltip && ( value={value}
<Tooltip position="right" content={tooltip}> onChange={onChange}
<QuestionCircleIcon /> qsConfig={QS_CONFIG}
</Tooltip> multiple
)} required={required}
<Lookup renderOptionsList={({ state, dispatch, canDelete }) => (
id="org-instance-groups" <OptionsList
lookupHeader={i18n._(t`Instance Groups`)} value={state.selectedItems}
name="instanceGroups" options={instanceGroups}
value={value} optionCount={count}
onLookupSave={onChange}
getItems={getInstanceGroups}
qsNamespace="instance-group"
multiple
columns={[ columns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
@@ -63,22 +84,33 @@ class InstanceGroupsLookup extends React.Component {
isNumeric: true, isNumeric: true,
}, },
]} ]}
sortedColumnKey="name" multiple={state.multiple}
header={i18n._(t`Instance Groups`)}
name="instanceGroups"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/> />
</FormGroup> )}
</div> />
); <LookupErrorMessage error={error} />
} </FormGroup>
);
} }
InstanceGroupsLookup.propTypes = { InstanceGroupsLookup.propTypes = {
value: PropTypes.arrayOf(PropTypes.object).isRequired, value: arrayOf(object).isRequired,
tooltip: PropTypes.string, tooltip: string,
onChange: PropTypes.func.isRequired, onChange: func.isRequired,
className: string,
required: bool,
}; };
InstanceGroupsLookup.defaultProps = { InstanceGroupsLookup.defaultProps = {
tooltip: '', tooltip: '',
className: '',
required: false,
}; };
export default withI18n()(InstanceGroupsLookup); export default withI18n()(withRouter(InstanceGroupsLookup));

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { string, func, bool } from 'prop-types'; import { string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
@@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api';
import { Inventory } from '@types'; import { Inventory } from '@types';
import Lookup from '@components/Lookup'; import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField'; import { FieldTooltip } from '@components/FormField';
import { getQSConfig, parseQueryString } from '@util/qs';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const getInventories = async params => InventoriesAPI.read(params); const QS_CONFIG = getQSConfig('inventory', {
page: 1,
page_size: 5,
order_by: 'name',
});
class InventoryLookup extends React.Component { function InventoryLookup({
render() { value,
const { tooltip,
value, onChange,
tooltip, onBlur,
onChange, required,
onBlur, isValid,
required, helperTextInvalid,
isValid, i18n,
helperTextInvalid, history,
i18n, }) {
} = this.props; const [inventories, setInventories] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
return ( useEffect(() => {
<FormGroup (async () => {
label={i18n._(t`Inventory`)} const params = parseQueryString(QS_CONFIG, history.location.search);
isRequired={required} try {
fieldId="inventory-lookup" const { data } = await InventoriesAPI.read(params);
isValid={isValid} setInventories(data.results);
helperTextInvalid={helperTextInvalid} setCount(data.count);
> } catch (err) {
{tooltip && <FieldTooltip content={tooltip} />} setError(err);
<Lookup }
id="inventory-lookup" })();
lookupHeader={i18n._(t`Inventory`)} }, [history.location]);
name="inventory"
value={value} return (
onLookupSave={onChange} <FormGroup
onBlur={onBlur} label={i18n._(t`Inventory`)}
getItems={getInventories} isRequired={required}
required={required} fieldId="inventory-lookup"
qsNamespace="inventory" isValid={isValid}
columns={[ helperTextInvalid={helperTextInvalid}
{ name: i18n._(t`Name`), key: 'name', isSortable: true }, >
{ {tooltip && <FieldTooltip content={tooltip} />}
name: i18n._(t`Modified`), <Lookup
key: 'modified', id="inventory-lookup"
isSortable: false, header={i18n._(t`Inventory`)}
isNumeric: true, value={value}
}, onChange={onChange}
{ onBlur={onBlur}
name: i18n._(t`Created`), required={required}
key: 'created', qsConfig={QS_CONFIG}
isSortable: false, renderOptionsList={({ state, dispatch, canDelete }) => (
isNumeric: true, <OptionsList
}, value={state.selectedItems}
]} options={inventories}
sortedColumnKey="name" optionCount={count}
/> columns={[
</FormGroup> { 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,
},
]}
multiple={state.multiple}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
} }
InventoryLookup.propTypes = { InventoryLookup.propTypes = {
@@ -77,4 +111,4 @@ InventoryLookup.defaultProps = {
required: false, required: false,
}; };
export default withI18n()(InventoryLookup); export default withI18n()(withRouter(InventoryLookup));

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'; import React, { Fragment, useReducer, useEffect } from 'react';
import { import {
string, string,
bool, bool,
@@ -15,20 +15,14 @@ import {
ButtonVariant, ButtonVariant,
InputGroup as PFInputGroup, InputGroup as PFInputGroup,
Modal, Modal,
ToolbarItem,
} 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 styled from 'styled-components';
import AnsibleSelect from '../AnsibleSelect'; import reducer, { initReducer } from './shared/reducer';
import PaginatedDataList from '../PaginatedDataList'; import { ChipGroup, Chip } from '../Chip';
import VerticalSeperator from '../VerticalSeparator'; import { QSConfig } from '@types';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { getQSConfig, parseQueryString } from '../../util/qs';
const SearchButton = styled(Button)` const SearchButton = styled(Button)`
::after { ::after {
@@ -36,6 +30,7 @@ const SearchButton = styled(Button)`
var(--pf-global--BorderColor--200); var(--pf-global--BorderColor--200);
} }
`; `;
SearchButton.displayName = 'SearchButton';
const InputGroup = styled(PFInputGroup)` const InputGroup = styled(PFInputGroup)`
${props => ${props =>
@@ -54,315 +49,124 @@ const ChipHolder = styled.div`
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
`; `;
class Lookup extends React.Component { function Lookup(props) {
constructor(props) { const {
super(props); id,
header,
onChange,
onBlur,
value,
multiple,
required,
qsConfig,
renderItemChip,
renderOptionsList,
history,
i18n,
} = props;
this.assertCorrectValueType(); const [state, dispatch] = useReducer(
let lookupSelectedItems = []; reducer,
if (props.value) { { value, multiple, required },
lookupSelectedItems = props.multiple ? [...props.value] : [props.value]; initReducer
} );
this.state = {
isModalOpen: false,
lookupSelectedItems,
results: [],
count: 0,
error: null,
};
this.qsConfig = getQSConfig(props.qsNamespace, {
page: 1,
page_size: 5,
order_by: props.sortedColumnKey,
});
this.handleModalToggle = this.handleModalToggle.bind(this);
this.toggleSelected = this.toggleSelected.bind(this);
this.saveModal = this.saveModal.bind(this);
this.getData = this.getData.bind(this);
this.clearQSParams = this.clearQSParams.bind(this);
}
componentDidMount() { useEffect(() => {
this.getData(); dispatch({ type: 'SET_MULTIPLE', value: multiple });
} }, [multiple]);
componentDidUpdate(prevProps) { useEffect(() => {
const { location, selectedCategory } = this.props; dispatch({ type: 'SET_VALUE', value });
if ( }, [value]);
location !== prevProps.location ||
prevProps.selectedCategory !== selectedCategory
) {
this.getData();
}
}
assertCorrectValueType() { const clearQSParams = () => {
const { multiple, value, selectCategoryOptions } = this.props;
if (selectCategoryOptions) {
return;
}
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() {
const {
getItems,
location: { search },
} = this.props;
const queryParams = parseQueryString(this.qsConfig, search);
this.setState({ error: false });
try {
const { data } = await getItems(queryParams);
const { results, count } = data;
this.setState({
results,
count,
});
} catch (err) {
this.setState({ error: true });
}
}
toggleSelected(row) {
const {
name,
onLookupSave,
multiple,
onToggleItem,
selectCategoryOptions,
} = this.props;
const {
lookupSelectedItems: updatedSelectedItems,
isModalOpen,
} = this.state;
const selectedIndex = updatedSelectedItems.findIndex(
selectedRow => selectedRow.id === row.id
);
if (multiple) {
if (selectCategoryOptions) {
onToggleItem(row, isModalOpen);
}
if (selectedIndex > -1) {
updatedSelectedItems.splice(selectedIndex, 1);
this.setState({ lookupSelectedItems: updatedSelectedItems });
} else {
this.setState(prevState => ({
lookupSelectedItems: [...prevState.lookupSelectedItems, row],
}));
}
} else {
this.setState({ lookupSelectedItems: [row] });
}
// Updates the selected items from parent state
// This handles the case where the user removes chips from the lookup input
// while the modal is closed
if (!isModalOpen) {
onLookupSave(updatedSelectedItems, name);
}
}
handleModalToggle() {
const { isModalOpen } = this.state;
const { value, multiple, selectCategory } = this.props;
// Resets the selected items from parent state whenever modal is opened
// This handles the case where the user closes/cancels the modal and
// opens it again
if (!isModalOpen) {
let lookupSelectedItems = [];
if (value) {
lookupSelectedItems = multiple ? [...value] : [value];
}
this.setState({ lookupSelectedItems });
} else {
this.clearQSParams();
if (selectCategory) {
selectCategory(null, 'Machine');
}
}
this.setState(prevState => ({
isModalOpen: !prevState.isModalOpen,
}));
}
saveModal() {
const { onLookupSave, name, multiple } = this.props;
const { lookupSelectedItems } = this.state;
const value = multiple
? lookupSelectedItems
: lookupSelectedItems[0] || null;
this.handleModalToggle();
onLookupSave(value, name);
}
clearQSParams() {
const { history } = this.props;
const parts = history.location.search.replace(/^\?/, '').split('&'); const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = this.qsConfig.namespace; const ns = qsConfig.namespace;
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
history.push(`${history.location.pathname}?${otherParts.join('&')}`); history.push(`${history.location.pathname}?${otherParts.join('&')}`);
} };
render() { const save = () => {
const { const { selectedItems } = state;
isModalOpen, const val = multiple ? selectedItems : selectedItems[0] || null;
lookupSelectedItems, onChange(val);
error, clearQSParams();
results, dispatch({ type: 'CLOSE_MODAL' });
count, };
} = this.state;
const { const removeItem = item => {
form, if (multiple) {
id, onChange(value.filter(i => i.id !== item.id));
lookupHeader, } else {
value, onChange(null);
columns, }
multiple, };
name,
onBlur, const closeModal = () => {
selectCategory, clearQSParams();
required, dispatch({ type: 'CLOSE_MODAL' });
i18n, };
selectCategoryOptions,
selectedCategory, const { isModalOpen, selectedItems } = state;
} = this.props; const canDelete = !required || (multiple && value.length > 1);
const header = lookupHeader || i18n._(t`Items`); let items = [];
const canDelete = !required || (multiple && value.length > 1); if (multiple) {
const chips = () => { items = value;
return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( } else if (value) {
<ChipGroup defaultIsOpen numChips={5}> items.push(value);
{(multiple ? value : [value]).map(chip => (
<CredentialChip
key={chip.id}
onClick={() => this.toggleSelected(chip)}
isReadOnly={!canDelete}
credential={chip}
/>
))}
</ChipGroup>
) : (
<ChipGroup defaultIsOpen numChips={5}>
{(multiple ? value : [value]).map(chip => (
<Chip
key={chip.id}
onClick={() => this.toggleSelected(chip)}
isReadOnly={!canDelete}
>
{chip.name}
</Chip>
))}
</ChipGroup>
);
};
return (
<Fragment>
<InputGroup onBlur={onBlur}>
<SearchButton
aria-label="Search"
id={id}
onClick={this.handleModalToggle}
variant={ButtonVariant.tertiary}
>
<SearchIcon />
</SearchButton>
<ChipHolder className="pf-c-form-control">
{value ? chips(value) : null}
</ChipHolder>
</InputGroup>
<Modal
className="awx-c-modal"
title={i18n._(t`Select ${header}`)}
isOpen={isModalOpen}
onClose={this.handleModalToggle}
actions={[
<Button
key="save"
variant="primary"
onClick={this.saveModal}
style={results.length === 0 ? { display: 'none' } : {}}
>
{i18n._(t`Save`)}
</Button>,
<Button
key="cancel"
variant="secondary"
onClick={this.handleModalToggle}
>
{results.length === 0 ? i18n._(t`Close`) : i18n._(t`Cancel`)}
</Button>,
]}
>
{selectCategoryOptions && selectCategoryOptions.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<span css="flex: 0 0 25%;">Selected Category</span>
<VerticalSeperator />
<AnsibleSelect
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label="Selected Category"
data={selectCategoryOptions}
value={selectedCategory.label}
onChange={selectCategory}
form={form}
/>
</ToolbarItem>
)}
<PaginatedDataList
items={results}
itemCount={count}
pluralizedItemName={lookupHeader}
qsConfig={this.qsConfig}
toolbarColumns={columns}
renderItem={item => (
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
isSelected={
selectCategoryOptions
? value.some(i => i.id === item.id)
: lookupSelectedItems.some(i => i.id === item.id)
}
onSelect={() => this.toggleSelected(item)}
isRadio={
!multiple ||
(selectCategoryOptions &&
selectCategoryOptions.length &&
selectedCategory.value !== 'Vault')
}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
{lookupSelectedItems.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={selectCategoryOptions ? value : lookupSelectedItems}
onRemove={this.toggleSelected}
isReadOnly={!canDelete}
isCredentialList={
selectCategoryOptions && selectCategoryOptions.length > 0
}
/>
)}
{error ? <div>error</div> : ''}
</Modal>
</Fragment>
);
} }
return (
<Fragment>
<InputGroup onBlur={onBlur}>
<SearchButton
aria-label="Search"
id={id}
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
variant={ButtonVariant.tertiary}
>
<SearchIcon />
</SearchButton>
<ChipHolder className="pf-c-form-control">
<ChipGroup numChips={5}>
{items.map(item =>
renderItemChip({
item,
removeItem,
canDelete,
})
)}
</ChipGroup>
</ChipHolder>
</InputGroup>
<Modal
className="awx-c-modal"
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
isOpen={isModalOpen}
onClose={closeModal}
actions={[
<Button
key="select"
variant="primary"
onClick={save}
style={
required && selectedItems.length === 0 ? { display: 'none' } : {}
}
>
{i18n._(t`Select`)}
</Button>,
<Button key="cancel" variant="secondary" onClick={closeModal}>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{renderOptionsList({
state,
dispatch,
canDelete,
})}
</Modal>
</Fragment>
);
} }
const Item = shape({ const Item = shape({
@@ -371,25 +175,33 @@ const Item = shape({
Lookup.propTypes = { Lookup.propTypes = {
id: string, id: string,
getItems: func.isRequired, header: string,
lookupHeader: string, onChange: func.isRequired,
name: string,
onLookupSave: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]), value: oneOfType([Item, arrayOf(Item)]),
sortedColumnKey: string.isRequired,
multiple: bool, multiple: bool,
required: bool, required: bool,
qsNamespace: string, onBlur: func,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
renderOptionsList: func.isRequired,
}; };
Lookup.defaultProps = { Lookup.defaultProps = {
id: 'lookup-search', id: 'lookup-search',
lookupHeader: null, header: null,
name: null,
value: null, value: null,
multiple: false, multiple: false,
required: false, required: false,
qsNamespace: 'lookup', onBlur: () => {},
renderItemChip: ({ item, removeItem, canDelete }) => (
<Chip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
>
{item.name}
</Chip>
),
}; };
export { Lookup as _Lookup }; export { Lookup as _Lookup };

View File

@@ -1,11 +1,9 @@
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
import React from 'react'; import React from 'react';
import { createMemoryHistory } from 'history'; import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import Lookup, { _Lookup } from './Lookup'; import { getQSConfig } from '@util/qs';
import Lookup from './Lookup';
let mockData = [{ name: 'foo', id: 1, isChecked: false }];
const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }];
/** /**
* Check that an element is present on the document body * Check that an element is present on the document body
@@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) {
}); });
} }
/** const QS_CONFIG = getQSConfig('test', {});
* Check lookup modal list for expected values const TestList = () => <div />;
* @param {wrapper} enzyme wrapper instance
* @param {expected} array of [selected, text] pairs describing
* the expected visible state of the modal data list
*/
async function checkModalListValues(wrapper, expected) {
// fail if modal isn't actually visible
checkRootElementPresent('body div[role="dialog"]');
// check list item values
const rows = await waitForElement(
wrapper,
'DataListItemRow',
el => el.length === expected.length
);
expect(rows).toHaveLength(expected.length);
rows.forEach((el, index) => {
const [expectedChecked, expectedText] = expected[index];
expect(expectedText).toEqual(el.text());
expect(expectedChecked).toEqual(el.find('input').props().checked);
});
}
/**
* Check lookup modal selection tags for expected values
* @param {wrapper} enzyme wrapper instance
* @param {expected} array of expected tag values
*/
async function checkModalTagValues(wrapper, expected) {
// fail if modal isn't actually visible
checkRootElementPresent('body div[role="dialog"]');
// check modal chip values
const chips = await waitForElement(
wrapper,
'Modal Chip span',
el => el.length === expected.length
);
expect(chips).toHaveLength(expected.length);
chips.forEach((el, index) => {
expect(el.text()).toEqual(expected[index]);
});
}
describe('<Lookup multiple/>', () => {
let wrapper;
let onChange;
beforeEach(() => {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
onChange = jest.fn();
document.body.innerHTML = '';
wrapper = mountWithContexts(
<Lookup
multiple
lookupHeader="Foo Bar"
name="foobar"
value={mockSelected}
onLookupSave={onChange}
getItems={() => ({
data: {
count: 2,
results: [
...mockSelected,
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
],
},
})}
columns={mockColumns}
sortedColumnKey="name"
/>
);
});
test('Initially renders succesfully', () => {
expect(wrapper.find('Lookup')).toHaveLength(1);
});
test('Expected items are shown', async done => {
expect(wrapper.find('Lookup')).toHaveLength(1);
await checkInputTagValues(wrapper, ['foo']);
done();
});
test('Open and close modal', async done => {
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
// This check couldn't pass unless api response was formatted properly
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
checkRootElementNotPresent('body div[role="dialog"]');
done();
});
test('Add item with checkbox then save', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="checkbox"]')
.simulate('change');
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Save')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0].map(({ name }) => name)).toEqual([
'foo',
'bar',
]);
done();
});
test('Add item with checkbox then cancel', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="checkbox"]')
.simulate('change');
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(0);
await checkInputTagValues(wrapper, ['foo']);
done();
});
test('Remove item with checkbox', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'foo')
.find('input[type="checkbox"]')
.simulate('change');
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, []);
done();
});
test('Remove item with selected icon button', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
wrapper
.find('Modal Chip')
.findWhere(el => el.text() === 'foo')
.first()
.find('button')
.simulate('click');
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, []);
done();
});
test('Remove item with input group button', async done => {
await checkInputTagValues(wrapper, ['foo']);
wrapper
.find('Lookup InputGroup Chip')
.findWhere(el => el.text() === 'foo')
.first()
.find('button')
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith([], 'foobar');
done();
});
});
describe('<Lookup />', () => { describe('<Lookup />', () => {
let wrapper; let wrapper;
let onChange; let onChange;
async function mountWrapper() {
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
await act(async () => {
wrapper = mountWithContexts(
<Lookup
id="test"
multiple
header="Foo Bar"
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
);
});
return wrapper;
}
beforeEach(() => { beforeEach(() => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
onChange = jest.fn(); onChange = jest.fn();
document.body.innerHTML = ''; document.body.innerHTML = '';
wrapper = mountWithContexts(
<Lookup
lookupHeader="Foo Bar"
name="foobar"
value={mockSelected}
onLookupSave={onChange}
getItems={() => ({
data: {
count: 2,
results: [
mockSelected,
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
],
},
})}
columns={mockColumns}
sortedColumnKey="name"
/>
);
}); });
test('Initially renders succesfully', () => { afterEach(() => {
jest.restoreAllMocks();
});
test('should render succesfully', async () => {
wrapper = await mountWrapper();
expect(wrapper.find('Lookup')).toHaveLength(1); expect(wrapper.find('Lookup')).toHaveLength(1);
}); });
test('Expected items are shown', async done => { test('should show selected items', async () => {
wrapper = await mountWrapper();
expect(wrapper.find('Lookup')).toHaveLength(1); expect(wrapper.find('Lookup')).toHaveLength(1);
await checkInputTagValues(wrapper, ['foo']); await checkInputTagValues(wrapper, ['foo']);
done();
}); });
test('Open and close modal', async done => { test('should open and close modal', async () => {
checkRootElementNotPresent('body div[role="dialog"]'); wrapper = await mountWrapper();
wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]');
// This check couldn't pass unless api response was formatted properly
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
checkRootElementNotPresent('body div[role="dialog"]'); checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click'); wrapper.find('button[aria-label="Search"]').simulate('click');
checkRootElementPresent('body div[role="dialog"]'); checkRootElementPresent('body div[role="dialog"]');
const list = wrapper.find('TestList');
expect(list).toHaveLength(1);
expect(list.prop('state')).toEqual({
selectedItems: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
value: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
multiple: true,
isModalOpen: true,
required: false,
});
expect(list.prop('dispatch')).toBeTruthy();
expect(list.prop('canDelete')).toEqual(true);
wrapper wrapper
.find('Modal button') .find('Modal button')
.findWhere(e => e.text() === 'Cancel') .findWhere(e => e.text() === 'Cancel')
.first() .first()
.simulate('click'); .simulate('click');
checkRootElementNotPresent('body div[role="dialog"]'); checkRootElementNotPresent('body div[role="dialog"]');
done();
}); });
test('Change selected item with radio control then save', async done => { test('should remove item when X button clicked', async () => {
wrapper.find('button[aria-label="Search"]').simulate('click'); wrapper = await mountWrapper();
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]); await checkInputTagValues(wrapper, ['foo']);
await checkModalTagValues(wrapper, ['foo']);
wrapper wrapper
.find('DataListItemRow') .find('Lookup InputGroup Chip')
.findWhere(el => el.text() === 'bar') .findWhere(el => el.text() === 'foo')
.find('input[type="radio"]')
.simulate('change');
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
await checkModalTagValues(wrapper, ['bar']);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Save')
.first() .first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(1);
const [[{ name }]] = onChange.mock.calls;
expect(name).toEqual('bar');
done();
});
test('Change selected item with checkbox then cancel', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
wrapper
.find('DataListItemRow')
.findWhere(el => el.text() === 'bar')
.find('input[type="radio"]')
.simulate('change');
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
await checkModalTagValues(wrapper, ['bar']);
wrapper
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
expect(onChange).toHaveBeenCalledTimes(0);
done();
});
test('should re-fetch data when URL params change', async done => {
mockData = [{ name: 'foo', id: 1, isChecked: false }];
const history = createMemoryHistory({
initialEntries: ['/organizations/add'],
});
const getItems = jest.fn();
const LookupWrapper = mountWithContexts(
<_Lookup
multiple
name="foo"
lookupHeader="Foo Bar"
onLookupSave={() => {}}
value={mockData}
columns={mockColumns}
sortedColumnKey="name"
getItems={getItems}
location={{ history }}
i18n={{ _: val => val.toString() }}
/>
);
expect(getItems).toHaveBeenCalledTimes(1);
history.push('organizations/add?page=2');
LookupWrapper.setProps({
location: { history },
});
LookupWrapper.update();
expect(getItems).toHaveBeenCalledTimes(2);
done();
});
test('should clear its query params when closed', async () => {
mockData = [{ name: 'foo', id: 1, isChecked: false }];
const history = createMemoryHistory({
initialEntries: ['/organizations/add?inventory.name=foo&bar=baz'],
});
wrapper = mountWithContexts(
<_Lookup
multiple
name="foo"
lookupHeader="Foo Bar"
onLookupSave={() => {}}
value={mockData}
columns={mockColumns}
sortedColumnKey="name"
getItems={() => {}}
location={{ history }}
history={history}
qsNamespace="inventory"
i18n={{ _: val => val.toString() }}
/>
);
wrapper
.find('InputGroup Button')
.at(0)
.invoke('onClick')(); .invoke('onClick')();
wrapper.find('Modal').invoke('onClose')(); expect(onChange).toHaveBeenCalledTimes(1);
expect(history.location.search).toEqual('?bar=baz'); expect(onChange).toHaveBeenCalledWith([]);
});
test('should pass canDelete false if required single select', async () => {
await act(async () => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
wrapper = mountWithContexts(
<Lookup
id="test"
header="Foo Bar"
required
value={mockSelected}
onChange={onChange}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<TestList
id="options-list"
state={state}
dispatch={dispatch}
canDelete={canDelete}
/>
)}
/>
);
});
wrapper.find('button[aria-label="Search"]').simulate('click');
const list = wrapper.find('TestList');
expect(list.prop('canDelete')).toEqual(false);
}); });
}); });

View File

@@ -1,146 +1,167 @@
import React from 'react'; import React, { Fragment, useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core'; import { FormGroup, ToolbarItem } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { CredentialsAPI, CredentialTypesAPI } from '@api'; import { CredentialsAPI, CredentialTypesAPI } from '@api';
import Lookup from '@components/Lookup'; import AnsibleSelect from '@components/AnsibleSelect';
import { FieldTooltip } from '@components/FormField';
import { CredentialChip } from '@components/Chip';
import VerticalSeperator from '@components/VerticalSeparator';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` const QS_CONFIG = getQSConfig('credentials', {
margin-left: 10px; page: 1,
`; page_size: 5,
order_by: 'name',
});
class MultiCredentialsLookup extends React.Component { async function loadCredentialTypes() {
constructor(props) { const { data } = await CredentialTypesAPI.read();
super(props); const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
return data.results.filter(type => acceptableTypes.includes(type.kind));
}
this.state = { async function loadCredentials(params, selectedCredentialTypeId) {
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' }, params.credential_type = selectedCredentialTypeId || 1;
credentialTypes: [], const { data } = await CredentialsAPI.read(params);
}; return data;
this.loadCredentialTypes = this.loadCredentialTypes.bind(this); }
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
this
);
this.loadCredentials = this.loadCredentials.bind(this);
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
}
componentDidMount() { function MultiCredentialsLookup(props) {
this.loadCredentialTypes(); const { tooltip, value, onChange, onError, history, i18n } = props;
} const [credentialTypes, setCredentialTypes] = useState([]);
const [selectedType, setSelectedType] = useState(null);
const [credentials, setCredentials] = useState([]);
const [credentialsCount, setCredentialsCount] = useState(0);
async loadCredentialTypes() { useEffect(() => {
const { onError } = this.props; (async () => {
try { try {
const { data } = await CredentialTypesAPI.read(); const types = await loadCredentialTypes();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault']; setCredentialTypes(types);
const credentialTypes = []; const match = types.find(type => type.kind === 'ssh') || types[0];
data.results.forEach(cred => { setSelectedType(match);
acceptableTypes.forEach(aT => { } catch (err) {
if (aT === cred.kind) { onError(err);
// This object has several repeated values as some of it's children }
// require different field values. })();
cred = { }, [onError]);
id: cred.id,
key: cred.id,
kind: cred.kind,
type: cred.namespace,
value: cred.name,
label: cred.name,
isDisabled: false,
};
credentialTypes.push(cred);
}
});
});
this.setState({ credentialTypes });
} catch (err) {
onError(err);
}
}
async loadCredentials(params) { useEffect(() => {
const { selectedCredentialType } = this.state; (async () => {
params.credential_type = selectedCredentialType.id || 1; if (!selectedType) {
return CredentialsAPI.read(params); return;
} }
try {
const params = parseQueryString(QS_CONFIG, history.location.search);
const { results, count } = await loadCredentials(
params,
selectedType.id
);
setCredentials(results);
setCredentialsCount(count);
} catch (err) {
onError(err);
}
})();
}, [selectedType, history.location.search, onError]);
toggleCredentialSelection(newCredential) { const renderChip = ({ item, removeItem, canDelete }) => (
const { onChange, credentials: credentialsToUpdate } = this.props; <CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
);
let newCredentialsList; const isMultiple = selectedType && selectedType.kind === 'vault';
const isSelectedCredentialInState =
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
0;
if (isSelectedCredentialInState) { return (
newCredentialsList = credentialsToUpdate.filter( <FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
cred => cred.id !== newCredential.id {tooltip && <FieldTooltip content={tooltip} />}
); <Lookup
} else { id="multiCredential"
newCredentialsList = credentialsToUpdate.filter( header={i18n._(t`Credentials`)}
credential => value={value}
credential.kind === 'vault' || credential.kind !== newCredential.kind multiple
); onChange={onChange}
newCredentialsList = [...newCredentialsList, newCredential]; qsConfig={QS_CONFIG}
} renderItemChip={renderChip}
onChange(newCredentialsList); renderOptionsList={({ state, dispatch, canDelete }) => {
} return (
<Fragment>
handleCredentialTypeSelect(value, type) { {credentialTypes && credentialTypes.length > 0 && (
const { credentialTypes } = this.state; <ToolbarItem css=" display: flex; align-items: center;">
const selectedType = credentialTypes.filter(item => item.label === type); <div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
this.setState({ selectedCredentialType: selectedType[0] }); <VerticalSeperator />
} <AnsibleSelect
css="flex: 1 1 75%;"
render() { id="multiCredentialsLookUp-select"
const { selectedCredentialType, credentialTypes } = this.state; label={i18n._(t`Selected Category`)}
const { tooltip, i18n, credentials } = this.props; data={credentialTypes.map(type => ({
return ( key: type.id,
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential"> value: type.id,
{tooltip && ( label: type.name,
<Tooltip position="right" content={tooltip}> isDisabled: false,
<QuestionCircleIcon /> }))}
</Tooltip> value={selectedType && selectedType.id}
)} onChange={(e, id) => {
{credentialTypes && ( setSelectedType(
<Lookup credentialTypes.find(o => o.id === parseInt(id, 10))
selectCategoryOptions={credentialTypes} );
selectCategory={this.handleCredentialTypeSelect} }}
selectedCategory={selectedCredentialType} />
onToggleItem={this.toggleCredentialSelection} </ToolbarItem>
onloadCategories={this.loadCredentialTypes} )}
id="multiCredential" <OptionsList
lookupHeader={i18n._(t`Credentials`)} value={state.selectedItems}
name="credentials" options={credentials}
value={credentials} optionCount={credentialsCount}
multiple columns={[
onLookupSave={() => {}} {
getItems={this.loadCredentials} name: i18n._(t`Name`),
qsNamespace="credentials" key: 'name',
columns={[ isSortable: true,
{ isSearchable: true,
name: i18n._(t`Name`), },
key: 'name', ]}
isSortable: true, multiple={isMultiple}
isSearchable: true, header={i18n._(t`Credentials`)}
}, name="credentials"
]} qsConfig={QS_CONFIG}
sortedColumnKey="name" readOnly={!canDelete}
/> selectItem={item => {
)} if (isMultiple) {
</FormGroup> return dispatch({ type: 'SELECT_ITEM', item });
); }
} const selectedItems = state.selectedItems.filter(
i => i.kind !== item.kind
);
selectedItems.push(item);
return dispatch({
type: 'SET_SELECTED_ITEMS',
selectedItems,
});
}}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
renderItemChip={renderChip}
/>
</Fragment>
);
}}
/>
</FormGroup>
);
} }
MultiCredentialsLookup.propTypes = { MultiCredentialsLookup.propTypes = {
tooltip: PropTypes.string, tooltip: PropTypes.string,
credentials: PropTypes.arrayOf( value: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
name: PropTypes.string, name: PropTypes.string,
@@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = {
MultiCredentialsLookup.defaultProps = { MultiCredentialsLookup.defaultProps = {
tooltip: '', tooltip: '',
credentials: [], value: [],
}; };
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(MultiCredentialsLookup); export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(withRouter(MultiCredentialsLookup));

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import MultiCredentialsLookup from './MultiCredentialsLookup'; import MultiCredentialsLookup from './MultiCredentialsLookup';
import { CredentialsAPI, CredentialTypesAPI } from '@api'; import { CredentialsAPI, CredentialTypesAPI } from '@api';
@@ -8,9 +8,6 @@ jest.mock('@api');
describe('<MultiCredentialsLookup />', () => { describe('<MultiCredentialsLookup />', () => {
let wrapper; let wrapper;
let lookup;
let credLookup;
let onChange;
const credentials = [ const credentials = [
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
@@ -18,8 +15,9 @@ describe('<MultiCredentialsLookup />', () => {
{ name: 'Gatsby', id: 21, kind: 'vault' }, { name: 'Gatsby', id: 21, kind: 'vault' },
{ name: 'Gatsby', id: 8, kind: 'Machine' }, { name: 'Gatsby', id: 8, kind: 'Machine' },
]; ];
beforeEach(() => { beforeEach(() => {
CredentialTypesAPI.read.mockResolvedValue({ CredentialTypesAPI.read.mockResolvedValueOnce({
data: { data: {
results: [ results: [
{ {
@@ -46,17 +44,6 @@ describe('<MultiCredentialsLookup />', () => {
count: 3, count: 3,
}, },
}); });
onChange = jest.fn();
wrapper = mountWithContexts(
<MultiCredentialsLookup
onError={() => {}}
credentials={credentials}
onChange={onChange}
tooltip="This is credentials look up"
/>
);
lookup = wrapper.find('Lookup');
credLookup = wrapper.find('MultiCredentialsLookup');
}); });
afterEach(() => { afterEach(() => {
@@ -64,16 +51,40 @@ describe('<MultiCredentialsLookup />', () => {
wrapper.unmount(); wrapper.unmount();
}); });
test('MultiCredentialsLookup renders properly', () => { test('MultiCredentialsLookup renders properly', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1); expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
expect(CredentialTypesAPI.read).toHaveBeenCalled(); expect(CredentialTypesAPI.read).toHaveBeenCalled();
}); });
test('onChange is called when you click to remove a credential from input', async () => { test('onChange is called when you click to remove a credential from input', async () => {
const chip = wrapper.find('PFChip').find({ isOverflowChip: false }); const onChange = jest.fn();
const button = chip.at(1).find('ChipButton'); await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
const chip = wrapper.find('CredentialChip');
expect(chip).toHaveLength(4); expect(chip).toHaveLength(4);
button.prop('onClick')(); const button = chip.at(1).find('ChipButton');
await act(async () => {
button.invoke('onClick')();
});
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' }, { id: 21, kind: 'vault', name: 'Gatsby' },
@@ -81,33 +92,122 @@ describe('<MultiCredentialsLookup />', () => {
]); ]);
}); });
test('can change credential types', () => { test('should change credential types', async () => {
lookup.prop('selectCategory')({}, 'Vault'); await act(async () => {
expect(credLookup.state('selectedCredentialType')).toEqual({ wrapper = mountWithContexts(
id: 500, <MultiCredentialsLookup
key: 500, value={credentials}
kind: 'vault', tooltip="This is credentials look up"
type: 'buzz', onChange={() => {}}
value: 'Vault', onError={() => {}}
label: 'Vault', />
isDisabled: false, );
}); });
expect(CredentialsAPI.read).toHaveBeenCalled(); const searchButton = await waitForElement(wrapper, 'SearchButton');
await act(async () => {
searchButton.invoke('onClick')();
});
const select = await waitForElement(wrapper, 'AnsibleSelect');
CredentialsAPI.read.mockResolvedValueOnce({
data: {
results: [
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
],
count: 1,
},
});
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
await act(async () => {
select.invoke('onChange')({}, 500);
});
wrapper.update();
expect(CredentialsAPI.read).toHaveBeenCalledTimes(3);
expect(wrapper.find('OptionsList').prop('options')).toEqual([
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
]);
}); });
test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => {
lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' }); test('should only add 1 credential per credential type except vault(see below)', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
const searchButton = await waitForElement(wrapper, 'SearchButton');
await act(async () => {
searchButton.invoke('onClick')();
});
wrapper.update();
const optionsList = wrapper.find('OptionsList');
expect(optionsList.prop('multiple')).toEqual(false);
act(() => {
optionsList.invoke('selectItem')({
id: 5,
kind: 'Machine',
name: 'Cred 5',
url: 'www.google.com',
});
});
wrapper.update();
act(() => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' }, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' }, { id: 21, kind: 'vault', name: 'Gatsby' },
{ id: 9, kind: 'Machine', name: 'Party' }, { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]); ]);
}); });
test('Toggle credentials only adds 1 credential per credential type', () => {
lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' }); test('should allow multiple vault credentials', async () => {
const onChange = jest.fn();
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={onChange}
onError={() => {}}
/>
);
});
const searchButton = await waitForElement(wrapper, 'SearchButton');
await act(async () => {
searchButton.invoke('onClick')();
});
wrapper.update();
const typeSelect = wrapper.find('AnsibleSelect');
act(() => {
typeSelect.invoke('onChange')({}, 500);
});
wrapper.update();
const optionsList = wrapper.find('OptionsList');
expect(optionsList.prop('multiple')).toEqual(true);
act(() => {
optionsList.invoke('selectItem')({
id: 5,
kind: 'Machine',
name: 'Cred 5',
url: 'www.google.com',
});
});
wrapper.update();
act(() => {
wrapper.find('Button[variant="primary"]').invoke('onClick')();
});
expect(onChange).toBeCalledWith([ expect(onChange).toBeCalledWith([
...credentials, { id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ name: 'Party', id: 22, kind: 'vault' }, { id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' },
{ id: 8, kind: 'Machine', name: 'Gatsby' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
]); ]);
}); });
}); });

View File

@@ -1,13 +1,21 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { string, func, bool } from 'prop-types';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
import { Organization } from '@types'; import { Organization } from '@types';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup'; import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
const getOrganizations = async params => OrganizationsAPI.read(params); const QS_CONFIG = getQSConfig('organizations', {
page: 1,
page_size: 5,
order_by: 'name',
});
function OrganizationLookup({ function OrganizationLookup({
helperTextInvalid, helperTextInvalid,
@@ -17,7 +25,25 @@ function OrganizationLookup({
onChange, onChange,
required, required,
value, value,
history,
}) { }) {
const [organizations, setOrganizations] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await OrganizationsAPI.read(params);
setOrganizations(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
return ( return (
<FormGroup <FormGroup
fieldId="organization" fieldId="organization"
@@ -28,15 +54,29 @@ function OrganizationLookup({
> >
<Lookup <Lookup
id="organization" id="organization"
lookupHeader={i18n._(t`Organization`)} header={i18n._(t`Organization`)}
name="organization"
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onLookupSave={onChange} onChange={onChange}
getItems={getOrganizations} qsConfig={QS_CONFIG}
required={required} required={required}
sortedColumnKey="name" sortedColumnKey="name"
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={organizations}
optionCount={count}
multiple={state.multiple}
header={i18n._(t`Organization`)}
name="organization"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/> />
<LookupErrorMessage error={error} />
</FormGroup> </FormGroup>
); );
} }
@@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = {
value: null, value: null,
}; };
export default withI18n()(OrganizationLookup);
export { OrganizationLookup as _OrganizationLookup }; export { OrganizationLookup as _OrganizationLookup };
export default withI18n()(withRouter(OrganizationLookup));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup'; import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
@@ -8,18 +9,22 @@ jest.mock('@api');
describe('OrganizationLookup', () => { describe('OrganizationLookup', () => {
let wrapper; let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
wrapper.unmount();
}); });
test('initially renders successfully', () => { test('should render successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
test('should fetch organizations', () => {
test('should fetch organizations', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
expect(OrganizationsAPI.read).toHaveBeenCalledWith({ expect(OrganizationsAPI.read).toHaveBeenCalledWith({
order_by: 'name', order_by: 'name',
@@ -27,11 +32,19 @@ describe('OrganizationLookup', () => {
page_size: 5, page_size: 5,
}); });
}); });
test('should display "Organization" label', () => {
test('should display "Organization" label', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
const title = wrapper.find('FormGroup .pf-c-form__label-text'); const title = wrapper.find('FormGroup .pf-c-form__label-text');
expect(title.text()).toEqual('Organization'); expect(title.text()).toEqual('Organization');
}); });
test('should define default value for function props', () => {
test('should define default value for function props', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function); expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow(); expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
}); });

View File

@@ -1,59 +1,90 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { string, func, bool } from 'prop-types'; import { string, func, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
import { Project } from '@types'; import { Project } from '@types';
import Lookup from '@components/Lookup';
import { FieldTooltip } from '@components/FormField'; import { FieldTooltip } from '@components/FormField';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './Lookup';
import OptionsList from './shared/OptionsList';
import LookupErrorMessage from './shared/LookupErrorMessage';
class ProjectLookup extends React.Component { const QS_CONFIG = getQSConfig('project', {
render() { page: 1,
const { page_size: 5,
helperTextInvalid, order_by: 'name',
i18n, });
isValid,
onChange,
required,
tooltip,
value,
onBlur,
} = this.props;
const loadProjects = async params => { function ProjectLookup({
const response = await ProjectsAPI.read(params); helperTextInvalid,
const { results, count } = response.data; i18n,
if (count === 1) { isValid,
onChange(results[0], 'project'); onChange,
required,
tooltip,
value,
onBlur,
history,
}) {
const [projects, setProjects] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await ProjectsAPI.read(params);
setProjects(data.results);
setCount(data.count);
if (data.count === 1) {
onChange(data.results[0]);
}
} catch (err) {
setError(err);
} }
return response; })();
}; }, [onChange, history.location]);
return ( return (
<FormGroup <FormGroup
fieldId="project" fieldId="project"
helperTextInvalid={helperTextInvalid} helperTextInvalid={helperTextInvalid}
isRequired={required} isRequired={required}
isValid={isValid} isValid={isValid}
label={i18n._(t`Project`)} label={i18n._(t`Project`)}
> >
{tooltip && <FieldTooltip content={tooltip} />} {tooltip && <FieldTooltip content={tooltip} />}
<Lookup <Lookup
id="project" id="project"
lookupHeader={i18n._(t`Project`)} header={i18n._(t`Project`)}
name="project" name="project"
value={value} value={value}
onBlur={onBlur} onBlur={onBlur}
onLookupSave={onChange} onChange={onChange}
getItems={loadProjects} required={required}
required={required} qsConfig={QS_CONFIG}
sortedColumnKey="name" renderOptionsList={({ state, dispatch, canDelete }) => (
qsNamespace="project" <OptionsList
/> value={state.selectedItems}
</FormGroup> options={projects}
); optionCount={count}
} multiple={state.multiple}
header={i18n._(t`Project`)}
name="project"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FormGroup>
);
} }
ProjectLookup.propTypes = { ProjectLookup.propTypes = {
@@ -75,4 +106,5 @@ ProjectLookup.defaultProps = {
onBlur: () => {}, onBlur: () => {},
}; };
export default withI18n()(ProjectLookup); export { ProjectLookup as _ProjectLookup };
export default withI18n()(withRouter(ProjectLookup));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import { ProjectsAPI } from '@api'; import { ProjectsAPI } from '@api';
@@ -15,9 +16,11 @@ describe('<ProjectLookup />', () => {
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />); await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0); await sleep(0);
expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project'); expect(onChange).toHaveBeenCalledWith({ id: 1 });
}); });
test('should not auto-select project when multiple available', async () => { test('should not auto-select project when multiple available', async () => {
@@ -28,7 +31,9 @@ describe('<ProjectLookup />', () => {
}, },
}); });
const onChange = jest.fn(); const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />); await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0); await sleep(0);
expect(onChange).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
}); });

View File

@@ -0,0 +1,5 @@
# Lookup
required single select lookups should not include a close X on the tag... you would have to select something else to change it
optional single select lookups should include a close X to remove it on the spot

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
function LookupErrorMessage({ error, i18n }) {
if (!error) {
return null;
}
return (
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
{error.message || i18n._(t`An error occured`)}
</div>
);
}
export default withI18n()(LookupErrorMessage);

View File

@@ -0,0 +1,95 @@
import React from 'react';
import {
arrayOf,
shape,
bool,
func,
number,
string,
oneOfType,
} from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import SelectedList from '../../SelectedList';
import PaginatedDataList from '../../PaginatedDataList';
import CheckboxListItem from '../../CheckboxListItem';
import DataListToolbar from '../../DataListToolbar';
import { QSConfig } from '@types';
function OptionsList({
value,
options,
optionCount,
columns,
multiple,
header,
name,
qsConfig,
readOnly,
selectItem,
deselectItem,
renderItemChip,
isLoading,
i18n,
}) {
return (
<div>
{value.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={value}
showOverflowAfter={5}
onRemove={item => deselectItem(item)}
isReadOnly={readOnly}
renderItemChip={renderItemChip}
/>
)}
<PaginatedDataList
items={options}
itemCount={optionCount}
pluralizedItemName={header}
qsConfig={qsConfig}
toolbarColumns={columns}
hasContentLoading={isLoading}
renderItem={item => (
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
isSelected={value.some(i => i.id === item.id)}
onSelect={() => selectItem(item)}
onDeselect={() => deselectItem(item)}
isRadio={!multiple}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</div>
);
}
const Item = shape({
id: oneOfType([number, string]).isRequired,
name: string.isRequired,
url: string,
});
OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
options: arrayOf(Item).isRequired,
optionCount: number.isRequired,
columns: arrayOf(shape({})),
multiple: bool,
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
deselectItem: func.isRequired,
renderItemChip: func,
};
OptionsList.defaultProps = {
multiple: false,
renderItemChip: null,
columns: [],
};
export default withI18n()(OptionsList);

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { getQSConfig } from '@util/qs';
import OptionsList from './OptionsList';
const qsConfig = getQSConfig('test', {});
describe('<OptionsList />', () => {
it('should display list of options', () => {
const options = [
{ id: 1, name: 'foo', url: '/item/1' },
{ id: 2, name: 'bar', url: '/item/2' },
{ id: 3, name: 'baz', url: '/item/3' },
];
const wrapper = mountWithContexts(
<OptionsList
value={[]}
options={options}
optionCount={3}
columns={[]}
qsConfig={qsConfig}
selectItem={() => {}}
deselectItem={() => {}}
name="Item"
/>
);
expect(wrapper.find('PaginatedDataList').prop('items')).toEqual(options);
expect(wrapper.find('SelectedList')).toHaveLength(0);
});
it('should render selected list', () => {
const options = [
{ id: 1, name: 'foo', url: '/item/1' },
{ id: 2, name: 'bar', url: '/item/2' },
{ id: 3, name: 'baz', url: '/item/3' },
];
const wrapper = mountWithContexts(
<OptionsList
value={[options[1]]}
options={options}
optionCount={3}
columns={[]}
qsConfig={qsConfig}
selectItem={() => {}}
deselectItem={() => {}}
name="Item"
/>
);
const list = wrapper.find('SelectedList');
expect(list).toHaveLength(1);
expect(list.prop('selected')).toEqual([options[1]]);
});
});

View File

@@ -0,0 +1,96 @@
export default function reducer(state, action) {
switch (action.type) {
case 'SELECT_ITEM':
return selectItem(state, action.item);
case 'DESELECT_ITEM':
return deselectItem(state, action.item);
case 'TOGGLE_MODAL':
return toggleModal(state);
case 'CLOSE_MODAL':
return closeModal(state);
case 'SET_MULTIPLE':
return { ...state, multiple: action.value };
case 'SET_VALUE':
return { ...state, value: action.value };
case 'SET_SELECTED_ITEMS':
return { ...state, selectedItems: action.selectedItems };
default:
throw new Error(`Unrecognized action type: ${action.type}`);
}
}
function selectItem(state, item) {
const { selectedItems, multiple } = state;
if (!multiple) {
return {
...state,
selectedItems: [item],
};
}
const index = selectedItems.findIndex(i => i.id === item.id);
if (index > -1) {
return state;
}
return {
...state,
selectedItems: [...selectedItems, item],
};
}
function deselectItem(state, item) {
return {
...state,
selectedItems: state.selectedItems.filter(i => i.id !== item.id),
};
}
function toggleModal(state) {
const { isModalOpen, value, multiple } = state;
if (isModalOpen) {
return closeModal(state);
}
let selectedItems = [];
if (multiple) {
selectedItems = [...value];
} else if (value) {
selectedItems.push(value);
}
return {
...state,
isModalOpen: !isModalOpen,
selectedItems,
};
}
function closeModal(state) {
return {
...state,
isModalOpen: false,
};
}
export function initReducer({ value, multiple = false, required = false }) {
assertCorrectValueType(value, multiple);
let selectedItems = [];
if (value) {
selectedItems = multiple ? [...value] : [value];
}
return {
selectedItems,
value,
multiple,
isModalOpen: false,
required,
};
}
function assertCorrectValueType(value, multiple) {
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');
}
}

View File

@@ -0,0 +1,280 @@
import reducer, { initReducer } from './reducer';
describe('Lookup reducer', () => {
describe('SELECT_ITEM', () => {
it('should add item to selected items (multiple select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 2 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
});
});
it('should not duplicate item if already selected (multiple select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }],
multiple: true,
});
});
it('should replace selected item (single select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: false,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 2 },
});
expect(result).toEqual({
selectedItems: [{ id: 2 }],
multiple: false,
});
});
it('should not duplicate item if already selected (single select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: false,
};
const result = reducer(state, {
type: 'SELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }],
multiple: false,
});
});
});
describe('DESELECT_ITEM', () => {
it('should de-select item (multiple)', () => {
const state = {
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
};
const result = reducer(state, {
type: 'DESELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [{ id: 2 }],
multiple: true,
});
});
it('should not change list if item not selected (multiple)', () => {
const state = {
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
};
const result = reducer(state, {
type: 'DESELECT_ITEM',
item: { id: 3 },
});
expect(result).toEqual({
selectedItems: [{ id: 1 }, { id: 2 }],
multiple: true,
});
});
it('should de-select item (single select)', () => {
const state = {
selectedItems: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'DESELECT_ITEM',
item: { id: 1 },
});
expect(result).toEqual({
selectedItems: [],
multiple: true,
});
});
});
describe('TOGGLE_MODAL', () => {
it('should open the modal (single)', () => {
const state = {
isModalOpen: false,
selectedItems: [],
value: { id: 1 },
multiple: false,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: { id: 1 },
multiple: false,
});
});
it('should set null value to empty array', () => {
const state = {
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: null,
multiple: false,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: true,
selectedItems: [],
value: null,
multiple: false,
});
});
it('should open the modal (multiple)', () => {
const state = {
isModalOpen: false,
selectedItems: [],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
it('should close the modal', () => {
const state = {
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'TOGGLE_MODAL',
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
});
describe('CLOSE_MODAL', () => {
it('should close the modal', () => {
const state = {
isModalOpen: true,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'CLOSE_MODAL',
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
});
describe('SET_MULTIPLE', () => {
it('should set multiple to true', () => {
const state = {
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: false,
};
const result = reducer(state, {
type: 'SET_MULTIPLE',
value: true,
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
});
});
it('should set multiple to false', () => {
const state = {
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SET_MULTIPLE',
value: false,
});
expect(result).toEqual({
isModalOpen: false,
selectedItems: [{ id: 1 }],
value: [{ id: 1 }],
multiple: false,
});
});
});
describe('SET_VALUE', () => {
it('should set the value', () => {
const state = {
value: [{ id: 1 }],
multiple: true,
};
const result = reducer(state, {
type: 'SET_VALUE',
value: [{ id: 3 }],
});
expect(result).toEqual({
value: [{ id: 3 }],
multiple: true,
});
});
});
});
describe('initReducer', () => {
it('should init', () => {
const state = initReducer({
value: [],
multiple: true,
required: true,
});
expect(state).toEqual({
selectedItems: [],
value: [],
multiple: true,
isModalOpen: false,
required: true,
});
});
});

View File

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Split as PFSplit, SplitItem } from '@patternfly/react-core'; import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { ChipGroup, Chip, CredentialChip } from '../Chip'; import { ChipGroup, Chip } from '../Chip';
import VerticalSeparator from '../VerticalSeparator'; import VerticalSeparator from '../VerticalSeparator';
const Split = styled(PFSplit)` const Split = styled(PFSplit)`
@@ -26,34 +26,31 @@ class SelectedList extends Component {
onRemove, onRemove,
displayKey, displayKey,
isReadOnly, isReadOnly,
isCredentialList, renderItemChip,
} = this.props; } = this.props;
const chips = isCredentialList
? selected.map(item => ( const renderChip =
<CredentialChip renderItemChip ||
key={item.id} (({ item, removeItem }) => (
isReadOnly={isReadOnly} <Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}>
onClick={() => onRemove(item)} {item[displayKey]}
credential={item} </Chip>
> ));
{item[displayKey]}
</CredentialChip>
))
: selected.map(item => (
<Chip
key={item.id}
isReadOnly={isReadOnly}
onClick={() => onRemove(item)}
>
{item[displayKey]}
</Chip>
));
return ( return (
<Split> <Split>
<SplitLabelItem>{label}</SplitLabelItem> <SplitLabelItem>{label}</SplitLabelItem>
<VerticalSeparator /> <VerticalSeparator />
<SplitItem> <SplitItem>
<ChipGroup numChips={5}>{chips}</ChipGroup> <ChipGroup numChips={5}>
{selected.map(item =>
renderChip({
item,
removeItem: () => onRemove(item),
canDelete: !isReadOnly,
})
)}
</ChipGroup>
</SplitItem> </SplitItem>
</Split> </Split>
); );
@@ -66,6 +63,7 @@ SelectedList.propTypes = {
onRemove: PropTypes.func, onRemove: PropTypes.func,
selected: PropTypes.arrayOf(PropTypes.object).isRequired, selected: PropTypes.arrayOf(PropTypes.object).isRequired,
isReadOnly: PropTypes.bool, isReadOnly: PropTypes.bool,
renderItemChip: PropTypes.func,
}; };
SelectedList.defaultProps = { SelectedList.defaultProps = {
@@ -73,6 +71,7 @@ SelectedList.defaultProps = {
label: 'Selected', label: 'Selected',
onRemove: () => null, onRemove: () => null,
isReadOnly: false, isReadOnly: false,
renderItemChip: null,
}; };
export default SelectedList; export default SelectedList;

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import HostAdd from './HostAdd'; import HostAdd from './HostAdd';
@@ -7,8 +8,11 @@ import { HostsAPI } from '@api';
jest.mock('@api'); jest.mock('@api');
describe('<HostAdd />', () => { describe('<HostAdd />', () => {
test('handleSubmit should post to api', () => { test('handleSubmit should post to api', async () => {
const wrapper = mountWithContexts(<HostAdd />); let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />);
});
const updatedHostData = { const updatedHostData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
@@ -19,21 +23,27 @@ describe('<HostAdd />', () => {
expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData); expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData);
}); });
test('should navigate to hosts list when cancel is clicked', () => { test('should navigate to hosts list when cancel is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const wrapper = mountWithContexts(<HostAdd />, { let wrapper;
context: { router: { history } }, await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
}); });
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(history.location.pathname).toEqual('/hosts'); expect(history.location.pathname).toEqual('/hosts');
}); });
test('should navigate to hosts list when close (x) is clicked', () => { test('should navigate to hosts list when close (x) is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const wrapper = mountWithContexts(<HostAdd />, { let wrapper;
context: { router: { history } }, await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
}); });
wrapper.find('button[aria-label="Close"]').prop('onClick')(); wrapper.find('button[aria-label="Close"]').invoke('onClick')();
expect(history.location.pathname).toEqual('/hosts'); expect(history.location.pathname).toEqual('/hosts');
}); });
@@ -51,11 +61,14 @@ describe('<HostAdd />', () => {
...hostData, ...hostData,
}, },
}); });
const wrapper = mountWithContexts(<HostAdd />, { let wrapper;
context: { router: { history } }, await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('HostForm').prop('handleSubmit')(hostData); await wrapper.find('HostForm').invoke('handleSubmit')(hostData);
expect(history.location.pathname).toEqual('/hosts/5'); expect(history.location.pathname).toEqual('/hosts/5');
}); });
}); });

View File

@@ -27,22 +27,24 @@ describe('<OrganizationAdd />', () => {
test('should navigate to organizations list when cancel is clicked', async () => { test('should navigate to organizations list when cancel is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
let wrapper;
await act(async () => { await act(async () => {
const wrapper = mountWithContexts(<OrganizationAdd />, { wrapper = mountWithContexts(<OrganizationAdd />, {
context: { router: { history } }, context: { router: { history } },
}); });
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
}); });
expect(history.location.pathname).toEqual('/organizations'); expect(history.location.pathname).toEqual('/organizations');
}); });
test('should navigate to organizations list when close (x) is clicked', async () => { test('should navigate to organizations list when close (x) is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
let wrapper;
await act(async () => { await act(async () => {
const wrapper = mountWithContexts(<OrganizationAdd />, { wrapper = mountWithContexts(<OrganizationAdd />, {
context: { router: { history } }, context: { router: { history } },
}); });
wrapper.find('button[aria-label="Close"]').prop('onClick')(); wrapper.find('button[aria-label="Close"]').invoke('onClick')();
}); });
expect(history.location.pathname).toEqual('/organizations'); expect(history.location.pathname).toEqual('/organizations');
}); });
@@ -63,8 +65,9 @@ describe('<OrganizationAdd />', () => {
...orgData, ...orgData,
}, },
}); });
let wrapper;
await act(async () => { await act(async () => {
const wrapper = mountWithContexts(<OrganizationAdd />, { wrapper = mountWithContexts(<OrganizationAdd />, {
context: { router: { history } }, context: { router: { history } },
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
@@ -92,23 +95,27 @@ describe('<OrganizationAdd />', () => {
...orgData, ...orgData,
}, },
}); });
let wrapper;
await act(async () => { await act(async () => {
const wrapper = mountWithContexts(<OrganizationAdd />); wrapper = mountWithContexts(<OrganizationAdd />);
await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('handleSubmit')(
orgData,
[3],
[]
);
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('OrganizationForm').prop('handleSubmit')(
orgData,
[3],
[]
);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
}); });
test('AnsibleSelect component renders if there are virtual environments', async () => { test('AnsibleSelect component renders if there are virtual environments', async () => {
const config = {
custom_virtualenvs: ['foo', 'bar'],
};
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationAdd />, { wrapper = mountWithContexts(<OrganizationAdd />, {
context: { config: { custom_virtualenvs: ['foo', 'bar'] } }, context: { config },
}).find('AnsibleSelect'); }).find('AnsibleSelect');
}); });
expect(wrapper.find('FormSelect')).toHaveLength(1); expect(wrapper.find('FormSelect')).toHaveLength(1);
@@ -122,10 +129,13 @@ describe('<OrganizationAdd />', () => {
}); });
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => { test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
const config = {
custom_virtualenvs: [],
};
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<OrganizationAdd />, { wrapper = mountWithContexts(<OrganizationAdd />, {
context: { config: { custom_virtualenvs: [] } }, context: { config },
}).find('AnsibleSelect'); }).find('AnsibleSelect');
}); });
expect(wrapper.find('FormSelect')).toHaveLength(0); expect(wrapper.find('FormSelect')).toHaveLength(0);

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
@@ -6,8 +7,6 @@ import OrganizationEdit from './OrganizationEdit';
jest.mock('@api'); jest.mock('@api');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
describe('<OrganizationEdit />', () => { describe('<OrganizationEdit />', () => {
const mockData = { const mockData = {
name: 'Foo', name: 'Foo',
@@ -19,10 +18,11 @@ describe('<OrganizationEdit />', () => {
}, },
}; };
test('handleSubmit should call api update', () => { test('handleSubmit should call api update', async () => {
const wrapper = mountWithContexts( let wrapper;
<OrganizationEdit organization={mockData} /> await act(async () => {
); wrapper = mountWithContexts(<OrganizationEdit organization={mockData} />);
});
const updatedOrgData = { const updatedOrgData = {
name: 'new name', name: 'new name',
@@ -39,21 +39,23 @@ describe('<OrganizationEdit />', () => {
}); });
test('handleSubmit associates and disassociates instance groups', async () => { test('handleSubmit associates and disassociates instance groups', async () => {
const wrapper = mountWithContexts( let wrapper;
<OrganizationEdit organization={mockData} /> await act(async () => {
); wrapper = mountWithContexts(<OrganizationEdit organization={mockData} />);
});
const updatedOrgData = { const updatedOrgData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
custom_virtualenv: 'Buzz', custom_virtualenv: 'Buzz',
}; };
wrapper.find('OrganizationForm').prop('handleSubmit')( await act(async () => {
updatedOrgData, wrapper.find('OrganizationForm').invoke('handleSubmit')(
[3, 4], updatedOrgData,
[2] [3, 4],
); [2]
await sleep(1); );
});
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4); expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
@@ -63,14 +65,17 @@ describe('<OrganizationEdit />', () => {
); );
}); });
test('should navigate to organization detail when cancel is clicked', () => { test('should navigate to organization detail when cancel is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const wrapper = mountWithContexts( let wrapper;
<OrganizationEdit organization={mockData} />, await act(async () => {
{ context: { router: { history } } } wrapper = mountWithContexts(
); <OrganizationEdit organization={mockData} />,
{ context: { router: { history } } }
);
});
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
expect(history.location.pathname).toEqual('/organizations/1/details'); expect(history.location.pathname).toEqual('/organizations/1/details');
}); });

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils'; import { sleep } from '@testUtils/testUtils';
import { OrganizationsAPI } from '@api'; import { OrganizationsAPI } from '@api';
@@ -30,18 +30,20 @@ describe('<OrganizationForm />', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('should request related instance groups from api', () => { test('should request related instance groups from api', async () => {
mountWithContexts( await act(async () => {
<OrganizationForm mountWithContexts(
organization={mockData} <OrganizationForm
handleSubmit={jest.fn()} organization={mockData}
handleCancel={jest.fn()} handleSubmit={jest.fn()}
me={meConfig.me} handleCancel={jest.fn()}
/>, me={meConfig.me}
{ />,
context: { network }, {
} context: { network },
); }
);
});
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
}); });
@@ -53,34 +55,39 @@ describe('<OrganizationForm />', () => {
results: mockInstanceGroups, results: mockInstanceGroups,
}, },
}); });
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={jest.fn()} <OrganizationForm
handleCancel={jest.fn()} organization={mockData}
me={meConfig.me} handleSubmit={jest.fn()}
/>, handleCancel={jest.fn()}
{ me={meConfig.me}
context: { network }, />,
} {
); context: { network },
}
);
});
await sleep(0);
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled(); expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled();
expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual( expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual(
mockInstanceGroups mockInstanceGroups
); );
}); });
test('changing instance group successfully sets instanceGroups state', () => { test('changing instance group successfully sets instanceGroups state', async () => {
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={jest.fn()} <OrganizationForm
handleCancel={jest.fn()} organization={mockData}
me={meConfig.me} handleSubmit={jest.fn()}
/> handleCancel={jest.fn()}
); me={meConfig.me}
/>
);
});
const lookup = wrapper.find('InstanceGroupsLookup'); const lookup = wrapper.find('InstanceGroupsLookup');
expect(lookup.length).toBe(1); expect(lookup.length).toBe(1);
@@ -102,15 +109,18 @@ describe('<OrganizationForm />', () => {
]); ]);
}); });
test('changing inputs should update form values', () => { test('changing inputs should update form values', async () => {
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={jest.fn()} <OrganizationForm
handleCancel={jest.fn()} organization={mockData}
me={meConfig.me} handleSubmit={jest.fn()}
/> handleCancel={jest.fn()}
); me={meConfig.me}
/>
);
});
const form = wrapper.find('Formik'); const form = wrapper.find('Formik');
wrapper.find('input#org-name').simulate('change', { wrapper.find('input#org-name').simulate('change', {
@@ -127,21 +137,24 @@ describe('<OrganizationForm />', () => {
expect(form.state('values').max_hosts).toEqual('134'); expect(form.state('values').max_hosts).toEqual('134');
}); });
test('AnsibleSelect component renders if there are virtual environments', () => { test('AnsibleSelect component renders if there are virtual environments', async () => {
const config = { const config = {
custom_virtualenvs: ['foo', 'bar'], custom_virtualenvs: ['foo', 'bar'],
}; };
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={jest.fn()} <OrganizationForm
handleCancel={jest.fn()} organization={mockData}
me={meConfig.me} handleSubmit={jest.fn()}
/>, handleCancel={jest.fn()}
{ me={meConfig.me}
context: { config }, />,
} {
); context: { config },
}
);
});
expect(wrapper.find('FormSelect')).toHaveLength(1); expect(wrapper.find('FormSelect')).toHaveLength(1);
expect(wrapper.find('FormSelectOption')).toHaveLength(3); expect(wrapper.find('FormSelectOption')).toHaveLength(3);
expect( expect(
@@ -154,14 +167,17 @@ describe('<OrganizationForm />', () => {
test('calls handleSubmit when form submitted', async () => { test('calls handleSubmit when form submitted', async () => {
const handleSubmit = jest.fn(); const handleSubmit = jest.fn();
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={handleSubmit} <OrganizationForm
handleCancel={jest.fn()} organization={mockData}
me={meConfig.me} handleSubmit={handleSubmit}
/> handleCancel={jest.fn()}
); me={meConfig.me}
/>
);
});
expect(handleSubmit).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(1); await sleep(1);
@@ -194,18 +210,20 @@ describe('<OrganizationForm />', () => {
OrganizationsAPI.update.mockResolvedValue(1, mockDataForm); OrganizationsAPI.update.mockResolvedValue(1, mockDataForm);
OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done'); OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done');
OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done'); OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done');
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={handleSubmit} <OrganizationForm
handleCancel={jest.fn()} organization={mockData}
me={meConfig.me} handleSubmit={handleSubmit}
/>, handleCancel={jest.fn()}
{ me={meConfig.me}
context: { network }, />,
} {
); context: { network },
await sleep(0); }
);
});
wrapper.find('InstanceGroupsLookup').prop('onChange')( wrapper.find('InstanceGroupsLookup').prop('onChange')(
[{ name: 'One', id: 1 }, { name: 'Three', id: 3 }], [{ name: 'One', id: 1 }, { name: 'Three', id: 3 }],
'instanceGroups' 'instanceGroups'
@@ -219,15 +237,17 @@ describe('<OrganizationForm />', () => {
test('handleSubmit is called with max_hosts value if it is in range', async () => { test('handleSubmit is called with max_hosts value if it is in range', async () => {
const handleSubmit = jest.fn(); const handleSubmit = jest.fn();
// normal mount let wrapper;
const wrapper = mountWithContexts( await act(async () => {
<OrganizationForm wrapper = mountWithContexts(
organization={mockData} <OrganizationForm
handleSubmit={handleSubmit} organization={mockData}
handleCancel={jest.fn()} handleSubmit={handleSubmit}
me={meConfig.me} handleCancel={jest.fn()}
/> me={meConfig.me}
); />
);
});
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(0); await sleep(0);
expect(handleSubmit).toHaveBeenCalledWith( expect(handleSubmit).toHaveBeenCalledWith(
@@ -245,32 +265,38 @@ describe('<OrganizationForm />', () => {
test('handleSubmit does not get called if max_hosts value is out of range', async () => { test('handleSubmit does not get called if max_hosts value is out of range', async () => {
const handleSubmit = jest.fn(); const handleSubmit = jest.fn();
// not mount with Negative value // mount with negative value
let wrapper1;
const mockDataNegative = JSON.parse(JSON.stringify(mockData)); const mockDataNegative = JSON.parse(JSON.stringify(mockData));
mockDataNegative.max_hosts = -5; mockDataNegative.max_hosts = -5;
const wrapper1 = mountWithContexts( await act(async () => {
<OrganizationForm wrapper1 = mountWithContexts(
organization={mockDataNegative} <OrganizationForm
handleSubmit={handleSubmit} organization={mockDataNegative}
handleCancel={jest.fn()} handleSubmit={handleSubmit}
me={meConfig.me} handleCancel={jest.fn()}
/> me={meConfig.me}
); />
);
});
wrapper1.find('button[aria-label="Save"]').simulate('click'); wrapper1.find('button[aria-label="Save"]').simulate('click');
await sleep(0); await sleep(0);
expect(handleSubmit).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled();
// not mount with Out of Range value // mount with out of range value
let wrapper2;
const mockDataOoR = JSON.parse(JSON.stringify(mockData)); const mockDataOoR = JSON.parse(JSON.stringify(mockData));
mockDataOoR.max_hosts = 999999999999; mockDataOoR.max_hosts = 999999999999;
const wrapper2 = mountWithContexts( await act(async () => {
<OrganizationForm wrapper2 = mountWithContexts(
organization={mockDataOoR} <OrganizationForm
handleSubmit={handleSubmit} organization={mockDataOoR}
handleCancel={jest.fn()} handleSubmit={handleSubmit}
me={meConfig.me} handleCancel={jest.fn()}
/> me={meConfig.me}
); />
);
});
wrapper2.find('button[aria-label="Save"]').simulate('click'); wrapper2.find('button[aria-label="Save"]').simulate('click');
await sleep(0); await sleep(0);
expect(handleSubmit).not.toHaveBeenCalled(); expect(handleSubmit).not.toHaveBeenCalled();
@@ -282,14 +308,17 @@ describe('<OrganizationForm />', () => {
// mount with String value (default to zero) // mount with String value (default to zero)
const mockDataString = JSON.parse(JSON.stringify(mockData)); const mockDataString = JSON.parse(JSON.stringify(mockData));
mockDataString.max_hosts = 'Bee'; mockDataString.max_hosts = 'Bee';
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockDataString} wrapper = mountWithContexts(
handleSubmit={handleSubmit} <OrganizationForm
handleCancel={jest.fn()} organization={mockDataString}
me={meConfig.me} handleSubmit={handleSubmit}
/> handleCancel={jest.fn()}
); me={meConfig.me}
/>
);
});
wrapper.find('button[aria-label="Save"]').simulate('click'); wrapper.find('button[aria-label="Save"]').simulate('click');
await sleep(0); await sleep(0);
expect(handleSubmit).toHaveBeenCalledWith( expect(handleSubmit).toHaveBeenCalledWith(
@@ -304,17 +333,20 @@ describe('<OrganizationForm />', () => {
); );
}); });
test('calls "handleCancel" when Cancel button is clicked', () => { test('calls "handleCancel" when Cancel button is clicked', async () => {
const handleCancel = jest.fn(); const handleCancel = jest.fn();
const wrapper = mountWithContexts( let wrapper;
<OrganizationForm await act(async () => {
organization={mockData} wrapper = mountWithContexts(
handleSubmit={jest.fn()} <OrganizationForm
handleCancel={handleCancel} organization={mockData}
me={meConfig.me} handleSubmit={jest.fn()}
/> handleCancel={handleCancel}
); me={meConfig.me}
/>
);
});
expect(handleCancel).not.toHaveBeenCalled(); expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled(); expect(handleCancel).toBeCalled();

View File

@@ -98,17 +98,19 @@ describe('<ProjectAdd />', () => {
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const formik = wrapper.find('Formik').instance(); const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => { await act(async () => {
formik.setState( const changeState = new Promise(resolve => {
{ formik.setState(
values: { {
...projectData, values: {
...projectData,
},
}, },
}, () => resolve()
() => resolve() );
); });
await changeState;
}); });
await changeState;
await act(async () => { await act(async () => {
wrapper.find('form').simulate('submit'); wrapper.find('form').simulate('submit');
}); });
@@ -146,7 +148,9 @@ describe('<ProjectAdd />', () => {
context: { router: { history } }, context: { router: { history } },
}).find('ProjectAdd CardHeader'); }).find('ProjectAdd CardHeader');
}); });
wrapper.find('CardCloseButton').simulate('click'); await act(async () => {
wrapper.find('CardCloseButton').simulate('click');
});
expect(history.location.pathname).toEqual('/projects'); expect(history.location.pathname).toEqual('/projects');
}); });
@@ -158,7 +162,9 @@ describe('<ProjectAdd />', () => {
}); });
}); });
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); await act(async () => {
wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click');
});
expect(history.location.pathname).toEqual('/projects'); expect(history.location.pathname).toEqual('/projects');
}); });
}); });

View File

@@ -144,8 +144,8 @@ describe('<ProjectEdit />', () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, { wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
context: { router: { history } }, context: { router: { history } },
}); });
wrapper.find('CardCloseButton').simulate('click');
}); });
wrapper.find('CardCloseButton').simulate('click');
expect(history.location.pathname).toEqual('/projects/123/details'); expect(history.location.pathname).toEqual('/projects/123/details');
}); });
@@ -157,7 +157,9 @@ describe('<ProjectEdit />', () => {
}); });
}); });
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click'); await act(async () => {
wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click');
});
expect(history.location.pathname).toEqual('/projects/123/details'); expect(history.location.pathname).toEqual('/projects/123/details');
}); });
}); });

View File

@@ -131,17 +131,19 @@ describe('<ProjectForm />', () => {
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const formik = wrapper.find('Formik').instance(); const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => { await act(async () => {
formik.setState( const changeState = new Promise(resolve => {
{ formik.setState(
values: { {
...mockData, values: {
...mockData,
},
}, },
}, () => resolve()
() => resolve() );
); });
await changeState;
}); });
await changeState;
wrapper.update(); wrapper.update();
expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1); expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1);
expect( expect(
@@ -191,18 +193,20 @@ describe('<ProjectForm />', () => {
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const formik = wrapper.find('Formik').instance(); const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => { await act(async () => {
formik.setState( const changeState = new Promise(resolve => {
{ formik.setState(
values: { {
...mockData, values: {
scm_type: 'insights', ...mockData,
scm_type: 'insights',
},
}, },
}, () => resolve()
() => resolve() );
); });
await changeState;
}); });
await changeState;
wrapper.update(); wrapper.update();
expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe( expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe(
1 1

View File

@@ -51,7 +51,7 @@ export const ScmCredentialFormField = withI18n()(
value={credential.value} value={credential.value}
onChange={value => { onChange={value => {
onCredentialSelection('scm', value); onCredentialSelection('scm', value);
form.setFieldValue('credential', value.id); form.setFieldValue('credential', value ? value.id : '');
}} }}
/> />
)} )}

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import TeamAdd from './TeamAdd'; import TeamAdd from './TeamAdd';
@@ -7,32 +8,38 @@ import { TeamsAPI } from '@api';
jest.mock('@api'); jest.mock('@api');
describe('<TeamAdd />', () => { describe('<TeamAdd />', () => {
test('handleSubmit should post to api', () => { test('handleSubmit should post to api', async () => {
const wrapper = mountWithContexts(<TeamAdd />); const wrapper = mountWithContexts(<TeamAdd />);
const updatedTeamData = { const updatedTeamData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
organization: 1, organization: 1,
}; };
wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData);
});
expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData);
}); });
test('should navigate to teams list when cancel is clicked', () => { test('should navigate to teams list when cancel is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const wrapper = mountWithContexts(<TeamAdd />, { const wrapper = mountWithContexts(<TeamAdd />, {
context: { router: { history } }, context: { router: { history } },
}); });
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/teams'); expect(history.location.pathname).toEqual('/teams');
}); });
test('should navigate to teams list when close (x) is clicked', () => { test('should navigate to teams list when close (x) is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const wrapper = mountWithContexts(<TeamAdd />, { const wrapper = mountWithContexts(<TeamAdd />, {
context: { router: { history } }, context: { router: { history } },
}); });
wrapper.find('button[aria-label="Close"]').prop('onClick')(); await act(async () => {
wrapper.find('button[aria-label="Close"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/teams'); expect(history.location.pathname).toEqual('/teams');
}); });
@@ -55,11 +62,16 @@ describe('<TeamAdd />', () => {
}, },
}, },
}); });
const wrapper = mountWithContexts(<TeamAdd />, { let wrapper;
context: { router: { history } }, await act(async () => {
wrapper = mountWithContexts(<TeamAdd />, {
context: { router: { history } },
});
}); });
await waitForElement(wrapper, 'button[aria-label="Save"]'); await waitForElement(wrapper, 'button[aria-label="Save"]');
await wrapper.find('TeamForm').prop('handleSubmit')(teamData); await act(async () => {
await wrapper.find('TeamForm').invoke('handleSubmit')(teamData);
});
expect(history.location.pathname).toEqual('/teams/5'); expect(history.location.pathname).toEqual('/teams/5');
}); });
}); });

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { TeamsAPI } from '@api'; import { TeamsAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { mountWithContexts } from '@testUtils/enzymeHelpers';
@@ -19,25 +20,29 @@ describe('<TeamEdit />', () => {
}, },
}; };
test('handleSubmit should call api update', () => { test('handleSubmit should call api update', async () => {
const wrapper = mountWithContexts(<TeamEdit team={mockData} />); const wrapper = mountWithContexts(<TeamEdit team={mockData} />);
const updatedTeamData = { const updatedTeamData = {
name: 'new name', name: 'new name',
description: 'new description', description: 'new description',
}; };
wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData);
});
expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData); expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData);
}); });
test('should navigate to team detail when cancel is clicked', () => { test('should navigate to team detail when cancel is clicked', async () => {
const history = createMemoryHistory({}); const history = createMemoryHistory({});
const wrapper = mountWithContexts(<TeamEdit team={mockData} />, { const wrapper = mountWithContexts(<TeamEdit team={mockData} />, {
context: { router: { history } }, context: { router: { history } },
}); });
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/teams/1/details'); expect(history.location.pathname).toEqual('/teams/1/details');
}); });

View File

@@ -30,15 +30,17 @@ describe('<TeamForm />', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
test('changing inputs should update form values', () => { test('changing inputs should update form values', async () => {
wrapper = mountWithContexts( await act(async () => {
<TeamForm wrapper = mountWithContexts(
team={mockData} <TeamForm
handleSubmit={jest.fn()} team={mockData}
handleCancel={jest.fn()} handleSubmit={jest.fn()}
me={meConfig.me} handleCancel={jest.fn()}
/> me={meConfig.me}
); />
);
});
const form = wrapper.find('Formik'); const form = wrapper.find('Formik');
wrapper.find('input#team-name').simulate('change', { wrapper.find('input#team-name').simulate('change', {
@@ -78,17 +80,19 @@ describe('<TeamForm />', () => {
expect(handleSubmit).toBeCalled(); expect(handleSubmit).toBeCalled();
}); });
test('calls handleCancel when Cancel button is clicked', () => { test('calls handleCancel when Cancel button is clicked', async () => {
const handleCancel = jest.fn(); const handleCancel = jest.fn();
wrapper = mountWithContexts( await act(async () => {
<TeamForm wrapper = mountWithContexts(
team={mockData} <TeamForm
handleSubmit={jest.fn()} team={mockData}
handleCancel={handleCancel} handleSubmit={jest.fn()}
me={meConfig.me} handleCancel={handleCancel}
/> me={meConfig.me}
); />
);
});
expect(handleCancel).not.toHaveBeenCalled(); expect(handleCancel).not.toHaveBeenCalled();
wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
expect(handleCancel).toBeCalled(); expect(handleCancel).toBeCalled();

View File

@@ -101,19 +101,21 @@ describe('<JobTemplateAdd />', () => {
}); });
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0); await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
const formik = wrapper.find('Formik').instance(); const formik = wrapper.find('Formik').instance();
const changeState = new Promise(resolve => { await act(async () => {
formik.setState( const changeState = new Promise(resolve => {
{ formik.setState(
values: { {
...jobTemplateData, values: {
labels: [], ...jobTemplateData,
instanceGroups: [], labels: [],
instanceGroups: [],
},
}, },
}, () => resolve()
() => resolve() );
); });
await changeState;
}); });
await changeState;
wrapper.find('form').simulate('submit'); wrapper.find('form').simulate('submit');
await sleep(1); await sleep(1);
expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData); expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData);

View File

@@ -79,6 +79,8 @@ class JobTemplateForm extends Component {
}; };
this.handleProjectValidation = this.handleProjectValidation.bind(this); this.handleProjectValidation = this.handleProjectValidation.bind(this);
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this); this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
this.handleProjectUpdate = this.handleProjectUpdate.bind(this);
this.setContentError = this.setContentError.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -119,6 +121,16 @@ class JobTemplateForm extends Component {
}; };
} }
handleProjectUpdate(project) {
const { setFieldValue } = this.props;
setFieldValue('project', project.id);
this.setState({ project });
}
setContentError(contentError) {
this.setState({ contentError });
}
render() { render() {
const { const {
contentError, contentError,
@@ -252,10 +264,7 @@ class JobTemplateForm extends Component {
you want this job to execute.`)} you want this job to execute.`)}
isValid={!form.touched.project || !form.errors.project} isValid={!form.touched.project || !form.errors.project}
helperTextInvalid={form.errors.project} helperTextInvalid={form.errors.project}
onChange={value => { onChange={this.handleProjectUpdate}
form.setFieldValue('project', value.id);
this.setState({ project: value });
}}
required required
/> />
)} )}
@@ -285,7 +294,7 @@ class JobTemplateForm extends Component {
form={form} form={form}
field={field} field={field}
onBlur={() => form.setFieldTouched('playbook')} onBlur={() => form.setFieldTouched('playbook')}
onError={err => this.setState({ contentError: err })} onError={this.setContentError}
/> />
</FormGroup> </FormGroup>
); );
@@ -305,7 +314,7 @@ class JobTemplateForm extends Component {
<LabelSelect <LabelSelect
value={field.value} value={field.value}
onChange={labels => setFieldValue('labels', labels)} onChange={labels => setFieldValue('labels', labels)}
onError={err => this.setState({ contentError: err })} onError={this.setContentError}
/> />
</FormGroup> </FormGroup>
)} )}
@@ -317,11 +326,11 @@ class JobTemplateForm extends Component {
fieldId="template-credentials" fieldId="template-credentials"
render={({ field }) => ( render={({ field }) => (
<MultiCredentialsLookup <MultiCredentialsLookup
credentials={field.value} value={field.value}
onChange={newCredentials => onChange={newCredentials =>
setFieldValue('credentials', newCredentials) setFieldValue('credentials', newCredentials)
} }
onError={err => this.setState({ contentError: err })} onError={this.setContentError}
tooltip={i18n._( tooltip={i18n._(
t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.` t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
)} )}

View File

@@ -214,7 +214,7 @@ describe('UsersList with full permissions', () => {
); );
}); });
test('api is called to delete users for each selected user.', () => { test('api is called to delete users for each selected user.', async () => {
UsersAPI.destroy = jest.fn(); UsersAPI.destroy = jest.fn();
wrapper.find('UsersList').setState({ wrapper.find('UsersList').setState({
users: mockUsers, users: mockUsers,
@@ -223,7 +223,7 @@ describe('UsersList with full permissions', () => {
isModalOpen: true, isModalOpen: true,
selected: mockUsers, selected: mockUsers,
}); });
wrapper.find('ToolbarDeleteButton').prop('onDelete')(); await wrapper.find('ToolbarDeleteButton').prop('onDelete')();
expect(UsersAPI.destroy).toHaveBeenCalledTimes(2); expect(UsersAPI.destroy).toHaveBeenCalledTimes(2);
}); });