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
commit 6fab3590ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1882 additions and 1276 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,20 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import React, { useEffect, useState } from 'react';
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 { Credential } from '@types';
import { mergeParams } from '@util/qs';
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import { FormGroup } from '@patternfly/react-core';
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({
helperTextInvalid,
@ -16,11 +25,28 @@ function CredentialLookup({
required,
credentialTypeId,
value,
history,
}) {
const getCredentials = async params =>
CredentialsAPI.read(
mergeParams(params, { credential_type: credentialTypeId })
);
const [credentials, setCredentials] = 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 CredentialsAPI.read(
mergeParams(params, { credential_type: credentialTypeId })
);
setCredentials(data.results);
setCount(data.count);
} catch (err) {
if (setError) {
setError(err);
}
}
})();
}, [credentialTypeId, history.location.search]);
return (
<FormGroup
@ -32,15 +58,26 @@ function CredentialLookup({
>
<Lookup
id="credential"
lookupHeader={label}
name="credential"
header={label}
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={getCredentials}
onChange={onChange}
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>
);
}
@ -65,4 +102,4 @@ CredentialLookup.defaultProps = {
};
export { CredentialLookup as _CredentialLookup };
export default withI18n()(CredentialLookup);
export default withI18n()(withRouter(CredentialLookup));

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
import { CredentialsAPI } from '@api';
@ -9,19 +10,48 @@ describe('CredentialLookup', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} />
);
CredentialsAPI.read.mockResolvedValueOnce({
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(() => {
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);
});
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).toHaveBeenCalledWith({
credential_type: 1,
@ -30,11 +60,31 @@ describe('CredentialLookup', () => {
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');
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).not.toThrow();
});

View File

@ -1,48 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { useState, useEffect } from 'react';
import { arrayOf, string, func, object, bool } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { FormGroup } from '@patternfly/react-core';
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)`
margin-left: 10px;
`;
const QS_CONFIG = getQSConfig('instance_groups', {
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 {
render() {
const { value, tooltip, onChange, className, i18n } = this.props;
useEffect(() => {
(async () => {
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]);
/*
Wrapping <div> added to workaround PF bug:
https://github.com/patternfly/patternfly-react/issues/2855
*/
return (
<div className={className}>
<FormGroup
label={i18n._(t`Instance Groups`)}
fieldId="org-instance-groups"
>
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
<Lookup
id="org-instance-groups"
lookupHeader={i18n._(t`Instance Groups`)}
name="instanceGroups"
value={value}
onLookupSave={onChange}
getItems={getInstanceGroups}
qsNamespace="instance-group"
multiple
return (
<FormGroup
className={className}
label={i18n._(t`Instance Groups`)}
fieldId="org-instance-groups"
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="org-instance-groups"
header={i18n._(t`Instance Groups`)}
value={value}
onChange={onChange}
qsConfig={QS_CONFIG}
multiple
required={required}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={instanceGroups}
optionCount={count}
columns={[
{
name: i18n._(t`Name`),
@ -63,22 +84,33 @@ class InstanceGroupsLookup extends React.Component {
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 = {
value: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: arrayOf(object).isRequired,
tooltip: string,
onChange: func.isRequired,
className: string,
required: bool,
};
InstanceGroupsLookup.defaultProps = {
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 { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api';
import { Inventory } from '@types';
import Lookup from '@components/Lookup';
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 {
render() {
const {
value,
tooltip,
onChange,
onBlur,
required,
isValid,
helperTextInvalid,
i18n,
} = this.props;
function InventoryLookup({
value,
tooltip,
onChange,
onBlur,
required,
isValid,
helperTextInvalid,
i18n,
history,
}) {
const [inventories, setInventories] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
return (
<FormGroup
label={i18n._(t`Inventory`)}
isRequired={required}
fieldId="inventory-lookup"
isValid={isValid}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="inventory-lookup"
lookupHeader={i18n._(t`Inventory`)}
name="inventory"
value={value}
onLookupSave={onChange}
onBlur={onBlur}
getItems={getInventories}
required={required}
qsNamespace="inventory"
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
sortedColumnKey="name"
/>
</FormGroup>
);
}
useEffect(() => {
(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await InventoriesAPI.read(params);
setInventories(data.results);
setCount(data.count);
} catch (err) {
setError(err);
}
})();
}, [history.location]);
return (
<FormGroup
label={i18n._(t`Inventory`)}
isRequired={required}
fieldId="inventory-lookup"
isValid={isValid}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="inventory-lookup"
header={i18n._(t`Inventory`)}
value={value}
onChange={onChange}
onBlur={onBlur}
required={required}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={inventories}
optionCount={count}
columns={[
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
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 = {
@ -77,4 +111,4 @@ InventoryLookup.defaultProps = {
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 {
string,
bool,
@ -15,20 +15,14 @@ import {
ButtonVariant,
InputGroup as PFInputGroup,
Modal,
ToolbarItem,
} from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import AnsibleSelect from '../AnsibleSelect';
import PaginatedDataList from '../PaginatedDataList';
import VerticalSeperator from '../VerticalSeparator';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { getQSConfig, parseQueryString } from '../../util/qs';
import reducer, { initReducer } from './shared/reducer';
import { ChipGroup, Chip } from '../Chip';
import { QSConfig } from '@types';
const SearchButton = styled(Button)`
::after {
@ -36,6 +30,7 @@ const SearchButton = styled(Button)`
var(--pf-global--BorderColor--200);
}
`;
SearchButton.displayName = 'SearchButton';
const InputGroup = styled(PFInputGroup)`
${props =>
@ -54,315 +49,124 @@ const ChipHolder = styled.div`
border-bottom-right-radius: 3px;
`;
class Lookup extends React.Component {
constructor(props) {
super(props);
function Lookup(props) {
const {
id,
header,
onChange,
onBlur,
value,
multiple,
required,
qsConfig,
renderItemChip,
renderOptionsList,
history,
i18n,
} = props;
this.assertCorrectValueType();
let lookupSelectedItems = [];
if (props.value) {
lookupSelectedItems = props.multiple ? [...props.value] : [props.value];
}
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);
}
const [state, dispatch] = useReducer(
reducer,
{ value, multiple, required },
initReducer
);
componentDidMount() {
this.getData();
}
useEffect(() => {
dispatch({ type: 'SET_MULTIPLE', value: multiple });
}, [multiple]);
componentDidUpdate(prevProps) {
const { location, selectedCategory } = this.props;
if (
location !== prevProps.location ||
prevProps.selectedCategory !== selectedCategory
) {
this.getData();
}
}
useEffect(() => {
dispatch({ type: 'SET_VALUE', value });
}, [value]);
assertCorrectValueType() {
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 clearQSParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = this.qsConfig.namespace;
const ns = qsConfig.namespace;
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
}
};
render() {
const {
isModalOpen,
lookupSelectedItems,
error,
results,
count,
} = this.state;
const {
form,
id,
lookupHeader,
value,
columns,
multiple,
name,
onBlur,
selectCategory,
required,
i18n,
selectCategoryOptions,
selectedCategory,
} = this.props;
const header = lookupHeader || i18n._(t`Items`);
const canDelete = !required || (multiple && value.length > 1);
const chips = () => {
return selectCategoryOptions && selectCategoryOptions.length > 0 ? (
<ChipGroup defaultIsOpen numChips={5}>
{(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>
);
const save = () => {
const { selectedItems } = state;
const val = multiple ? selectedItems : selectedItems[0] || null;
onChange(val);
clearQSParams();
dispatch({ type: 'CLOSE_MODAL' });
};
const removeItem = item => {
if (multiple) {
onChange(value.filter(i => i.id !== item.id));
} else {
onChange(null);
}
};
const closeModal = () => {
clearQSParams();
dispatch({ type: 'CLOSE_MODAL' });
};
const { isModalOpen, selectedItems } = state;
const canDelete = !required || (multiple && value.length > 1);
let items = [];
if (multiple) {
items = value;
} else if (value) {
items.push(value);
}
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({
@ -371,25 +175,33 @@ const Item = shape({
Lookup.propTypes = {
id: string,
getItems: func.isRequired,
lookupHeader: string,
name: string,
onLookupSave: func.isRequired,
header: string,
onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]),
sortedColumnKey: string.isRequired,
multiple: bool,
required: bool,
qsNamespace: string,
onBlur: func,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
renderOptionsList: func.isRequired,
};
Lookup.defaultProps = {
id: 'lookup-search',
lookupHeader: null,
name: null,
header: null,
value: null,
multiple: 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 };

View File

@ -1,11 +1,9 @@
/* eslint-disable react/jsx-pascal-case */
import React from 'react';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import Lookup, { _Lookup } from './Lookup';
let mockData = [{ name: 'foo', id: 1, isChecked: false }];
const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }];
import { getQSConfig } from '@util/qs';
import Lookup from './Lookup';
/**
* Check that an element is present on the document body
@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) {
});
}
/**
* Check lookup modal list for expected values
* @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();
});
});
const QS_CONFIG = getQSConfig('test', {});
const TestList = () => <div />;
describe('<Lookup />', () => {
let wrapper;
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(() => {
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
onChange = jest.fn();
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);
});
test('Expected items are shown', async done => {
test('should show selected items', async () => {
wrapper = await mountWrapper();
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');
test('should open and close modal', async () => {
wrapper = await mountWrapper();
checkRootElementNotPresent('body div[role="dialog"]');
wrapper.find('button[aria-label="Search"]').simulate('click');
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
.find('Modal button')
.findWhere(e => e.text() === 'Cancel')
.first()
.simulate('click');
checkRootElementNotPresent('body div[role="dialog"]');
done();
});
test('Change selected item with radio control then save', async done => {
wrapper.find('button[aria-label="Search"]').simulate('click');
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
await checkModalTagValues(wrapper, ['foo']);
test('should remove item when X button clicked', async () => {
wrapper = await mountWrapper();
await checkInputTagValues(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() === 'Save')
.find('Lookup InputGroup Chip')
.findWhere(el => el.text() === 'foo')
.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')();
wrapper.find('Modal').invoke('onClose')();
expect(history.location.search).toEqual('?bar=baz');
expect(onChange).toHaveBeenCalledTimes(1);
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup, Tooltip } from '@patternfly/react-core';
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { FormGroup, ToolbarItem } from '@patternfly/react-core';
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)`
margin-left: 10px;
`;
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
page_size: 5,
order_by: 'name',
});
class MultiCredentialsLookup extends React.Component {
constructor(props) {
super(props);
async function loadCredentialTypes() {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
return data.results.filter(type => acceptableTypes.includes(type.kind));
}
this.state = {
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' },
credentialTypes: [],
};
this.loadCredentialTypes = this.loadCredentialTypes.bind(this);
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
this
);
this.loadCredentials = this.loadCredentials.bind(this);
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
}
async function loadCredentials(params, selectedCredentialTypeId) {
params.credential_type = selectedCredentialTypeId || 1;
const { data } = await CredentialsAPI.read(params);
return data;
}
componentDidMount() {
this.loadCredentialTypes();
}
function MultiCredentialsLookup(props) {
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() {
const { onError } = this.props;
try {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
const credentialTypes = [];
data.results.forEach(cred => {
acceptableTypes.forEach(aT => {
if (aT === cred.kind) {
// This object has several repeated values as some of it's children
// require different field values.
cred = {
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);
}
}
useEffect(() => {
(async () => {
try {
const types = await loadCredentialTypes();
setCredentialTypes(types);
const match = types.find(type => type.kind === 'ssh') || types[0];
setSelectedType(match);
} catch (err) {
onError(err);
}
})();
}, [onError]);
async loadCredentials(params) {
const { selectedCredentialType } = this.state;
params.credential_type = selectedCredentialType.id || 1;
return CredentialsAPI.read(params);
}
useEffect(() => {
(async () => {
if (!selectedType) {
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 { onChange, credentials: credentialsToUpdate } = this.props;
const renderChip = ({ item, removeItem, canDelete }) => (
<CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
);
let newCredentialsList;
const isSelectedCredentialInState =
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
0;
const isMultiple = selectedType && selectedType.kind === 'vault';
if (isSelectedCredentialInState) {
newCredentialsList = credentialsToUpdate.filter(
cred => cred.id !== newCredential.id
);
} else {
newCredentialsList = credentialsToUpdate.filter(
credential =>
credential.kind === 'vault' || credential.kind !== newCredential.kind
);
newCredentialsList = [...newCredentialsList, newCredential];
}
onChange(newCredentialsList);
}
handleCredentialTypeSelect(value, type) {
const { credentialTypes } = this.state;
const selectedType = credentialTypes.filter(item => item.label === type);
this.setState({ selectedCredentialType: selectedType[0] });
}
render() {
const { selectedCredentialType, credentialTypes } = this.state;
const { tooltip, i18n, credentials } = this.props;
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && (
<Tooltip position="right" content={tooltip}>
<QuestionCircleIcon />
</Tooltip>
)}
{credentialTypes && (
<Lookup
selectCategoryOptions={credentialTypes}
selectCategory={this.handleCredentialTypeSelect}
selectedCategory={selectedCredentialType}
onToggleItem={this.toggleCredentialSelection}
onloadCategories={this.loadCredentialTypes}
id="multiCredential"
lookupHeader={i18n._(t`Credentials`)}
name="credentials"
value={credentials}
multiple
onLookupSave={() => {}}
getItems={this.loadCredentials}
qsNamespace="credentials"
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
sortedColumnKey="name"
/>
)}
</FormGroup>
);
}
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="multiCredential"
header={i18n._(t`Credentials`)}
value={value}
multiple
onChange={onChange}
qsConfig={QS_CONFIG}
renderItemChip={renderChip}
renderOptionsList={({ state, dispatch, canDelete }) => {
return (
<Fragment>
{credentialTypes && credentialTypes.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
<VerticalSeperator />
<AnsibleSelect
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label={i18n._(t`Selected Category`)}
data={credentialTypes.map(type => ({
key: type.id,
value: type.id,
label: type.name,
isDisabled: false,
}))}
value={selectedType && selectedType.id}
onChange={(e, id) => {
setSelectedType(
credentialTypes.find(o => o.id === parseInt(id, 10))
);
}}
/>
</ToolbarItem>
)}
<OptionsList
value={state.selectedItems}
options={credentials}
optionCount={credentialsCount}
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
multiple={isMultiple}
header={i18n._(t`Credentials`)}
name="credentials"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => {
if (isMultiple) {
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 = {
tooltip: PropTypes.string,
credentials: PropTypes.arrayOf(
value: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = {
MultiCredentialsLookup.defaultProps = {
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 { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import MultiCredentialsLookup from './MultiCredentialsLookup';
import { CredentialsAPI, CredentialTypesAPI } from '@api';
@ -8,9 +8,6 @@ jest.mock('@api');
describe('<MultiCredentialsLookup />', () => {
let wrapper;
let lookup;
let credLookup;
let onChange;
const credentials = [
{ 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: 8, kind: 'Machine' },
];
beforeEach(() => {
CredentialTypesAPI.read.mockResolvedValue({
CredentialTypesAPI.read.mockResolvedValueOnce({
data: {
results: [
{
@ -46,17 +44,6 @@ describe('<MultiCredentialsLookup />', () => {
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(() => {
@ -64,16 +51,40 @@ describe('<MultiCredentialsLookup />', () => {
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(CredentialTypesAPI.read).toHaveBeenCalled();
});
test('onChange is called when you click to remove a credential from input', async () => {
const chip = wrapper.find('PFChip').find({ isOverflowChip: false });
const button = chip.at(1).find('ChipButton');
const onChange = jest.fn();
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);
button.prop('onClick')();
const button = chip.at(1).find('ChipButton');
await act(async () => {
button.invoke('onClick')();
});
expect(onChange).toBeCalledWith([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 21, kind: 'vault', name: 'Gatsby' },
@ -81,33 +92,122 @@ describe('<MultiCredentialsLookup />', () => {
]);
});
test('can change credential types', () => {
lookup.prop('selectCategory')({}, 'Vault');
expect(credLookup.state('selectedCredentialType')).toEqual({
id: 500,
key: 500,
kind: 'vault',
type: 'buzz',
value: 'Vault',
label: 'Vault',
isDisabled: false,
test('should change credential types', async () => {
await act(async () => {
wrapper = mountWithContexts(
<MultiCredentialsLookup
value={credentials}
tooltip="This is credentials look up"
onChange={() => {}}
onError={() => {}}
/>
);
});
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([
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
{ 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([
...credentials,
{ name: 'Party', id: 22, kind: 'vault' },
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
{ 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 { t } from '@lingui/macro';
import { string, func, bool } from 'prop-types';
import { OrganizationsAPI } from '@api';
import { Organization } from '@types';
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({
helperTextInvalid,
@ -17,7 +25,25 @@ function OrganizationLookup({
onChange,
required,
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 (
<FormGroup
fieldId="organization"
@ -28,15 +54,29 @@ function OrganizationLookup({
>
<Lookup
id="organization"
lookupHeader={i18n._(t`Organization`)}
name="organization"
header={i18n._(t`Organization`)}
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={getOrganizations}
onChange={onChange}
qsConfig={QS_CONFIG}
required={required}
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>
);
}
@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = {
value: null,
};
export default withI18n()(OrganizationLookup);
export { OrganizationLookup as _OrganizationLookup };
export default withI18n()(withRouter(OrganizationLookup));

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
import { OrganizationsAPI } from '@api';
@ -8,18 +9,22 @@ jest.mock('@api');
describe('OrganizationLookup', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => {
test('should render successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
});
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).toHaveBeenCalledWith({
order_by: 'name',
@ -27,11 +32,19 @@ describe('OrganizationLookup', () => {
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');
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).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 { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
import { ProjectsAPI } from '@api';
import { Project } from '@types';
import Lookup from '@components/Lookup';
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 {
render() {
const {
helperTextInvalid,
i18n,
isValid,
onChange,
required,
tooltip,
value,
onBlur,
} = this.props;
const QS_CONFIG = getQSConfig('project', {
page: 1,
page_size: 5,
order_by: 'name',
});
const loadProjects = async params => {
const response = await ProjectsAPI.read(params);
const { results, count } = response.data;
if (count === 1) {
onChange(results[0], 'project');
function ProjectLookup({
helperTextInvalid,
i18n,
isValid,
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 (
<FormGroup
fieldId="project"
helperTextInvalid={helperTextInvalid}
isRequired={required}
isValid={isValid}
label={i18n._(t`Project`)}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="project"
lookupHeader={i18n._(t`Project`)}
name="project"
value={value}
onBlur={onBlur}
onLookupSave={onChange}
getItems={loadProjects}
required={required}
sortedColumnKey="name"
qsNamespace="project"
/>
</FormGroup>
);
}
return (
<FormGroup
fieldId="project"
helperTextInvalid={helperTextInvalid}
isRequired={required}
isValid={isValid}
label={i18n._(t`Project`)}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="project"
header={i18n._(t`Project`)}
name="project"
value={value}
onBlur={onBlur}
onChange={onChange}
required={required}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
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 = {
@ -75,4 +106,5 @@ ProjectLookup.defaultProps = {
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 { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import { ProjectsAPI } from '@api';
@ -15,9 +16,11 @@ describe('<ProjectLookup />', () => {
},
});
const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />);
await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0);
expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project');
expect(onChange).toHaveBeenCalledWith({ id: 1 });
});
test('should not auto-select project when multiple available', async () => {
@ -28,7 +31,9 @@ describe('<ProjectLookup />', () => {
},
});
const onChange = jest.fn();
mountWithContexts(<ProjectLookup onChange={onChange} />);
await act(async () => {
mountWithContexts(<ProjectLookup onChange={onChange} />);
});
await sleep(0);
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 { Split as PFSplit, SplitItem } from '@patternfly/react-core';
import styled from 'styled-components';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { ChipGroup, Chip } from '../Chip';
import VerticalSeparator from '../VerticalSeparator';
const Split = styled(PFSplit)`
@ -26,34 +26,31 @@ class SelectedList extends Component {
onRemove,
displayKey,
isReadOnly,
isCredentialList,
renderItemChip,
} = this.props;
const chips = isCredentialList
? selected.map(item => (
<CredentialChip
key={item.id}
isReadOnly={isReadOnly}
onClick={() => onRemove(item)}
credential={item}
>
{item[displayKey]}
</CredentialChip>
))
: selected.map(item => (
<Chip
key={item.id}
isReadOnly={isReadOnly}
onClick={() => onRemove(item)}
>
{item[displayKey]}
</Chip>
));
const renderChip =
renderItemChip ||
(({ item, removeItem }) => (
<Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}>
{item[displayKey]}
</Chip>
));
return (
<Split>
<SplitLabelItem>{label}</SplitLabelItem>
<VerticalSeparator />
<SplitItem>
<ChipGroup numChips={5}>{chips}</ChipGroup>
<ChipGroup numChips={5}>
{selected.map(item =>
renderChip({
item,
removeItem: () => onRemove(item),
canDelete: !isReadOnly,
})
)}
</ChipGroup>
</SplitItem>
</Split>
);
@ -66,6 +63,7 @@ SelectedList.propTypes = {
onRemove: PropTypes.func,
selected: PropTypes.arrayOf(PropTypes.object).isRequired,
isReadOnly: PropTypes.bool,
renderItemChip: PropTypes.func,
};
SelectedList.defaultProps = {
@ -73,6 +71,7 @@ SelectedList.defaultProps = {
label: 'Selected',
onRemove: () => null,
isReadOnly: false,
renderItemChip: null,
};
export default SelectedList;

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import HostAdd from './HostAdd';
@ -7,8 +8,11 @@ import { HostsAPI } from '@api';
jest.mock('@api');
describe('<HostAdd />', () => {
test('handleSubmit should post to api', () => {
const wrapper = mountWithContexts(<HostAdd />);
test('handleSubmit should post to api', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />);
});
const updatedHostData = {
name: 'new name',
description: 'new description',
@ -19,21 +23,27 @@ describe('<HostAdd />', () => {
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 wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
let wrapper;
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');
});
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 wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
let wrapper;
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');
});
@ -51,11 +61,14 @@ describe('<HostAdd />', () => {
...hostData,
},
});
const wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostAdd />, {
context: { router: { history } },
});
});
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');
});
});

View File

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

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { OrganizationsAPI } from '@api';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
@ -6,8 +7,6 @@ import OrganizationEdit from './OrganizationEdit';
jest.mock('@api');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
describe('<OrganizationEdit />', () => {
const mockData = {
name: 'Foo',
@ -19,10 +18,11 @@ describe('<OrganizationEdit />', () => {
},
};
test('handleSubmit should call api update', () => {
const wrapper = mountWithContexts(
<OrganizationEdit organization={mockData} />
);
test('handleSubmit should call api update', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<OrganizationEdit organization={mockData} />);
});
const updatedOrgData = {
name: 'new name',
@ -39,21 +39,23 @@ describe('<OrganizationEdit />', () => {
});
test('handleSubmit associates and disassociates instance groups', async () => {
const wrapper = mountWithContexts(
<OrganizationEdit organization={mockData} />
);
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<OrganizationEdit organization={mockData} />);
});
const updatedOrgData = {
name: 'new name',
description: 'new description',
custom_virtualenv: 'Buzz',
};
wrapper.find('OrganizationForm').prop('handleSubmit')(
updatedOrgData,
[3, 4],
[2]
);
await sleep(1);
await act(async () => {
wrapper.find('OrganizationForm').invoke('handleSubmit')(
updatedOrgData,
[3, 4],
[2]
);
});
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
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 wrapper = mountWithContexts(
<OrganizationEdit organization={mockData} />,
{ context: { router: { history } } }
);
let wrapper;
await act(async () => {
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');
});

View File

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

View File

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

View File

@ -144,8 +144,8 @@ describe('<ProjectEdit />', () => {
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
context: { router: { history } },
});
wrapper.find('CardCloseButton').simulate('click');
});
wrapper.find('CardCloseButton').simulate('click');
expect(history.location.pathname).toEqual('/projects/123/details');
});
@ -157,7 +157,9 @@ describe('<ProjectEdit />', () => {
});
});
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');
});
});

View File

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

View File

@ -51,7 +51,7 @@ export const ScmCredentialFormField = withI18n()(
value={credential.value}
onChange={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 { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import TeamAdd from './TeamAdd';
@ -7,32 +8,38 @@ import { TeamsAPI } from '@api';
jest.mock('@api');
describe('<TeamAdd />', () => {
test('handleSubmit should post to api', () => {
test('handleSubmit should post to api', async () => {
const wrapper = mountWithContexts(<TeamAdd />);
const updatedTeamData = {
name: 'new name',
description: 'new description',
organization: 1,
};
wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData);
await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(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 wrapper = mountWithContexts(<TeamAdd />, {
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');
});
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 wrapper = mountWithContexts(<TeamAdd />, {
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');
});
@ -55,11 +62,16 @@ describe('<TeamAdd />', () => {
},
},
});
const wrapper = mountWithContexts(<TeamAdd />, {
context: { router: { history } },
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<TeamAdd />, {
context: { router: { history } },
});
});
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');
});
});

View File

@ -1,4 +1,5 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { TeamsAPI } from '@api';
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 updatedTeamData = {
name: 'new name',
description: 'new description',
};
wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData);
await act(async () => {
wrapper.find('TeamForm').invoke('handleSubmit')(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 wrapper = mountWithContexts(<TeamEdit team={mockData} />, {
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');
});

View File

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

View File

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

View File

@ -79,6 +79,8 @@ class JobTemplateForm extends Component {
};
this.handleProjectValidation = this.handleProjectValidation.bind(this);
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
this.handleProjectUpdate = this.handleProjectUpdate.bind(this);
this.setContentError = this.setContentError.bind(this);
}
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() {
const {
contentError,
@ -252,10 +264,7 @@ class JobTemplateForm extends Component {
you want this job to execute.`)}
isValid={!form.touched.project || !form.errors.project}
helperTextInvalid={form.errors.project}
onChange={value => {
form.setFieldValue('project', value.id);
this.setState({ project: value });
}}
onChange={this.handleProjectUpdate}
required
/>
)}
@ -285,7 +294,7 @@ class JobTemplateForm extends Component {
form={form}
field={field}
onBlur={() => form.setFieldTouched('playbook')}
onError={err => this.setState({ contentError: err })}
onError={this.setContentError}
/>
</FormGroup>
);
@ -305,7 +314,7 @@ class JobTemplateForm extends Component {
<LabelSelect
value={field.value}
onChange={labels => setFieldValue('labels', labels)}
onError={err => this.setState({ contentError: err })}
onError={this.setContentError}
/>
</FormGroup>
)}
@ -317,11 +326,11 @@ class JobTemplateForm extends Component {
fieldId="template-credentials"
render={({ field }) => (
<MultiCredentialsLookup
credentials={field.value}
value={field.value}
onChange={newCredentials =>
setFieldValue('credentials', newCredentials)
}
onError={err => this.setState({ contentError: err })}
onError={this.setContentError}
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.`
)}

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();
wrapper.find('UsersList').setState({
users: mockUsers,
@ -223,7 +223,7 @@ describe('UsersList with full permissions', () => {
isModalOpen: true,
selected: mockUsers,
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
await wrapper.find('ToolbarDeleteButton').prop('onDelete')();
expect(UsersAPI.destroy).toHaveBeenCalledTimes(2);
});