mirror of
https://github.com/ansible/awx.git
synced 2026-01-19 21:51:26 -03:30
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:
commit
6fab3590ae
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -12,6 +12,7 @@ describe('CheckboxListItem', () => {
|
||||
label="Buzz"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
onDeselect={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper).toHaveLength(1);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
5
awx/ui_next/src/components/Lookup/README.md
Normal file
5
awx/ui_next/src/components/Lookup/README.md
Normal 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
|
||||
@ -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);
|
||||
95
awx/ui_next/src/components/Lookup/shared/OptionsList.jsx
Normal file
95
awx/ui_next/src/components/Lookup/shared/OptionsList.jsx
Normal 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);
|
||||
@ -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]]);
|
||||
});
|
||||
});
|
||||
96
awx/ui_next/src/components/Lookup/shared/reducer.js
Normal file
96
awx/ui_next/src/components/Lookup/shared/reducer.js
Normal 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');
|
||||
}
|
||||
}
|
||||
280
awx/ui_next/src/components/Lookup/shared/reducer.test.js
Normal file
280
awx/ui_next/src/components/Lookup/shared/reducer.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 : '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.`
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user