mirror of
https://github.com/ansible/awx.git
synced 2026-05-23 16:47:45 -02: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:
@@ -25,7 +25,7 @@ class AnsibleSelect extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { id, data, i18n, isValid, onBlur, value } = this.props;
|
const { id, data, i18n, isValid, onBlur, value, className } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormSelect
|
<FormSelect
|
||||||
@@ -35,13 +35,14 @@ class AnsibleSelect extends React.Component {
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
aria-label={i18n._(t`Select Input`)}
|
aria-label={i18n._(t`Select Input`)}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{data.map(datum => (
|
{data.map(option => (
|
||||||
<FormSelectOption
|
<FormSelectOption
|
||||||
key={datum.key}
|
key={option.key}
|
||||||
value={datum.value}
|
value={option.value}
|
||||||
label={datum.label}
|
label={option.label}
|
||||||
isDisabled={datum.isDisabled}
|
isDisabled={option.isDisabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
@@ -49,19 +50,28 @@ class AnsibleSelect extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Option = shape({
|
||||||
|
key: oneOfType([string, number]).isRequired,
|
||||||
|
value: oneOfType([string, number]).isRequired,
|
||||||
|
label: string.isRequired,
|
||||||
|
isDisabled: bool,
|
||||||
|
});
|
||||||
|
|
||||||
AnsibleSelect.defaultProps = {
|
AnsibleSelect.defaultProps = {
|
||||||
data: [],
|
data: [],
|
||||||
isValid: true,
|
isValid: true,
|
||||||
onBlur: () => {},
|
onBlur: () => {},
|
||||||
|
className: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
AnsibleSelect.propTypes = {
|
AnsibleSelect.propTypes = {
|
||||||
data: arrayOf(shape()),
|
data: arrayOf(Option),
|
||||||
id: string.isRequired,
|
id: string.isRequired,
|
||||||
isValid: bool,
|
isValid: bool,
|
||||||
onBlur: func,
|
onBlur: func,
|
||||||
onChange: func.isRequired,
|
onChange: func.isRequired,
|
||||||
value: oneOfType([string, number]).isRequired,
|
value: oneOfType([string, number]).isRequired,
|
||||||
|
className: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AnsibleSelect as _AnsibleSelect };
|
export { AnsibleSelect as _AnsibleSelect };
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const CheckboxListItem = ({
|
|||||||
label,
|
label,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onDeselect,
|
||||||
isRadio,
|
isRadio,
|
||||||
}) => {
|
}) => {
|
||||||
const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
|
const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
|
||||||
@@ -25,7 +26,7 @@ const CheckboxListItem = ({
|
|||||||
<CheckboxRadio
|
<CheckboxRadio
|
||||||
id={`selected-${itemId}`}
|
id={`selected-${itemId}`}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={onSelect}
|
onChange={isSelected ? onDeselect : onSelect}
|
||||||
aria-labelledby={`check-action-item-${itemId}`}
|
aria-labelledby={`check-action-item-${itemId}`}
|
||||||
name={name}
|
name={name}
|
||||||
value={itemId}
|
value={itemId}
|
||||||
@@ -60,6 +61,7 @@ CheckboxListItem.propTypes = {
|
|||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
isSelected: PropTypes.bool.isRequired,
|
isSelected: PropTypes.bool.isRequired,
|
||||||
onSelect: PropTypes.func.isRequired,
|
onSelect: PropTypes.func.isRequired,
|
||||||
|
onDeselect: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CheckboxListItem;
|
export default CheckboxListItem;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('CheckboxListItem', () => {
|
|||||||
label="Buzz"
|
label="Buzz"
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
|
onDeselect={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(wrapper).toHaveLength(1);
|
expect(wrapper).toHaveLength(1);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
|
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
|
||||||
|
|
||||||
// TODO: Better loading state - skeleton lines / spinner, etc.
|
// TODO: Better loading state - skeleton lines / spinner, etc.
|
||||||
const ContentLoading = ({ i18n }) => (
|
const ContentLoading = ({ className, i18n }) => (
|
||||||
<EmptyState>
|
<EmptyState className={className}>
|
||||||
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
|
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import { bool, func, number, string, oneOfType } from 'prop-types';
|
import { bool, func, number, string, oneOfType } from 'prop-types';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
import { CredentialsAPI } from '@api';
|
import { CredentialsAPI } from '@api';
|
||||||
import { Credential } from '@types';
|
import { Credential } from '@types';
|
||||||
import { mergeParams } from '@util/qs';
|
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import Lookup from '@components/Lookup';
|
import Lookup from '@components/Lookup';
|
||||||
|
import OptionsList from './shared/OptionsList';
|
||||||
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
|
const QS_CONFIG = getQSConfig('credentials', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
function CredentialLookup({
|
function CredentialLookup({
|
||||||
helperTextInvalid,
|
helperTextInvalid,
|
||||||
@@ -16,11 +25,28 @@ function CredentialLookup({
|
|||||||
required,
|
required,
|
||||||
credentialTypeId,
|
credentialTypeId,
|
||||||
value,
|
value,
|
||||||
|
history,
|
||||||
}) {
|
}) {
|
||||||
const getCredentials = async params =>
|
const [credentials, setCredentials] = useState([]);
|
||||||
CredentialsAPI.read(
|
const [count, setCount] = useState(0);
|
||||||
mergeParams(params, { credential_type: credentialTypeId })
|
const [error, setError] = useState(null);
|
||||||
);
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
try {
|
||||||
|
const { data } = await CredentialsAPI.read(
|
||||||
|
mergeParams(params, { credential_type: credentialTypeId })
|
||||||
|
);
|
||||||
|
setCredentials(data.results);
|
||||||
|
setCount(data.count);
|
||||||
|
} catch (err) {
|
||||||
|
if (setError) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [credentialTypeId, history.location.search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
@@ -32,15 +58,26 @@ function CredentialLookup({
|
|||||||
>
|
>
|
||||||
<Lookup
|
<Lookup
|
||||||
id="credential"
|
id="credential"
|
||||||
lookupHeader={label}
|
header={label}
|
||||||
name="credential"
|
|
||||||
value={value}
|
value={value}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onLookupSave={onChange}
|
onChange={onChange}
|
||||||
getItems={getCredentials}
|
|
||||||
required={required}
|
required={required}
|
||||||
sortedColumnKey="name"
|
qsConfig={QS_CONFIG}
|
||||||
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
|
<OptionsList
|
||||||
|
value={state.selectedItems}
|
||||||
|
options={credentials}
|
||||||
|
optionCount={count}
|
||||||
|
header={label}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
readOnly={!canDelete}
|
||||||
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
<LookupErrorMessage error={error} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,4 +102,4 @@ CredentialLookup.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { CredentialLookup as _CredentialLookup };
|
export { CredentialLookup as _CredentialLookup };
|
||||||
export default withI18n()(CredentialLookup);
|
export default withI18n()(withRouter(CredentialLookup));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
|
import CredentialLookup, { _CredentialLookup } from './CredentialLookup';
|
||||||
import { CredentialsAPI } from '@api';
|
import { CredentialsAPI } from '@api';
|
||||||
@@ -9,19 +10,48 @@ describe('CredentialLookup', () => {
|
|||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = mountWithContexts(
|
CredentialsAPI.read.mockResolvedValueOnce({
|
||||||
<CredentialLookup credentialTypeId={1} label="Foo" onChange={() => {}} />
|
data: {
|
||||||
);
|
results: [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
|
||||||
|
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
|
||||||
|
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
|
||||||
|
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
|
||||||
|
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||||
|
],
|
||||||
|
count: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders successfully', () => {
|
test('should render successfully', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialLookup
|
||||||
|
credentialTypeId={1}
|
||||||
|
label="Foo"
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(wrapper.find('CredentialLookup')).toHaveLength(1);
|
expect(wrapper.find('CredentialLookup')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
test('should fetch credentials', () => {
|
|
||||||
|
test('should fetch credentials', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialLookup
|
||||||
|
credentialTypeId={1}
|
||||||
|
label="Foo"
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
|
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
expect(CredentialsAPI.read).toHaveBeenCalledWith({
|
||||||
credential_type: 1,
|
credential_type: 1,
|
||||||
@@ -30,11 +60,31 @@ describe('CredentialLookup', () => {
|
|||||||
page_size: 5,
|
page_size: 5,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test('should display label', () => {
|
|
||||||
|
test('should display label', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialLookup
|
||||||
|
credentialTypeId={1}
|
||||||
|
label="Foo"
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||||
expect(title.text()).toEqual('Foo');
|
expect(title.text()).toEqual('Foo');
|
||||||
});
|
});
|
||||||
test('should define default value for function props', () => {
|
|
||||||
|
test('should define default value for function props', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialLookup
|
||||||
|
credentialTypeId={1}
|
||||||
|
label="Foo"
|
||||||
|
onChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
expect(_CredentialLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
||||||
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
|
expect(_CredentialLookup.defaultProps.onBlur).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +1,69 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import { arrayOf, string, func, object, bool } from 'prop-types';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { FormGroup, Tooltip } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import { InstanceGroupsAPI } from '@api';
|
import { InstanceGroupsAPI } from '@api';
|
||||||
import Lookup from '@components/Lookup';
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
|
import { FieldTooltip } from '@components/FormField';
|
||||||
|
import Lookup from './Lookup';
|
||||||
|
import OptionsList from './shared/OptionsList';
|
||||||
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
const QS_CONFIG = getQSConfig('instance_groups', {
|
||||||
margin-left: 10px;
|
page: 1,
|
||||||
`;
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
const getInstanceGroups = async params => InstanceGroupsAPI.read(params);
|
function InstanceGroupsLookup(props) {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
required,
|
||||||
|
history,
|
||||||
|
i18n,
|
||||||
|
} = props;
|
||||||
|
const [instanceGroups, setInstanceGroups] = useState([]);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
class InstanceGroupsLookup extends React.Component {
|
useEffect(() => {
|
||||||
render() {
|
(async () => {
|
||||||
const { value, tooltip, onChange, className, i18n } = this.props;
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
try {
|
||||||
|
const { data } = await InstanceGroupsAPI.read(params);
|
||||||
|
setInstanceGroups(data.results);
|
||||||
|
setCount(data.count);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [history.location]);
|
||||||
|
|
||||||
/*
|
return (
|
||||||
Wrapping <div> added to workaround PF bug:
|
<FormGroup
|
||||||
https://github.com/patternfly/patternfly-react/issues/2855
|
className={className}
|
||||||
*/
|
label={i18n._(t`Instance Groups`)}
|
||||||
return (
|
fieldId="org-instance-groups"
|
||||||
<div className={className}>
|
>
|
||||||
<FormGroup
|
{tooltip && <FieldTooltip content={tooltip} />}
|
||||||
label={i18n._(t`Instance Groups`)}
|
<Lookup
|
||||||
fieldId="org-instance-groups"
|
id="org-instance-groups"
|
||||||
>
|
header={i18n._(t`Instance Groups`)}
|
||||||
{tooltip && (
|
value={value}
|
||||||
<Tooltip position="right" content={tooltip}>
|
onChange={onChange}
|
||||||
<QuestionCircleIcon />
|
qsConfig={QS_CONFIG}
|
||||||
</Tooltip>
|
multiple
|
||||||
)}
|
required={required}
|
||||||
<Lookup
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
id="org-instance-groups"
|
<OptionsList
|
||||||
lookupHeader={i18n._(t`Instance Groups`)}
|
value={state.selectedItems}
|
||||||
name="instanceGroups"
|
options={instanceGroups}
|
||||||
value={value}
|
optionCount={count}
|
||||||
onLookupSave={onChange}
|
|
||||||
getItems={getInstanceGroups}
|
|
||||||
qsNamespace="instance-group"
|
|
||||||
multiple
|
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
@@ -63,22 +84,33 @@ class InstanceGroupsLookup extends React.Component {
|
|||||||
isNumeric: true,
|
isNumeric: true,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
sortedColumnKey="name"
|
multiple={state.multiple}
|
||||||
|
header={i18n._(t`Instance Groups`)}
|
||||||
|
name="instanceGroups"
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
readOnly={!canDelete}
|
||||||
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
)}
|
||||||
</div>
|
/>
|
||||||
);
|
<LookupErrorMessage error={error} />
|
||||||
}
|
</FormGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
InstanceGroupsLookup.propTypes = {
|
InstanceGroupsLookup.propTypes = {
|
||||||
value: PropTypes.arrayOf(PropTypes.object).isRequired,
|
value: arrayOf(object).isRequired,
|
||||||
tooltip: PropTypes.string,
|
tooltip: string,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: func.isRequired,
|
||||||
|
className: string,
|
||||||
|
required: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
InstanceGroupsLookup.defaultProps = {
|
InstanceGroupsLookup.defaultProps = {
|
||||||
tooltip: '',
|
tooltip: '',
|
||||||
|
className: '',
|
||||||
|
required: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(InstanceGroupsLookup);
|
export default withI18n()(withRouter(InstanceGroupsLookup));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { string, func, bool } from 'prop-types';
|
import { string, func, bool } from 'prop-types';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
@@ -7,61 +8,94 @@ import { InventoriesAPI } from '@api';
|
|||||||
import { Inventory } from '@types';
|
import { Inventory } from '@types';
|
||||||
import Lookup from '@components/Lookup';
|
import Lookup from '@components/Lookup';
|
||||||
import { FieldTooltip } from '@components/FormField';
|
import { FieldTooltip } from '@components/FormField';
|
||||||
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
|
import OptionsList from './shared/OptionsList';
|
||||||
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
const getInventories = async params => InventoriesAPI.read(params);
|
const QS_CONFIG = getQSConfig('inventory', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
class InventoryLookup extends React.Component {
|
function InventoryLookup({
|
||||||
render() {
|
value,
|
||||||
const {
|
tooltip,
|
||||||
value,
|
onChange,
|
||||||
tooltip,
|
onBlur,
|
||||||
onChange,
|
required,
|
||||||
onBlur,
|
isValid,
|
||||||
required,
|
helperTextInvalid,
|
||||||
isValid,
|
i18n,
|
||||||
helperTextInvalid,
|
history,
|
||||||
i18n,
|
}) {
|
||||||
} = this.props;
|
const [inventories, setInventories] = useState([]);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<FormGroup
|
(async () => {
|
||||||
label={i18n._(t`Inventory`)}
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
isRequired={required}
|
try {
|
||||||
fieldId="inventory-lookup"
|
const { data } = await InventoriesAPI.read(params);
|
||||||
isValid={isValid}
|
setInventories(data.results);
|
||||||
helperTextInvalid={helperTextInvalid}
|
setCount(data.count);
|
||||||
>
|
} catch (err) {
|
||||||
{tooltip && <FieldTooltip content={tooltip} />}
|
setError(err);
|
||||||
<Lookup
|
}
|
||||||
id="inventory-lookup"
|
})();
|
||||||
lookupHeader={i18n._(t`Inventory`)}
|
}, [history.location]);
|
||||||
name="inventory"
|
|
||||||
value={value}
|
return (
|
||||||
onLookupSave={onChange}
|
<FormGroup
|
||||||
onBlur={onBlur}
|
label={i18n._(t`Inventory`)}
|
||||||
getItems={getInventories}
|
isRequired={required}
|
||||||
required={required}
|
fieldId="inventory-lookup"
|
||||||
qsNamespace="inventory"
|
isValid={isValid}
|
||||||
columns={[
|
helperTextInvalid={helperTextInvalid}
|
||||||
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
|
>
|
||||||
{
|
{tooltip && <FieldTooltip content={tooltip} />}
|
||||||
name: i18n._(t`Modified`),
|
<Lookup
|
||||||
key: 'modified',
|
id="inventory-lookup"
|
||||||
isSortable: false,
|
header={i18n._(t`Inventory`)}
|
||||||
isNumeric: true,
|
value={value}
|
||||||
},
|
onChange={onChange}
|
||||||
{
|
onBlur={onBlur}
|
||||||
name: i18n._(t`Created`),
|
required={required}
|
||||||
key: 'created',
|
qsConfig={QS_CONFIG}
|
||||||
isSortable: false,
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
isNumeric: true,
|
<OptionsList
|
||||||
},
|
value={state.selectedItems}
|
||||||
]}
|
options={inventories}
|
||||||
sortedColumnKey="name"
|
optionCount={count}
|
||||||
/>
|
columns={[
|
||||||
</FormGroup>
|
{ name: i18n._(t`Name`), key: 'name', isSortable: true },
|
||||||
);
|
{
|
||||||
}
|
name: i18n._(t`Modified`),
|
||||||
|
key: 'modified',
|
||||||
|
isSortable: false,
|
||||||
|
isNumeric: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Created`),
|
||||||
|
key: 'created',
|
||||||
|
isSortable: false,
|
||||||
|
isNumeric: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
multiple={state.multiple}
|
||||||
|
header={i18n._(t`Inventory`)}
|
||||||
|
name="inventory"
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
readOnly={!canDelete}
|
||||||
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<LookupErrorMessage error={error} />
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
InventoryLookup.propTypes = {
|
InventoryLookup.propTypes = {
|
||||||
@@ -77,4 +111,4 @@ InventoryLookup.defaultProps = {
|
|||||||
required: false,
|
required: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(InventoryLookup);
|
export default withI18n()(withRouter(InventoryLookup));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment, useReducer, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
string,
|
string,
|
||||||
bool,
|
bool,
|
||||||
@@ -15,20 +15,14 @@ import {
|
|||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
InputGroup as PFInputGroup,
|
InputGroup as PFInputGroup,
|
||||||
Modal,
|
Modal,
|
||||||
ToolbarItem,
|
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import AnsibleSelect from '../AnsibleSelect';
|
import reducer, { initReducer } from './shared/reducer';
|
||||||
import PaginatedDataList from '../PaginatedDataList';
|
import { ChipGroup, Chip } from '../Chip';
|
||||||
import VerticalSeperator from '../VerticalSeparator';
|
import { QSConfig } from '@types';
|
||||||
import DataListToolbar from '../DataListToolbar';
|
|
||||||
import CheckboxListItem from '../CheckboxListItem';
|
|
||||||
import SelectedList from '../SelectedList';
|
|
||||||
import { ChipGroup, Chip, CredentialChip } from '../Chip';
|
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
|
||||||
|
|
||||||
const SearchButton = styled(Button)`
|
const SearchButton = styled(Button)`
|
||||||
::after {
|
::after {
|
||||||
@@ -36,6 +30,7 @@ const SearchButton = styled(Button)`
|
|||||||
var(--pf-global--BorderColor--200);
|
var(--pf-global--BorderColor--200);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
SearchButton.displayName = 'SearchButton';
|
||||||
|
|
||||||
const InputGroup = styled(PFInputGroup)`
|
const InputGroup = styled(PFInputGroup)`
|
||||||
${props =>
|
${props =>
|
||||||
@@ -54,315 +49,124 @@ const ChipHolder = styled.div`
|
|||||||
border-bottom-right-radius: 3px;
|
border-bottom-right-radius: 3px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class Lookup extends React.Component {
|
function Lookup(props) {
|
||||||
constructor(props) {
|
const {
|
||||||
super(props);
|
id,
|
||||||
|
header,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
value,
|
||||||
|
multiple,
|
||||||
|
required,
|
||||||
|
qsConfig,
|
||||||
|
renderItemChip,
|
||||||
|
renderOptionsList,
|
||||||
|
history,
|
||||||
|
i18n,
|
||||||
|
} = props;
|
||||||
|
|
||||||
this.assertCorrectValueType();
|
const [state, dispatch] = useReducer(
|
||||||
let lookupSelectedItems = [];
|
reducer,
|
||||||
if (props.value) {
|
{ value, multiple, required },
|
||||||
lookupSelectedItems = props.multiple ? [...props.value] : [props.value];
|
initReducer
|
||||||
}
|
);
|
||||||
this.state = {
|
|
||||||
isModalOpen: false,
|
|
||||||
lookupSelectedItems,
|
|
||||||
results: [],
|
|
||||||
count: 0,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
this.qsConfig = getQSConfig(props.qsNamespace, {
|
|
||||||
page: 1,
|
|
||||||
page_size: 5,
|
|
||||||
order_by: props.sortedColumnKey,
|
|
||||||
});
|
|
||||||
this.handleModalToggle = this.handleModalToggle.bind(this);
|
|
||||||
this.toggleSelected = this.toggleSelected.bind(this);
|
|
||||||
this.saveModal = this.saveModal.bind(this);
|
|
||||||
this.getData = this.getData.bind(this);
|
|
||||||
this.clearQSParams = this.clearQSParams.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
useEffect(() => {
|
||||||
this.getData();
|
dispatch({ type: 'SET_MULTIPLE', value: multiple });
|
||||||
}
|
}, [multiple]);
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
useEffect(() => {
|
||||||
const { location, selectedCategory } = this.props;
|
dispatch({ type: 'SET_VALUE', value });
|
||||||
if (
|
}, [value]);
|
||||||
location !== prevProps.location ||
|
|
||||||
prevProps.selectedCategory !== selectedCategory
|
|
||||||
) {
|
|
||||||
this.getData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertCorrectValueType() {
|
const clearQSParams = () => {
|
||||||
const { multiple, value, selectCategoryOptions } = this.props;
|
|
||||||
if (selectCategoryOptions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!multiple && Array.isArray(value)) {
|
|
||||||
throw new Error(
|
|
||||||
'Lookup value must not be an array unless `multiple` is set'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (multiple && !Array.isArray(value)) {
|
|
||||||
throw new Error('Lookup value must be an array if `multiple` is set');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getData() {
|
|
||||||
const {
|
|
||||||
getItems,
|
|
||||||
location: { search },
|
|
||||||
} = this.props;
|
|
||||||
const queryParams = parseQueryString(this.qsConfig, search);
|
|
||||||
|
|
||||||
this.setState({ error: false });
|
|
||||||
try {
|
|
||||||
const { data } = await getItems(queryParams);
|
|
||||||
const { results, count } = data;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
results,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.setState({ error: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSelected(row) {
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
onLookupSave,
|
|
||||||
multiple,
|
|
||||||
onToggleItem,
|
|
||||||
selectCategoryOptions,
|
|
||||||
} = this.props;
|
|
||||||
const {
|
|
||||||
lookupSelectedItems: updatedSelectedItems,
|
|
||||||
isModalOpen,
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const selectedIndex = updatedSelectedItems.findIndex(
|
|
||||||
selectedRow => selectedRow.id === row.id
|
|
||||||
);
|
|
||||||
if (multiple) {
|
|
||||||
if (selectCategoryOptions) {
|
|
||||||
onToggleItem(row, isModalOpen);
|
|
||||||
}
|
|
||||||
if (selectedIndex > -1) {
|
|
||||||
updatedSelectedItems.splice(selectedIndex, 1);
|
|
||||||
this.setState({ lookupSelectedItems: updatedSelectedItems });
|
|
||||||
} else {
|
|
||||||
this.setState(prevState => ({
|
|
||||||
lookupSelectedItems: [...prevState.lookupSelectedItems, row],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.setState({ lookupSelectedItems: [row] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates the selected items from parent state
|
|
||||||
// This handles the case where the user removes chips from the lookup input
|
|
||||||
// while the modal is closed
|
|
||||||
if (!isModalOpen) {
|
|
||||||
onLookupSave(updatedSelectedItems, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleModalToggle() {
|
|
||||||
const { isModalOpen } = this.state;
|
|
||||||
const { value, multiple, selectCategory } = this.props;
|
|
||||||
// Resets the selected items from parent state whenever modal is opened
|
|
||||||
// This handles the case where the user closes/cancels the modal and
|
|
||||||
// opens it again
|
|
||||||
if (!isModalOpen) {
|
|
||||||
let lookupSelectedItems = [];
|
|
||||||
if (value) {
|
|
||||||
lookupSelectedItems = multiple ? [...value] : [value];
|
|
||||||
}
|
|
||||||
this.setState({ lookupSelectedItems });
|
|
||||||
} else {
|
|
||||||
this.clearQSParams();
|
|
||||||
if (selectCategory) {
|
|
||||||
selectCategory(null, 'Machine');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setState(prevState => ({
|
|
||||||
isModalOpen: !prevState.isModalOpen,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
saveModal() {
|
|
||||||
const { onLookupSave, name, multiple } = this.props;
|
|
||||||
const { lookupSelectedItems } = this.state;
|
|
||||||
const value = multiple
|
|
||||||
? lookupSelectedItems
|
|
||||||
: lookupSelectedItems[0] || null;
|
|
||||||
|
|
||||||
this.handleModalToggle();
|
|
||||||
onLookupSave(value, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearQSParams() {
|
|
||||||
const { history } = this.props;
|
|
||||||
const parts = history.location.search.replace(/^\?/, '').split('&');
|
const parts = history.location.search.replace(/^\?/, '').split('&');
|
||||||
const ns = this.qsConfig.namespace;
|
const ns = qsConfig.namespace;
|
||||||
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
|
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
|
||||||
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
|
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
const save = () => {
|
||||||
const {
|
const { selectedItems } = state;
|
||||||
isModalOpen,
|
const val = multiple ? selectedItems : selectedItems[0] || null;
|
||||||
lookupSelectedItems,
|
onChange(val);
|
||||||
error,
|
clearQSParams();
|
||||||
results,
|
dispatch({ type: 'CLOSE_MODAL' });
|
||||||
count,
|
};
|
||||||
} = this.state;
|
|
||||||
const {
|
const removeItem = item => {
|
||||||
form,
|
if (multiple) {
|
||||||
id,
|
onChange(value.filter(i => i.id !== item.id));
|
||||||
lookupHeader,
|
} else {
|
||||||
value,
|
onChange(null);
|
||||||
columns,
|
}
|
||||||
multiple,
|
};
|
||||||
name,
|
|
||||||
onBlur,
|
const closeModal = () => {
|
||||||
selectCategory,
|
clearQSParams();
|
||||||
required,
|
dispatch({ type: 'CLOSE_MODAL' });
|
||||||
i18n,
|
};
|
||||||
selectCategoryOptions,
|
|
||||||
selectedCategory,
|
const { isModalOpen, selectedItems } = state;
|
||||||
} = this.props;
|
const canDelete = !required || (multiple && value.length > 1);
|
||||||
const header = lookupHeader || i18n._(t`Items`);
|
let items = [];
|
||||||
const canDelete = !required || (multiple && value.length > 1);
|
if (multiple) {
|
||||||
const chips = () => {
|
items = value;
|
||||||
return selectCategoryOptions && selectCategoryOptions.length > 0 ? (
|
} else if (value) {
|
||||||
<ChipGroup defaultIsOpen numChips={5}>
|
items.push(value);
|
||||||
{(multiple ? value : [value]).map(chip => (
|
|
||||||
<CredentialChip
|
|
||||||
key={chip.id}
|
|
||||||
onClick={() => this.toggleSelected(chip)}
|
|
||||||
isReadOnly={!canDelete}
|
|
||||||
credential={chip}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
) : (
|
|
||||||
<ChipGroup defaultIsOpen numChips={5}>
|
|
||||||
{(multiple ? value : [value]).map(chip => (
|
|
||||||
<Chip
|
|
||||||
key={chip.id}
|
|
||||||
onClick={() => this.toggleSelected(chip)}
|
|
||||||
isReadOnly={!canDelete}
|
|
||||||
>
|
|
||||||
{chip.name}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<InputGroup onBlur={onBlur}>
|
|
||||||
<SearchButton
|
|
||||||
aria-label="Search"
|
|
||||||
id={id}
|
|
||||||
onClick={this.handleModalToggle}
|
|
||||||
variant={ButtonVariant.tertiary}
|
|
||||||
>
|
|
||||||
<SearchIcon />
|
|
||||||
</SearchButton>
|
|
||||||
<ChipHolder className="pf-c-form-control">
|
|
||||||
{value ? chips(value) : null}
|
|
||||||
</ChipHolder>
|
|
||||||
</InputGroup>
|
|
||||||
<Modal
|
|
||||||
className="awx-c-modal"
|
|
||||||
title={i18n._(t`Select ${header}`)}
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={this.handleModalToggle}
|
|
||||||
actions={[
|
|
||||||
<Button
|
|
||||||
key="save"
|
|
||||||
variant="primary"
|
|
||||||
onClick={this.saveModal}
|
|
||||||
style={results.length === 0 ? { display: 'none' } : {}}
|
|
||||||
>
|
|
||||||
{i18n._(t`Save`)}
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
key="cancel"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={this.handleModalToggle}
|
|
||||||
>
|
|
||||||
{results.length === 0 ? i18n._(t`Close`) : i18n._(t`Cancel`)}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{selectCategoryOptions && selectCategoryOptions.length > 0 && (
|
|
||||||
<ToolbarItem css=" display: flex; align-items: center;">
|
|
||||||
<span css="flex: 0 0 25%;">Selected Category</span>
|
|
||||||
<VerticalSeperator />
|
|
||||||
<AnsibleSelect
|
|
||||||
css="flex: 1 1 75%;"
|
|
||||||
id="multiCredentialsLookUp-select"
|
|
||||||
label="Selected Category"
|
|
||||||
data={selectCategoryOptions}
|
|
||||||
value={selectedCategory.label}
|
|
||||||
onChange={selectCategory}
|
|
||||||
form={form}
|
|
||||||
/>
|
|
||||||
</ToolbarItem>
|
|
||||||
)}
|
|
||||||
<PaginatedDataList
|
|
||||||
items={results}
|
|
||||||
itemCount={count}
|
|
||||||
pluralizedItemName={lookupHeader}
|
|
||||||
qsConfig={this.qsConfig}
|
|
||||||
toolbarColumns={columns}
|
|
||||||
renderItem={item => (
|
|
||||||
<CheckboxListItem
|
|
||||||
key={item.id}
|
|
||||||
itemId={item.id}
|
|
||||||
name={multiple ? item.name : name}
|
|
||||||
label={item.name}
|
|
||||||
isSelected={
|
|
||||||
selectCategoryOptions
|
|
||||||
? value.some(i => i.id === item.id)
|
|
||||||
: lookupSelectedItems.some(i => i.id === item.id)
|
|
||||||
}
|
|
||||||
onSelect={() => this.toggleSelected(item)}
|
|
||||||
isRadio={
|
|
||||||
!multiple ||
|
|
||||||
(selectCategoryOptions &&
|
|
||||||
selectCategoryOptions.length &&
|
|
||||||
selectedCategory.value !== 'Vault')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
|
||||||
showPageSizeOptions={false}
|
|
||||||
/>
|
|
||||||
{lookupSelectedItems.length > 0 && (
|
|
||||||
<SelectedList
|
|
||||||
label={i18n._(t`Selected`)}
|
|
||||||
selected={selectCategoryOptions ? value : lookupSelectedItems}
|
|
||||||
onRemove={this.toggleSelected}
|
|
||||||
isReadOnly={!canDelete}
|
|
||||||
isCredentialList={
|
|
||||||
selectCategoryOptions && selectCategoryOptions.length > 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{error ? <div>error</div> : ''}
|
|
||||||
</Modal>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<InputGroup onBlur={onBlur}>
|
||||||
|
<SearchButton
|
||||||
|
aria-label="Search"
|
||||||
|
id={id}
|
||||||
|
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
||||||
|
variant={ButtonVariant.tertiary}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</SearchButton>
|
||||||
|
<ChipHolder className="pf-c-form-control">
|
||||||
|
<ChipGroup numChips={5}>
|
||||||
|
{items.map(item =>
|
||||||
|
renderItemChip({
|
||||||
|
item,
|
||||||
|
removeItem,
|
||||||
|
canDelete,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ChipGroup>
|
||||||
|
</ChipHolder>
|
||||||
|
</InputGroup>
|
||||||
|
<Modal
|
||||||
|
className="awx-c-modal"
|
||||||
|
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="select"
|
||||||
|
variant="primary"
|
||||||
|
onClick={save}
|
||||||
|
style={
|
||||||
|
required && selectedItems.length === 0 ? { display: 'none' } : {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{i18n._(t`Select`)}
|
||||||
|
</Button>,
|
||||||
|
<Button key="cancel" variant="secondary" onClick={closeModal}>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{renderOptionsList({
|
||||||
|
state,
|
||||||
|
dispatch,
|
||||||
|
canDelete,
|
||||||
|
})}
|
||||||
|
</Modal>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Item = shape({
|
const Item = shape({
|
||||||
@@ -371,25 +175,33 @@ const Item = shape({
|
|||||||
|
|
||||||
Lookup.propTypes = {
|
Lookup.propTypes = {
|
||||||
id: string,
|
id: string,
|
||||||
getItems: func.isRequired,
|
header: string,
|
||||||
lookupHeader: string,
|
onChange: func.isRequired,
|
||||||
name: string,
|
|
||||||
onLookupSave: func.isRequired,
|
|
||||||
value: oneOfType([Item, arrayOf(Item)]),
|
value: oneOfType([Item, arrayOf(Item)]),
|
||||||
sortedColumnKey: string.isRequired,
|
|
||||||
multiple: bool,
|
multiple: bool,
|
||||||
required: bool,
|
required: bool,
|
||||||
qsNamespace: string,
|
onBlur: func,
|
||||||
|
qsConfig: QSConfig.isRequired,
|
||||||
|
renderItemChip: func,
|
||||||
|
renderOptionsList: func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
Lookup.defaultProps = {
|
Lookup.defaultProps = {
|
||||||
id: 'lookup-search',
|
id: 'lookup-search',
|
||||||
lookupHeader: null,
|
header: null,
|
||||||
name: null,
|
|
||||||
value: null,
|
value: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
required: false,
|
required: false,
|
||||||
qsNamespace: 'lookup',
|
onBlur: () => {},
|
||||||
|
renderItemChip: ({ item, removeItem, canDelete }) => (
|
||||||
|
<Chip
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => removeItem(item)}
|
||||||
|
isReadOnly={!canDelete}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Chip>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Lookup as _Lookup };
|
export { Lookup as _Lookup };
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
/* eslint-disable react/jsx-pascal-case */
|
/* eslint-disable react/jsx-pascal-case */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createMemoryHistory } from 'history';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import Lookup, { _Lookup } from './Lookup';
|
import { getQSConfig } from '@util/qs';
|
||||||
|
import Lookup from './Lookup';
|
||||||
let mockData = [{ name: 'foo', id: 1, isChecked: false }];
|
|
||||||
const mockColumns = [{ name: 'Name', key: 'name', isSortable: true }];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check that an element is present on the document body
|
* Check that an element is present on the document body
|
||||||
@@ -44,348 +42,118 @@ async function checkInputTagValues(wrapper, expected) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const QS_CONFIG = getQSConfig('test', {});
|
||||||
* Check lookup modal list for expected values
|
const TestList = () => <div />;
|
||||||
* @param {wrapper} enzyme wrapper instance
|
|
||||||
* @param {expected} array of [selected, text] pairs describing
|
|
||||||
* the expected visible state of the modal data list
|
|
||||||
*/
|
|
||||||
async function checkModalListValues(wrapper, expected) {
|
|
||||||
// fail if modal isn't actually visible
|
|
||||||
checkRootElementPresent('body div[role="dialog"]');
|
|
||||||
// check list item values
|
|
||||||
const rows = await waitForElement(
|
|
||||||
wrapper,
|
|
||||||
'DataListItemRow',
|
|
||||||
el => el.length === expected.length
|
|
||||||
);
|
|
||||||
expect(rows).toHaveLength(expected.length);
|
|
||||||
rows.forEach((el, index) => {
|
|
||||||
const [expectedChecked, expectedText] = expected[index];
|
|
||||||
expect(expectedText).toEqual(el.text());
|
|
||||||
expect(expectedChecked).toEqual(el.find('input').props().checked);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check lookup modal selection tags for expected values
|
|
||||||
* @param {wrapper} enzyme wrapper instance
|
|
||||||
* @param {expected} array of expected tag values
|
|
||||||
*/
|
|
||||||
async function checkModalTagValues(wrapper, expected) {
|
|
||||||
// fail if modal isn't actually visible
|
|
||||||
checkRootElementPresent('body div[role="dialog"]');
|
|
||||||
// check modal chip values
|
|
||||||
const chips = await waitForElement(
|
|
||||||
wrapper,
|
|
||||||
'Modal Chip span',
|
|
||||||
el => el.length === expected.length
|
|
||||||
);
|
|
||||||
expect(chips).toHaveLength(expected.length);
|
|
||||||
chips.forEach((el, index) => {
|
|
||||||
expect(el.text()).toEqual(expected[index]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('<Lookup multiple/>', () => {
|
|
||||||
let wrapper;
|
|
||||||
let onChange;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
|
|
||||||
onChange = jest.fn();
|
|
||||||
document.body.innerHTML = '';
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<Lookup
|
|
||||||
multiple
|
|
||||||
lookupHeader="Foo Bar"
|
|
||||||
name="foobar"
|
|
||||||
value={mockSelected}
|
|
||||||
onLookupSave={onChange}
|
|
||||||
getItems={() => ({
|
|
||||||
data: {
|
|
||||||
count: 2,
|
|
||||||
results: [
|
|
||||||
...mockSelected,
|
|
||||||
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
columns={mockColumns}
|
|
||||||
sortedColumnKey="name"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Initially renders succesfully', () => {
|
|
||||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Expected items are shown', async done => {
|
|
||||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
|
||||||
await checkInputTagValues(wrapper, ['foo']);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Open and close modal', async done => {
|
|
||||||
checkRootElementNotPresent('body div[role="dialog"]');
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
checkRootElementPresent('body div[role="dialog"]');
|
|
||||||
// This check couldn't pass unless api response was formatted properly
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
|
|
||||||
checkRootElementNotPresent('body div[role="dialog"]');
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
checkRootElementPresent('body div[role="dialog"]');
|
|
||||||
wrapper
|
|
||||||
.find('Modal button')
|
|
||||||
.findWhere(e => e.text() === 'Cancel')
|
|
||||||
.first()
|
|
||||||
.simulate('click');
|
|
||||||
checkRootElementNotPresent('body div[role="dialog"]');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Add item with checkbox then save', async done => {
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
wrapper
|
|
||||||
.find('DataListItemRow')
|
|
||||||
.findWhere(el => el.text() === 'bar')
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.simulate('change');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
|
|
||||||
wrapper
|
|
||||||
.find('Modal button')
|
|
||||||
.findWhere(e => e.text() === 'Save')
|
|
||||||
.first()
|
|
||||||
.simulate('click');
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onChange.mock.calls[0][0].map(({ name }) => name)).toEqual([
|
|
||||||
'foo',
|
|
||||||
'bar',
|
|
||||||
]);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Add item with checkbox then cancel', async done => {
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
wrapper
|
|
||||||
.find('DataListItemRow')
|
|
||||||
.findWhere(el => el.text() === 'bar')
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.simulate('change');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [true, 'bar']]);
|
|
||||||
wrapper
|
|
||||||
.find('Modal button')
|
|
||||||
.findWhere(e => e.text() === 'Cancel')
|
|
||||||
.first()
|
|
||||||
.simulate('click');
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(0);
|
|
||||||
await checkInputTagValues(wrapper, ['foo']);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove item with checkbox', async done => {
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, ['foo']);
|
|
||||||
wrapper
|
|
||||||
.find('DataListItemRow')
|
|
||||||
.findWhere(el => el.text() === 'foo')
|
|
||||||
.find('input[type="checkbox"]')
|
|
||||||
.simulate('change');
|
|
||||||
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, []);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove item with selected icon button', async done => {
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, ['foo']);
|
|
||||||
wrapper
|
|
||||||
.find('Modal Chip')
|
|
||||||
.findWhere(el => el.text() === 'foo')
|
|
||||||
.first()
|
|
||||||
.find('button')
|
|
||||||
.simulate('click');
|
|
||||||
await checkModalListValues(wrapper, [[false, 'foo'], [false, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, []);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove item with input group button', async done => {
|
|
||||||
await checkInputTagValues(wrapper, ['foo']);
|
|
||||||
wrapper
|
|
||||||
.find('Lookup InputGroup Chip')
|
|
||||||
.findWhere(el => el.text() === 'foo')
|
|
||||||
.first()
|
|
||||||
.find('button')
|
|
||||||
.simulate('click');
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onChange).toHaveBeenCalledWith([], 'foobar');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<Lookup />', () => {
|
describe('<Lookup />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let onChange;
|
let onChange;
|
||||||
|
|
||||||
|
async function mountWrapper() {
|
||||||
|
const mockSelected = [{ name: 'foo', id: 1, url: '/api/v2/item/1' }];
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Lookup
|
||||||
|
id="test"
|
||||||
|
multiple
|
||||||
|
header="Foo Bar"
|
||||||
|
value={mockSelected}
|
||||||
|
onChange={onChange}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
|
<TestList
|
||||||
|
id="options-list"
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
canDelete={canDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
|
|
||||||
onChange = jest.fn();
|
onChange = jest.fn();
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<Lookup
|
|
||||||
lookupHeader="Foo Bar"
|
|
||||||
name="foobar"
|
|
||||||
value={mockSelected}
|
|
||||||
onLookupSave={onChange}
|
|
||||||
getItems={() => ({
|
|
||||||
data: {
|
|
||||||
count: 2,
|
|
||||||
results: [
|
|
||||||
mockSelected,
|
|
||||||
{ name: 'bar', id: 2, url: '/api/v2/item/2' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
columns={mockColumns}
|
|
||||||
sortedColumnKey="name"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Initially renders succesfully', () => {
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render succesfully', async () => {
|
||||||
|
wrapper = await mountWrapper();
|
||||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
expect(wrapper.find('Lookup')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Expected items are shown', async done => {
|
test('should show selected items', async () => {
|
||||||
|
wrapper = await mountWrapper();
|
||||||
expect(wrapper.find('Lookup')).toHaveLength(1);
|
expect(wrapper.find('Lookup')).toHaveLength(1);
|
||||||
await checkInputTagValues(wrapper, ['foo']);
|
await checkInputTagValues(wrapper, ['foo']);
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Open and close modal', async done => {
|
test('should open and close modal', async () => {
|
||||||
checkRootElementNotPresent('body div[role="dialog"]');
|
wrapper = await mountWrapper();
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
checkRootElementPresent('body div[role="dialog"]');
|
|
||||||
// This check couldn't pass unless api response was formatted properly
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
wrapper.find('Modal button[aria-label="Close"]').simulate('click');
|
|
||||||
checkRootElementNotPresent('body div[role="dialog"]');
|
checkRootElementNotPresent('body div[role="dialog"]');
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||||
checkRootElementPresent('body div[role="dialog"]');
|
checkRootElementPresent('body div[role="dialog"]');
|
||||||
|
const list = wrapper.find('TestList');
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list.prop('state')).toEqual({
|
||||||
|
selectedItems: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
|
||||||
|
value: [{ id: 1, name: 'foo', url: '/api/v2/item/1' }],
|
||||||
|
multiple: true,
|
||||||
|
isModalOpen: true,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
expect(list.prop('dispatch')).toBeTruthy();
|
||||||
|
expect(list.prop('canDelete')).toEqual(true);
|
||||||
wrapper
|
wrapper
|
||||||
.find('Modal button')
|
.find('Modal button')
|
||||||
.findWhere(e => e.text() === 'Cancel')
|
.findWhere(e => e.text() === 'Cancel')
|
||||||
.first()
|
.first()
|
||||||
.simulate('click');
|
.simulate('click');
|
||||||
checkRootElementNotPresent('body div[role="dialog"]');
|
checkRootElementNotPresent('body div[role="dialog"]');
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Change selected item with radio control then save', async done => {
|
test('should remove item when X button clicked', async () => {
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
wrapper = await mountWrapper();
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
await checkInputTagValues(wrapper, ['foo']);
|
||||||
await checkModalTagValues(wrapper, ['foo']);
|
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListItemRow')
|
.find('Lookup InputGroup Chip')
|
||||||
.findWhere(el => el.text() === 'bar')
|
.findWhere(el => el.text() === 'foo')
|
||||||
.find('input[type="radio"]')
|
|
||||||
.simulate('change');
|
|
||||||
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, ['bar']);
|
|
||||||
wrapper
|
|
||||||
.find('Modal button')
|
|
||||||
.findWhere(e => e.text() === 'Save')
|
|
||||||
.first()
|
.first()
|
||||||
.simulate('click');
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(1);
|
|
||||||
const [[{ name }]] = onChange.mock.calls;
|
|
||||||
expect(name).toEqual('bar');
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Change selected item with checkbox then cancel', async done => {
|
|
||||||
wrapper.find('button[aria-label="Search"]').simulate('click');
|
|
||||||
await checkModalListValues(wrapper, [[true, 'foo'], [false, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, ['foo']);
|
|
||||||
wrapper
|
|
||||||
.find('DataListItemRow')
|
|
||||||
.findWhere(el => el.text() === 'bar')
|
|
||||||
.find('input[type="radio"]')
|
|
||||||
.simulate('change');
|
|
||||||
await checkModalListValues(wrapper, [[false, 'foo'], [true, 'bar']]);
|
|
||||||
await checkModalTagValues(wrapper, ['bar']);
|
|
||||||
wrapper
|
|
||||||
.find('Modal button')
|
|
||||||
.findWhere(e => e.text() === 'Cancel')
|
|
||||||
.first()
|
|
||||||
.simulate('click');
|
|
||||||
expect(onChange).toHaveBeenCalledTimes(0);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should re-fetch data when URL params change', async done => {
|
|
||||||
mockData = [{ name: 'foo', id: 1, isChecked: false }];
|
|
||||||
const history = createMemoryHistory({
|
|
||||||
initialEntries: ['/organizations/add'],
|
|
||||||
});
|
|
||||||
const getItems = jest.fn();
|
|
||||||
const LookupWrapper = mountWithContexts(
|
|
||||||
<_Lookup
|
|
||||||
multiple
|
|
||||||
name="foo"
|
|
||||||
lookupHeader="Foo Bar"
|
|
||||||
onLookupSave={() => {}}
|
|
||||||
value={mockData}
|
|
||||||
columns={mockColumns}
|
|
||||||
sortedColumnKey="name"
|
|
||||||
getItems={getItems}
|
|
||||||
location={{ history }}
|
|
||||||
i18n={{ _: val => val.toString() }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(getItems).toHaveBeenCalledTimes(1);
|
|
||||||
history.push('organizations/add?page=2');
|
|
||||||
LookupWrapper.setProps({
|
|
||||||
location: { history },
|
|
||||||
});
|
|
||||||
LookupWrapper.update();
|
|
||||||
expect(getItems).toHaveBeenCalledTimes(2);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should clear its query params when closed', async () => {
|
|
||||||
mockData = [{ name: 'foo', id: 1, isChecked: false }];
|
|
||||||
const history = createMemoryHistory({
|
|
||||||
initialEntries: ['/organizations/add?inventory.name=foo&bar=baz'],
|
|
||||||
});
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<_Lookup
|
|
||||||
multiple
|
|
||||||
name="foo"
|
|
||||||
lookupHeader="Foo Bar"
|
|
||||||
onLookupSave={() => {}}
|
|
||||||
value={mockData}
|
|
||||||
columns={mockColumns}
|
|
||||||
sortedColumnKey="name"
|
|
||||||
getItems={() => {}}
|
|
||||||
location={{ history }}
|
|
||||||
history={history}
|
|
||||||
qsNamespace="inventory"
|
|
||||||
i18n={{ _: val => val.toString() }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
wrapper
|
|
||||||
.find('InputGroup Button')
|
|
||||||
.at(0)
|
|
||||||
.invoke('onClick')();
|
.invoke('onClick')();
|
||||||
wrapper.find('Modal').invoke('onClose')();
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
expect(history.location.search).toEqual('?bar=baz');
|
expect(onChange).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should pass canDelete false if required single select', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
const mockSelected = { name: 'foo', id: 1, url: '/api/v2/item/1' };
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Lookup
|
||||||
|
id="test"
|
||||||
|
header="Foo Bar"
|
||||||
|
required
|
||||||
|
value={mockSelected}
|
||||||
|
onChange={onChange}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
|
<TestList
|
||||||
|
id="options-list"
|
||||||
|
state={state}
|
||||||
|
dispatch={dispatch}
|
||||||
|
canDelete={canDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.find('button[aria-label="Search"]').simulate('click');
|
||||||
|
const list = wrapper.find('TestList');
|
||||||
|
expect(list.prop('canDelete')).toEqual(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,146 +1,167 @@
|
|||||||
import React from 'react';
|
import React, { Fragment, useState, useEffect } from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { FormGroup, Tooltip } from '@patternfly/react-core';
|
import { FormGroup, ToolbarItem } from '@patternfly/react-core';
|
||||||
import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
import Lookup from '@components/Lookup';
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
|
import { FieldTooltip } from '@components/FormField';
|
||||||
|
import { CredentialChip } from '@components/Chip';
|
||||||
|
import VerticalSeperator from '@components/VerticalSeparator';
|
||||||
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
|
import Lookup from './Lookup';
|
||||||
|
import OptionsList from './shared/OptionsList';
|
||||||
|
|
||||||
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
|
const QS_CONFIG = getQSConfig('credentials', {
|
||||||
margin-left: 10px;
|
page: 1,
|
||||||
`;
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
class MultiCredentialsLookup extends React.Component {
|
async function loadCredentialTypes() {
|
||||||
constructor(props) {
|
const { data } = await CredentialTypesAPI.read();
|
||||||
super(props);
|
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
|
||||||
|
return data.results.filter(type => acceptableTypes.includes(type.kind));
|
||||||
|
}
|
||||||
|
|
||||||
this.state = {
|
async function loadCredentials(params, selectedCredentialTypeId) {
|
||||||
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' },
|
params.credential_type = selectedCredentialTypeId || 1;
|
||||||
credentialTypes: [],
|
const { data } = await CredentialsAPI.read(params);
|
||||||
};
|
return data;
|
||||||
this.loadCredentialTypes = this.loadCredentialTypes.bind(this);
|
}
|
||||||
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind(
|
|
||||||
this
|
|
||||||
);
|
|
||||||
this.loadCredentials = this.loadCredentials.bind(this);
|
|
||||||
this.toggleCredentialSelection = this.toggleCredentialSelection.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
function MultiCredentialsLookup(props) {
|
||||||
this.loadCredentialTypes();
|
const { tooltip, value, onChange, onError, history, i18n } = props;
|
||||||
}
|
const [credentialTypes, setCredentialTypes] = useState([]);
|
||||||
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
|
const [credentials, setCredentials] = useState([]);
|
||||||
|
const [credentialsCount, setCredentialsCount] = useState(0);
|
||||||
|
|
||||||
async loadCredentialTypes() {
|
useEffect(() => {
|
||||||
const { onError } = this.props;
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await CredentialTypesAPI.read();
|
const types = await loadCredentialTypes();
|
||||||
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
|
setCredentialTypes(types);
|
||||||
const credentialTypes = [];
|
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||||
data.results.forEach(cred => {
|
setSelectedType(match);
|
||||||
acceptableTypes.forEach(aT => {
|
} catch (err) {
|
||||||
if (aT === cred.kind) {
|
onError(err);
|
||||||
// This object has several repeated values as some of it's children
|
}
|
||||||
// require different field values.
|
})();
|
||||||
cred = {
|
}, [onError]);
|
||||||
id: cred.id,
|
|
||||||
key: cred.id,
|
|
||||||
kind: cred.kind,
|
|
||||||
type: cred.namespace,
|
|
||||||
value: cred.name,
|
|
||||||
label: cred.name,
|
|
||||||
isDisabled: false,
|
|
||||||
};
|
|
||||||
credentialTypes.push(cred);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.setState({ credentialTypes });
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCredentials(params) {
|
useEffect(() => {
|
||||||
const { selectedCredentialType } = this.state;
|
(async () => {
|
||||||
params.credential_type = selectedCredentialType.id || 1;
|
if (!selectedType) {
|
||||||
return CredentialsAPI.read(params);
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
const { results, count } = await loadCredentials(
|
||||||
|
params,
|
||||||
|
selectedType.id
|
||||||
|
);
|
||||||
|
setCredentials(results);
|
||||||
|
setCredentialsCount(count);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [selectedType, history.location.search, onError]);
|
||||||
|
|
||||||
toggleCredentialSelection(newCredential) {
|
const renderChip = ({ item, removeItem, canDelete }) => (
|
||||||
const { onChange, credentials: credentialsToUpdate } = this.props;
|
<CredentialChip
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => removeItem(item)}
|
||||||
|
isReadOnly={!canDelete}
|
||||||
|
credential={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
let newCredentialsList;
|
const isMultiple = selectedType && selectedType.kind === 'vault';
|
||||||
const isSelectedCredentialInState =
|
|
||||||
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length >
|
|
||||||
0;
|
|
||||||
|
|
||||||
if (isSelectedCredentialInState) {
|
return (
|
||||||
newCredentialsList = credentialsToUpdate.filter(
|
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
|
||||||
cred => cred.id !== newCredential.id
|
{tooltip && <FieldTooltip content={tooltip} />}
|
||||||
);
|
<Lookup
|
||||||
} else {
|
id="multiCredential"
|
||||||
newCredentialsList = credentialsToUpdate.filter(
|
header={i18n._(t`Credentials`)}
|
||||||
credential =>
|
value={value}
|
||||||
credential.kind === 'vault' || credential.kind !== newCredential.kind
|
multiple
|
||||||
);
|
onChange={onChange}
|
||||||
newCredentialsList = [...newCredentialsList, newCredential];
|
qsConfig={QS_CONFIG}
|
||||||
}
|
renderItemChip={renderChip}
|
||||||
onChange(newCredentialsList);
|
renderOptionsList={({ state, dispatch, canDelete }) => {
|
||||||
}
|
return (
|
||||||
|
<Fragment>
|
||||||
handleCredentialTypeSelect(value, type) {
|
{credentialTypes && credentialTypes.length > 0 && (
|
||||||
const { credentialTypes } = this.state;
|
<ToolbarItem css=" display: flex; align-items: center;">
|
||||||
const selectedType = credentialTypes.filter(item => item.label === type);
|
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
|
||||||
this.setState({ selectedCredentialType: selectedType[0] });
|
<VerticalSeperator />
|
||||||
}
|
<AnsibleSelect
|
||||||
|
css="flex: 1 1 75%;"
|
||||||
render() {
|
id="multiCredentialsLookUp-select"
|
||||||
const { selectedCredentialType, credentialTypes } = this.state;
|
label={i18n._(t`Selected Category`)}
|
||||||
const { tooltip, i18n, credentials } = this.props;
|
data={credentialTypes.map(type => ({
|
||||||
return (
|
key: type.id,
|
||||||
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
|
value: type.id,
|
||||||
{tooltip && (
|
label: type.name,
|
||||||
<Tooltip position="right" content={tooltip}>
|
isDisabled: false,
|
||||||
<QuestionCircleIcon />
|
}))}
|
||||||
</Tooltip>
|
value={selectedType && selectedType.id}
|
||||||
)}
|
onChange={(e, id) => {
|
||||||
{credentialTypes && (
|
setSelectedType(
|
||||||
<Lookup
|
credentialTypes.find(o => o.id === parseInt(id, 10))
|
||||||
selectCategoryOptions={credentialTypes}
|
);
|
||||||
selectCategory={this.handleCredentialTypeSelect}
|
}}
|
||||||
selectedCategory={selectedCredentialType}
|
/>
|
||||||
onToggleItem={this.toggleCredentialSelection}
|
</ToolbarItem>
|
||||||
onloadCategories={this.loadCredentialTypes}
|
)}
|
||||||
id="multiCredential"
|
<OptionsList
|
||||||
lookupHeader={i18n._(t`Credentials`)}
|
value={state.selectedItems}
|
||||||
name="credentials"
|
options={credentials}
|
||||||
value={credentials}
|
optionCount={credentialsCount}
|
||||||
multiple
|
columns={[
|
||||||
onLookupSave={() => {}}
|
{
|
||||||
getItems={this.loadCredentials}
|
name: i18n._(t`Name`),
|
||||||
qsNamespace="credentials"
|
key: 'name',
|
||||||
columns={[
|
isSortable: true,
|
||||||
{
|
isSearchable: true,
|
||||||
name: i18n._(t`Name`),
|
},
|
||||||
key: 'name',
|
]}
|
||||||
isSortable: true,
|
multiple={isMultiple}
|
||||||
isSearchable: true,
|
header={i18n._(t`Credentials`)}
|
||||||
},
|
name="credentials"
|
||||||
]}
|
qsConfig={QS_CONFIG}
|
||||||
sortedColumnKey="name"
|
readOnly={!canDelete}
|
||||||
/>
|
selectItem={item => {
|
||||||
)}
|
if (isMultiple) {
|
||||||
</FormGroup>
|
return dispatch({ type: 'SELECT_ITEM', item });
|
||||||
);
|
}
|
||||||
}
|
const selectedItems = state.selectedItems.filter(
|
||||||
|
i => i.kind !== item.kind
|
||||||
|
);
|
||||||
|
selectedItems.push(item);
|
||||||
|
return dispatch({
|
||||||
|
type: 'SET_SELECTED_ITEMS',
|
||||||
|
selectedItems,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
renderItemChip={renderChip}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
MultiCredentialsLookup.propTypes = {
|
MultiCredentialsLookup.propTypes = {
|
||||||
tooltip: PropTypes.string,
|
tooltip: PropTypes.string,
|
||||||
credentials: PropTypes.arrayOf(
|
value: PropTypes.arrayOf(
|
||||||
PropTypes.shape({
|
PropTypes.shape({
|
||||||
id: PropTypes.number,
|
id: PropTypes.number,
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
@@ -155,8 +176,8 @@ MultiCredentialsLookup.propTypes = {
|
|||||||
|
|
||||||
MultiCredentialsLookup.defaultProps = {
|
MultiCredentialsLookup.defaultProps = {
|
||||||
tooltip: '',
|
tooltip: '',
|
||||||
credentials: [],
|
value: [],
|
||||||
};
|
};
|
||||||
export { MultiCredentialsLookup as _MultiCredentialsLookup };
|
|
||||||
|
|
||||||
export default withI18n()(MultiCredentialsLookup);
|
export { MultiCredentialsLookup as _MultiCredentialsLookup };
|
||||||
|
export default withI18n()(withRouter(MultiCredentialsLookup));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import MultiCredentialsLookup from './MultiCredentialsLookup';
|
import MultiCredentialsLookup from './MultiCredentialsLookup';
|
||||||
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
import { CredentialsAPI, CredentialTypesAPI } from '@api';
|
||||||
|
|
||||||
@@ -8,9 +8,6 @@ jest.mock('@api');
|
|||||||
|
|
||||||
describe('<MultiCredentialsLookup />', () => {
|
describe('<MultiCredentialsLookup />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let lookup;
|
|
||||||
let credLookup;
|
|
||||||
let onChange;
|
|
||||||
|
|
||||||
const credentials = [
|
const credentials = [
|
||||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
@@ -18,8 +15,9 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
{ name: 'Gatsby', id: 21, kind: 'vault' },
|
{ name: 'Gatsby', id: 21, kind: 'vault' },
|
||||||
{ name: 'Gatsby', id: 8, kind: 'Machine' },
|
{ name: 'Gatsby', id: 8, kind: 'Machine' },
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
CredentialTypesAPI.read.mockResolvedValue({
|
CredentialTypesAPI.read.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
@@ -46,17 +44,6 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
count: 3,
|
count: 3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
onChange = jest.fn();
|
|
||||||
wrapper = mountWithContexts(
|
|
||||||
<MultiCredentialsLookup
|
|
||||||
onError={() => {}}
|
|
||||||
credentials={credentials}
|
|
||||||
onChange={onChange}
|
|
||||||
tooltip="This is credentials look up"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
lookup = wrapper.find('Lookup');
|
|
||||||
credLookup = wrapper.find('MultiCredentialsLookup');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -64,16 +51,40 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MultiCredentialsLookup renders properly', () => {
|
test('MultiCredentialsLookup renders properly', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
value={credentials}
|
||||||
|
tooltip="This is credentials look up"
|
||||||
|
onChange={onChange}
|
||||||
|
onError={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
|
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
|
||||||
expect(CredentialTypesAPI.read).toHaveBeenCalled();
|
expect(CredentialTypesAPI.read).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('onChange is called when you click to remove a credential from input', async () => {
|
test('onChange is called when you click to remove a credential from input', async () => {
|
||||||
const chip = wrapper.find('PFChip').find({ isOverflowChip: false });
|
const onChange = jest.fn();
|
||||||
const button = chip.at(1).find('ChipButton');
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
value={credentials}
|
||||||
|
tooltip="This is credentials look up"
|
||||||
|
onChange={onChange}
|
||||||
|
onError={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const chip = wrapper.find('CredentialChip');
|
||||||
expect(chip).toHaveLength(4);
|
expect(chip).toHaveLength(4);
|
||||||
button.prop('onClick')();
|
const button = chip.at(1).find('ChipButton');
|
||||||
|
await act(async () => {
|
||||||
|
button.invoke('onClick')();
|
||||||
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||||
@@ -81,33 +92,122 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can change credential types', () => {
|
test('should change credential types', async () => {
|
||||||
lookup.prop('selectCategory')({}, 'Vault');
|
await act(async () => {
|
||||||
expect(credLookup.state('selectedCredentialType')).toEqual({
|
wrapper = mountWithContexts(
|
||||||
id: 500,
|
<MultiCredentialsLookup
|
||||||
key: 500,
|
value={credentials}
|
||||||
kind: 'vault',
|
tooltip="This is credentials look up"
|
||||||
type: 'buzz',
|
onChange={() => {}}
|
||||||
value: 'Vault',
|
onError={() => {}}
|
||||||
label: 'Vault',
|
/>
|
||||||
isDisabled: false,
|
);
|
||||||
});
|
});
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalled();
|
const searchButton = await waitForElement(wrapper, 'SearchButton');
|
||||||
|
await act(async () => {
|
||||||
|
searchButton.invoke('onClick')();
|
||||||
|
});
|
||||||
|
const select = await waitForElement(wrapper, 'AnsibleSelect');
|
||||||
|
CredentialsAPI.read.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
||||||
|
],
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
||||||
|
await act(async () => {
|
||||||
|
select.invoke('onChange')({}, 500);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(CredentialsAPI.read).toHaveBeenCalledTimes(3);
|
||||||
|
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
||||||
|
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
test('Toggle credentials only adds 1 credential per credential type except vault(see below)', () => {
|
|
||||||
lookup.prop('onToggleItem')({ name: 'Party', id: 9, kind: 'Machine' });
|
test('should only add 1 credential per credential type except vault(see below)', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
value={credentials}
|
||||||
|
tooltip="This is credentials look up"
|
||||||
|
onChange={onChange}
|
||||||
|
onError={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const searchButton = await waitForElement(wrapper, 'SearchButton');
|
||||||
|
await act(async () => {
|
||||||
|
searchButton.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const optionsList = wrapper.find('OptionsList');
|
||||||
|
expect(optionsList.prop('multiple')).toEqual(false);
|
||||||
|
act(() => {
|
||||||
|
optionsList.invoke('selectItem')({
|
||||||
|
id: 5,
|
||||||
|
kind: 'Machine',
|
||||||
|
name: 'Cred 5',
|
||||||
|
url: 'www.google.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||||
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
||||||
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||||
{ id: 9, kind: 'Machine', name: 'Party' },
|
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
test('Toggle credentials only adds 1 credential per credential type', () => {
|
|
||||||
lookup.prop('onToggleItem')({ name: 'Party', id: 22, kind: 'vault' });
|
test('should allow multiple vault credentials', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
value={credentials}
|
||||||
|
tooltip="This is credentials look up"
|
||||||
|
onChange={onChange}
|
||||||
|
onError={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const searchButton = await waitForElement(wrapper, 'SearchButton');
|
||||||
|
await act(async () => {
|
||||||
|
searchButton.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const typeSelect = wrapper.find('AnsibleSelect');
|
||||||
|
act(() => {
|
||||||
|
typeSelect.invoke('onChange')({}, 500);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const optionsList = wrapper.find('OptionsList');
|
||||||
|
expect(optionsList.prop('multiple')).toEqual(true);
|
||||||
|
act(() => {
|
||||||
|
optionsList.invoke('selectItem')({
|
||||||
|
id: 5,
|
||||||
|
kind: 'Machine',
|
||||||
|
name: 'Cred 5',
|
||||||
|
url: 'www.google.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('Button[variant="primary"]').invoke('onClick')();
|
||||||
|
});
|
||||||
expect(onChange).toBeCalledWith([
|
expect(onChange).toBeCalledWith([
|
||||||
...credentials,
|
{ id: 1, kind: 'cloud', name: 'Foo', url: 'www.google.com' },
|
||||||
{ name: 'Party', id: 22, kind: 'vault' },
|
{ id: 2, kind: 'ssh', name: 'Alex', url: 'www.google.com' },
|
||||||
|
{ id: 21, kind: 'vault', name: 'Gatsby' },
|
||||||
|
{ id: 8, kind: 'Machine', name: 'Gatsby' },
|
||||||
|
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { string, func, bool } from 'prop-types';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { string, func, bool } from 'prop-types';
|
|
||||||
import { OrganizationsAPI } from '@api';
|
import { OrganizationsAPI } from '@api';
|
||||||
import { Organization } from '@types';
|
import { Organization } from '@types';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import Lookup from '@components/Lookup';
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
|
import Lookup from './Lookup';
|
||||||
|
import OptionsList from './shared/OptionsList';
|
||||||
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
const getOrganizations = async params => OrganizationsAPI.read(params);
|
const QS_CONFIG = getQSConfig('organizations', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
function OrganizationLookup({
|
function OrganizationLookup({
|
||||||
helperTextInvalid,
|
helperTextInvalid,
|
||||||
@@ -17,7 +25,25 @@ function OrganizationLookup({
|
|||||||
onChange,
|
onChange,
|
||||||
required,
|
required,
|
||||||
value,
|
value,
|
||||||
|
history,
|
||||||
}) {
|
}) {
|
||||||
|
const [organizations, setOrganizations] = useState([]);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
try {
|
||||||
|
const { data } = await OrganizationsAPI.read(params);
|
||||||
|
setOrganizations(data.results);
|
||||||
|
setCount(data.count);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [history.location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId="organization"
|
fieldId="organization"
|
||||||
@@ -28,15 +54,29 @@ function OrganizationLookup({
|
|||||||
>
|
>
|
||||||
<Lookup
|
<Lookup
|
||||||
id="organization"
|
id="organization"
|
||||||
lookupHeader={i18n._(t`Organization`)}
|
header={i18n._(t`Organization`)}
|
||||||
name="organization"
|
|
||||||
value={value}
|
value={value}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onLookupSave={onChange}
|
onChange={onChange}
|
||||||
getItems={getOrganizations}
|
qsConfig={QS_CONFIG}
|
||||||
required={required}
|
required={required}
|
||||||
sortedColumnKey="name"
|
sortedColumnKey="name"
|
||||||
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
|
<OptionsList
|
||||||
|
value={state.selectedItems}
|
||||||
|
options={organizations}
|
||||||
|
optionCount={count}
|
||||||
|
multiple={state.multiple}
|
||||||
|
header={i18n._(t`Organization`)}
|
||||||
|
name="organization"
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
readOnly={!canDelete}
|
||||||
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
<LookupErrorMessage error={error} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,5 +98,5 @@ OrganizationLookup.defaultProps = {
|
|||||||
value: null,
|
value: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(OrganizationLookup);
|
|
||||||
export { OrganizationLookup as _OrganizationLookup };
|
export { OrganizationLookup as _OrganizationLookup };
|
||||||
|
export default withI18n()(withRouter(OrganizationLookup));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
|
import OrganizationLookup, { _OrganizationLookup } from './OrganizationLookup';
|
||||||
import { OrganizationsAPI } from '@api';
|
import { OrganizationsAPI } from '@api';
|
||||||
@@ -8,18 +9,22 @@ jest.mock('@api');
|
|||||||
describe('OrganizationLookup', () => {
|
describe('OrganizationLookup', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders successfully', () => {
|
test('should render successfully', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
expect(wrapper).toHaveLength(1);
|
expect(wrapper).toHaveLength(1);
|
||||||
});
|
});
|
||||||
test('should fetch organizations', () => {
|
|
||||||
|
test('should fetch organizations', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
|
expect(OrganizationsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
expect(OrganizationsAPI.read).toHaveBeenCalledWith({
|
expect(OrganizationsAPI.read).toHaveBeenCalledWith({
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
@@ -27,11 +32,19 @@ describe('OrganizationLookup', () => {
|
|||||||
page_size: 5,
|
page_size: 5,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test('should display "Organization" label', () => {
|
|
||||||
|
test('should display "Organization" label', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
const title = wrapper.find('FormGroup .pf-c-form__label-text');
|
||||||
expect(title.text()).toEqual('Organization');
|
expect(title.text()).toEqual('Organization');
|
||||||
});
|
});
|
||||||
test('should define default value for function props', () => {
|
|
||||||
|
test('should define default value for function props', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<OrganizationLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
expect(_OrganizationLookup.defaultProps.onBlur).toBeInstanceOf(Function);
|
||||||
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
|
expect(_OrganizationLookup.defaultProps.onBlur).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,59 +1,90 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { string, func, bool } from 'prop-types';
|
import { string, func, bool } from 'prop-types';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import { ProjectsAPI } from '@api';
|
import { ProjectsAPI } from '@api';
|
||||||
import { Project } from '@types';
|
import { Project } from '@types';
|
||||||
import Lookup from '@components/Lookup';
|
|
||||||
import { FieldTooltip } from '@components/FormField';
|
import { FieldTooltip } from '@components/FormField';
|
||||||
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
|
import Lookup from './Lookup';
|
||||||
|
import OptionsList from './shared/OptionsList';
|
||||||
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
class ProjectLookup extends React.Component {
|
const QS_CONFIG = getQSConfig('project', {
|
||||||
render() {
|
page: 1,
|
||||||
const {
|
page_size: 5,
|
||||||
helperTextInvalid,
|
order_by: 'name',
|
||||||
i18n,
|
});
|
||||||
isValid,
|
|
||||||
onChange,
|
|
||||||
required,
|
|
||||||
tooltip,
|
|
||||||
value,
|
|
||||||
onBlur,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const loadProjects = async params => {
|
function ProjectLookup({
|
||||||
const response = await ProjectsAPI.read(params);
|
helperTextInvalid,
|
||||||
const { results, count } = response.data;
|
i18n,
|
||||||
if (count === 1) {
|
isValid,
|
||||||
onChange(results[0], 'project');
|
onChange,
|
||||||
|
required,
|
||||||
|
tooltip,
|
||||||
|
value,
|
||||||
|
onBlur,
|
||||||
|
history,
|
||||||
|
}) {
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
try {
|
||||||
|
const { data } = await ProjectsAPI.read(params);
|
||||||
|
setProjects(data.results);
|
||||||
|
setCount(data.count);
|
||||||
|
if (data.count === 1) {
|
||||||
|
onChange(data.results[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
}
|
}
|
||||||
return response;
|
})();
|
||||||
};
|
}, [onChange, history.location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId="project"
|
fieldId="project"
|
||||||
helperTextInvalid={helperTextInvalid}
|
helperTextInvalid={helperTextInvalid}
|
||||||
isRequired={required}
|
isRequired={required}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
label={i18n._(t`Project`)}
|
label={i18n._(t`Project`)}
|
||||||
>
|
>
|
||||||
{tooltip && <FieldTooltip content={tooltip} />}
|
{tooltip && <FieldTooltip content={tooltip} />}
|
||||||
<Lookup
|
<Lookup
|
||||||
id="project"
|
id="project"
|
||||||
lookupHeader={i18n._(t`Project`)}
|
header={i18n._(t`Project`)}
|
||||||
name="project"
|
name="project"
|
||||||
value={value}
|
value={value}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onLookupSave={onChange}
|
onChange={onChange}
|
||||||
getItems={loadProjects}
|
required={required}
|
||||||
required={required}
|
qsConfig={QS_CONFIG}
|
||||||
sortedColumnKey="name"
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
qsNamespace="project"
|
<OptionsList
|
||||||
/>
|
value={state.selectedItems}
|
||||||
</FormGroup>
|
options={projects}
|
||||||
);
|
optionCount={count}
|
||||||
}
|
multiple={state.multiple}
|
||||||
|
header={i18n._(t`Project`)}
|
||||||
|
name="project"
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
readOnly={!canDelete}
|
||||||
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<LookupErrorMessage error={error} />
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ProjectLookup.propTypes = {
|
ProjectLookup.propTypes = {
|
||||||
@@ -75,4 +106,5 @@ ProjectLookup.defaultProps = {
|
|||||||
onBlur: () => {},
|
onBlur: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(ProjectLookup);
|
export { ProjectLookup as _ProjectLookup };
|
||||||
|
export default withI18n()(withRouter(ProjectLookup));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
import { sleep } from '@testUtils/testUtils';
|
import { sleep } from '@testUtils/testUtils';
|
||||||
import { ProjectsAPI } from '@api';
|
import { ProjectsAPI } from '@api';
|
||||||
@@ -15,9 +16,11 @@ describe('<ProjectLookup />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
await act(async () => {
|
||||||
|
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
||||||
|
});
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(onChange).toHaveBeenCalledWith({ id: 1 }, 'project');
|
expect(onChange).toHaveBeenCalledWith({ id: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not auto-select project when multiple available', async () => {
|
test('should not auto-select project when multiple available', async () => {
|
||||||
@@ -28,7 +31,9 @@ describe('<ProjectLookup />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
await act(async () => {
|
||||||
|
mountWithContexts(<ProjectLookup onChange={onChange} />);
|
||||||
|
});
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
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 PropTypes from 'prop-types';
|
||||||
import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
|
import { Split as PFSplit, SplitItem } from '@patternfly/react-core';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { ChipGroup, Chip, CredentialChip } from '../Chip';
|
import { ChipGroup, Chip } from '../Chip';
|
||||||
import VerticalSeparator from '../VerticalSeparator';
|
import VerticalSeparator from '../VerticalSeparator';
|
||||||
|
|
||||||
const Split = styled(PFSplit)`
|
const Split = styled(PFSplit)`
|
||||||
@@ -26,34 +26,31 @@ class SelectedList extends Component {
|
|||||||
onRemove,
|
onRemove,
|
||||||
displayKey,
|
displayKey,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isCredentialList,
|
renderItemChip,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const chips = isCredentialList
|
|
||||||
? selected.map(item => (
|
const renderChip =
|
||||||
<CredentialChip
|
renderItemChip ||
|
||||||
key={item.id}
|
(({ item, removeItem }) => (
|
||||||
isReadOnly={isReadOnly}
|
<Chip key={item.id} onClick={removeItem} isReadOnly={isReadOnly}>
|
||||||
onClick={() => onRemove(item)}
|
{item[displayKey]}
|
||||||
credential={item}
|
</Chip>
|
||||||
>
|
));
|
||||||
{item[displayKey]}
|
|
||||||
</CredentialChip>
|
|
||||||
))
|
|
||||||
: selected.map(item => (
|
|
||||||
<Chip
|
|
||||||
key={item.id}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
onClick={() => onRemove(item)}
|
|
||||||
>
|
|
||||||
{item[displayKey]}
|
|
||||||
</Chip>
|
|
||||||
));
|
|
||||||
return (
|
return (
|
||||||
<Split>
|
<Split>
|
||||||
<SplitLabelItem>{label}</SplitLabelItem>
|
<SplitLabelItem>{label}</SplitLabelItem>
|
||||||
<VerticalSeparator />
|
<VerticalSeparator />
|
||||||
<SplitItem>
|
<SplitItem>
|
||||||
<ChipGroup numChips={5}>{chips}</ChipGroup>
|
<ChipGroup numChips={5}>
|
||||||
|
{selected.map(item =>
|
||||||
|
renderChip({
|
||||||
|
item,
|
||||||
|
removeItem: () => onRemove(item),
|
||||||
|
canDelete: !isReadOnly,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ChipGroup>
|
||||||
</SplitItem>
|
</SplitItem>
|
||||||
</Split>
|
</Split>
|
||||||
);
|
);
|
||||||
@@ -66,6 +63,7 @@ SelectedList.propTypes = {
|
|||||||
onRemove: PropTypes.func,
|
onRemove: PropTypes.func,
|
||||||
selected: PropTypes.arrayOf(PropTypes.object).isRequired,
|
selected: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
isReadOnly: PropTypes.bool,
|
isReadOnly: PropTypes.bool,
|
||||||
|
renderItemChip: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
SelectedList.defaultProps = {
|
SelectedList.defaultProps = {
|
||||||
@@ -73,6 +71,7 @@ SelectedList.defaultProps = {
|
|||||||
label: 'Selected',
|
label: 'Selected',
|
||||||
onRemove: () => null,
|
onRemove: () => null,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
|
renderItemChip: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SelectedList;
|
export default SelectedList;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import HostAdd from './HostAdd';
|
import HostAdd from './HostAdd';
|
||||||
@@ -7,8 +8,11 @@ import { HostsAPI } from '@api';
|
|||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<HostAdd />', () => {
|
describe('<HostAdd />', () => {
|
||||||
test('handleSubmit should post to api', () => {
|
test('handleSubmit should post to api', async () => {
|
||||||
const wrapper = mountWithContexts(<HostAdd />);
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostAdd />);
|
||||||
|
});
|
||||||
const updatedHostData = {
|
const updatedHostData = {
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
description: 'new description',
|
description: 'new description',
|
||||||
@@ -19,21 +23,27 @@ describe('<HostAdd />', () => {
|
|||||||
expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData);
|
expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to hosts list when cancel is clicked', () => {
|
test('should navigate to hosts list when cancel is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
const wrapper = mountWithContexts(<HostAdd />, {
|
let wrapper;
|
||||||
context: { router: { history } },
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
expect(history.location.pathname).toEqual('/hosts');
|
expect(history.location.pathname).toEqual('/hosts');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to hosts list when close (x) is clicked', () => {
|
test('should navigate to hosts list when close (x) is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
const wrapper = mountWithContexts(<HostAdd />, {
|
let wrapper;
|
||||||
context: { router: { history } },
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Close"]').prop('onClick')();
|
wrapper.find('button[aria-label="Close"]').invoke('onClick')();
|
||||||
expect(history.location.pathname).toEqual('/hosts');
|
expect(history.location.pathname).toEqual('/hosts');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,11 +61,14 @@ describe('<HostAdd />', () => {
|
|||||||
...hostData,
|
...hostData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const wrapper = mountWithContexts(<HostAdd />, {
|
let wrapper;
|
||||||
context: { router: { history } },
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||||
await wrapper.find('HostForm').prop('handleSubmit')(hostData);
|
await wrapper.find('HostForm').invoke('handleSubmit')(hostData);
|
||||||
expect(history.location.pathname).toEqual('/hosts/5');
|
expect(history.location.pathname).toEqual('/hosts/5');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,22 +27,24 @@ describe('<OrganizationAdd />', () => {
|
|||||||
|
|
||||||
test('should navigate to organizations list when cancel is clicked', async () => {
|
test('should navigate to organizations list when cancel is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const wrapper = mountWithContexts(<OrganizationAdd />, {
|
wrapper = mountWithContexts(<OrganizationAdd />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(history.location.pathname).toEqual('/organizations');
|
expect(history.location.pathname).toEqual('/organizations');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to organizations list when close (x) is clicked', async () => {
|
test('should navigate to organizations list when close (x) is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const wrapper = mountWithContexts(<OrganizationAdd />, {
|
wrapper = mountWithContexts(<OrganizationAdd />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Close"]').prop('onClick')();
|
wrapper.find('button[aria-label="Close"]').invoke('onClick')();
|
||||||
});
|
});
|
||||||
expect(history.location.pathname).toEqual('/organizations');
|
expect(history.location.pathname).toEqual('/organizations');
|
||||||
});
|
});
|
||||||
@@ -63,8 +65,9 @@ describe('<OrganizationAdd />', () => {
|
|||||||
...orgData,
|
...orgData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const wrapper = mountWithContexts(<OrganizationAdd />, {
|
wrapper = mountWithContexts(<OrganizationAdd />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||||
@@ -92,23 +95,27 @@ describe('<OrganizationAdd />', () => {
|
|||||||
...orgData,
|
...orgData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const wrapper = mountWithContexts(<OrganizationAdd />);
|
wrapper = mountWithContexts(<OrganizationAdd />);
|
||||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
|
||||||
await wrapper.find('OrganizationForm').prop('handleSubmit')(
|
|
||||||
orgData,
|
|
||||||
[3],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||||
|
await wrapper.find('OrganizationForm').prop('handleSubmit')(
|
||||||
|
orgData,
|
||||||
|
[3],
|
||||||
|
[]
|
||||||
|
);
|
||||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
|
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnsibleSelect component renders if there are virtual environments', async () => {
|
test('AnsibleSelect component renders if there are virtual environments', async () => {
|
||||||
|
const config = {
|
||||||
|
custom_virtualenvs: ['foo', 'bar'],
|
||||||
|
};
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<OrganizationAdd />, {
|
wrapper = mountWithContexts(<OrganizationAdd />, {
|
||||||
context: { config: { custom_virtualenvs: ['foo', 'bar'] } },
|
context: { config },
|
||||||
}).find('AnsibleSelect');
|
}).find('AnsibleSelect');
|
||||||
});
|
});
|
||||||
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
||||||
@@ -122,10 +129,13 @@ describe('<OrganizationAdd />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
|
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
|
||||||
|
const config = {
|
||||||
|
custom_virtualenvs: [],
|
||||||
|
};
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<OrganizationAdd />, {
|
wrapper = mountWithContexts(<OrganizationAdd />, {
|
||||||
context: { config: { custom_virtualenvs: [] } },
|
context: { config },
|
||||||
}).find('AnsibleSelect');
|
}).find('AnsibleSelect');
|
||||||
});
|
});
|
||||||
expect(wrapper.find('FormSelect')).toHaveLength(0);
|
expect(wrapper.find('FormSelect')).toHaveLength(0);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { OrganizationsAPI } from '@api';
|
import { OrganizationsAPI } from '@api';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
@@ -6,8 +7,6 @@ import OrganizationEdit from './OrganizationEdit';
|
|||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
describe('<OrganizationEdit />', () => {
|
describe('<OrganizationEdit />', () => {
|
||||||
const mockData = {
|
const mockData = {
|
||||||
name: 'Foo',
|
name: 'Foo',
|
||||||
@@ -19,10 +18,11 @@ describe('<OrganizationEdit />', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
test('handleSubmit should call api update', () => {
|
test('handleSubmit should call api update', async () => {
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationEdit organization={mockData} />
|
await act(async () => {
|
||||||
);
|
wrapper = mountWithContexts(<OrganizationEdit organization={mockData} />);
|
||||||
|
});
|
||||||
|
|
||||||
const updatedOrgData = {
|
const updatedOrgData = {
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
@@ -39,21 +39,23 @@ describe('<OrganizationEdit />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('handleSubmit associates and disassociates instance groups', async () => {
|
test('handleSubmit associates and disassociates instance groups', async () => {
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationEdit organization={mockData} />
|
await act(async () => {
|
||||||
);
|
wrapper = mountWithContexts(<OrganizationEdit organization={mockData} />);
|
||||||
|
});
|
||||||
|
|
||||||
const updatedOrgData = {
|
const updatedOrgData = {
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
description: 'new description',
|
description: 'new description',
|
||||||
custom_virtualenv: 'Buzz',
|
custom_virtualenv: 'Buzz',
|
||||||
};
|
};
|
||||||
wrapper.find('OrganizationForm').prop('handleSubmit')(
|
await act(async () => {
|
||||||
updatedOrgData,
|
wrapper.find('OrganizationForm').invoke('handleSubmit')(
|
||||||
[3, 4],
|
updatedOrgData,
|
||||||
[2]
|
[3, 4],
|
||||||
);
|
[2]
|
||||||
await sleep(1);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
|
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
|
||||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
|
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
|
||||||
@@ -63,14 +65,17 @@ describe('<OrganizationEdit />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to organization detail when cancel is clicked', () => {
|
test('should navigate to organization detail when cancel is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationEdit organization={mockData} />,
|
await act(async () => {
|
||||||
{ context: { router: { history } } }
|
wrapper = mountWithContexts(
|
||||||
);
|
<OrganizationEdit organization={mockData} />,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
|
||||||
expect(history.location.pathname).toEqual('/organizations/1/details');
|
expect(history.location.pathname).toEqual('/organizations/1/details');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
import { sleep } from '@testUtils/testUtils';
|
import { sleep } from '@testUtils/testUtils';
|
||||||
import { OrganizationsAPI } from '@api';
|
import { OrganizationsAPI } from '@api';
|
||||||
@@ -30,18 +30,20 @@ describe('<OrganizationForm />', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should request related instance groups from api', () => {
|
test('should request related instance groups from api', async () => {
|
||||||
mountWithContexts(
|
await act(async () => {
|
||||||
<OrganizationForm
|
mountWithContexts(
|
||||||
organization={mockData}
|
<OrganizationForm
|
||||||
handleSubmit={jest.fn()}
|
organization={mockData}
|
||||||
handleCancel={jest.fn()}
|
handleSubmit={jest.fn()}
|
||||||
me={meConfig.me}
|
handleCancel={jest.fn()}
|
||||||
/>,
|
me={meConfig.me}
|
||||||
{
|
/>,
|
||||||
context: { network },
|
{
|
||||||
}
|
context: { network },
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
|
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -53,34 +55,39 @@ describe('<OrganizationForm />', () => {
|
|||||||
results: mockInstanceGroups,
|
results: mockInstanceGroups,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={jest.fn()}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={jest.fn()}
|
||||||
/>,
|
handleCancel={jest.fn()}
|
||||||
{
|
me={meConfig.me}
|
||||||
context: { network },
|
/>,
|
||||||
}
|
{
|
||||||
);
|
context: { network },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await sleep(0);
|
|
||||||
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled();
|
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalled();
|
||||||
expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual(
|
expect(wrapper.find('OrganizationForm').state().instanceGroups).toEqual(
|
||||||
mockInstanceGroups
|
mockInstanceGroups
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changing instance group successfully sets instanceGroups state', () => {
|
test('changing instance group successfully sets instanceGroups state', async () => {
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={jest.fn()}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={jest.fn()}
|
||||||
/>
|
handleCancel={jest.fn()}
|
||||||
);
|
me={meConfig.me}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const lookup = wrapper.find('InstanceGroupsLookup');
|
const lookup = wrapper.find('InstanceGroupsLookup');
|
||||||
expect(lookup.length).toBe(1);
|
expect(lookup.length).toBe(1);
|
||||||
@@ -102,15 +109,18 @@ describe('<OrganizationForm />', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changing inputs should update form values', () => {
|
test('changing inputs should update form values', async () => {
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={jest.fn()}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={jest.fn()}
|
||||||
/>
|
handleCancel={jest.fn()}
|
||||||
);
|
me={meConfig.me}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const form = wrapper.find('Formik');
|
const form = wrapper.find('Formik');
|
||||||
wrapper.find('input#org-name').simulate('change', {
|
wrapper.find('input#org-name').simulate('change', {
|
||||||
@@ -127,21 +137,24 @@ describe('<OrganizationForm />', () => {
|
|||||||
expect(form.state('values').max_hosts).toEqual('134');
|
expect(form.state('values').max_hosts).toEqual('134');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnsibleSelect component renders if there are virtual environments', () => {
|
test('AnsibleSelect component renders if there are virtual environments', async () => {
|
||||||
const config = {
|
const config = {
|
||||||
custom_virtualenvs: ['foo', 'bar'],
|
custom_virtualenvs: ['foo', 'bar'],
|
||||||
};
|
};
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={jest.fn()}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={jest.fn()}
|
||||||
/>,
|
handleCancel={jest.fn()}
|
||||||
{
|
me={meConfig.me}
|
||||||
context: { config },
|
/>,
|
||||||
}
|
{
|
||||||
);
|
context: { config },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
expect(wrapper.find('FormSelect')).toHaveLength(1);
|
||||||
expect(wrapper.find('FormSelectOption')).toHaveLength(3);
|
expect(wrapper.find('FormSelectOption')).toHaveLength(3);
|
||||||
expect(
|
expect(
|
||||||
@@ -154,14 +167,17 @@ describe('<OrganizationForm />', () => {
|
|||||||
|
|
||||||
test('calls handleSubmit when form submitted', async () => {
|
test('calls handleSubmit when form submitted', async () => {
|
||||||
const handleSubmit = jest.fn();
|
const handleSubmit = jest.fn();
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={handleSubmit}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={handleSubmit}
|
||||||
/>
|
handleCancel={jest.fn()}
|
||||||
);
|
me={meConfig.me}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(handleSubmit).not.toHaveBeenCalled();
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||||
await sleep(1);
|
await sleep(1);
|
||||||
@@ -194,18 +210,20 @@ describe('<OrganizationForm />', () => {
|
|||||||
OrganizationsAPI.update.mockResolvedValue(1, mockDataForm);
|
OrganizationsAPI.update.mockResolvedValue(1, mockDataForm);
|
||||||
OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done');
|
OrganizationsAPI.associateInstanceGroup.mockResolvedValue('done');
|
||||||
OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done');
|
OrganizationsAPI.disassociateInstanceGroup.mockResolvedValue('done');
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={handleSubmit}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={handleSubmit}
|
||||||
/>,
|
handleCancel={jest.fn()}
|
||||||
{
|
me={meConfig.me}
|
||||||
context: { network },
|
/>,
|
||||||
}
|
{
|
||||||
);
|
context: { network },
|
||||||
await sleep(0);
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
wrapper.find('InstanceGroupsLookup').prop('onChange')(
|
wrapper.find('InstanceGroupsLookup').prop('onChange')(
|
||||||
[{ name: 'One', id: 1 }, { name: 'Three', id: 3 }],
|
[{ name: 'One', id: 1 }, { name: 'Three', id: 3 }],
|
||||||
'instanceGroups'
|
'instanceGroups'
|
||||||
@@ -219,15 +237,17 @@ describe('<OrganizationForm />', () => {
|
|||||||
test('handleSubmit is called with max_hosts value if it is in range', async () => {
|
test('handleSubmit is called with max_hosts value if it is in range', async () => {
|
||||||
const handleSubmit = jest.fn();
|
const handleSubmit = jest.fn();
|
||||||
|
|
||||||
// normal mount
|
let wrapper;
|
||||||
const wrapper = mountWithContexts(
|
await act(async () => {
|
||||||
<OrganizationForm
|
wrapper = mountWithContexts(
|
||||||
organization={mockData}
|
<OrganizationForm
|
||||||
handleSubmit={handleSubmit}
|
organization={mockData}
|
||||||
handleCancel={jest.fn()}
|
handleSubmit={handleSubmit}
|
||||||
me={meConfig.me}
|
handleCancel={jest.fn()}
|
||||||
/>
|
me={meConfig.me}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(handleSubmit).toHaveBeenCalledWith(
|
expect(handleSubmit).toHaveBeenCalledWith(
|
||||||
@@ -245,32 +265,38 @@ describe('<OrganizationForm />', () => {
|
|||||||
test('handleSubmit does not get called if max_hosts value is out of range', async () => {
|
test('handleSubmit does not get called if max_hosts value is out of range', async () => {
|
||||||
const handleSubmit = jest.fn();
|
const handleSubmit = jest.fn();
|
||||||
|
|
||||||
// not mount with Negative value
|
// mount with negative value
|
||||||
|
let wrapper1;
|
||||||
const mockDataNegative = JSON.parse(JSON.stringify(mockData));
|
const mockDataNegative = JSON.parse(JSON.stringify(mockData));
|
||||||
mockDataNegative.max_hosts = -5;
|
mockDataNegative.max_hosts = -5;
|
||||||
const wrapper1 = mountWithContexts(
|
await act(async () => {
|
||||||
<OrganizationForm
|
wrapper1 = mountWithContexts(
|
||||||
organization={mockDataNegative}
|
<OrganizationForm
|
||||||
handleSubmit={handleSubmit}
|
organization={mockDataNegative}
|
||||||
handleCancel={jest.fn()}
|
handleSubmit={handleSubmit}
|
||||||
me={meConfig.me}
|
handleCancel={jest.fn()}
|
||||||
/>
|
me={meConfig.me}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
wrapper1.find('button[aria-label="Save"]').simulate('click');
|
wrapper1.find('button[aria-label="Save"]').simulate('click');
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(handleSubmit).not.toHaveBeenCalled();
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// not mount with Out of Range value
|
// mount with out of range value
|
||||||
|
let wrapper2;
|
||||||
const mockDataOoR = JSON.parse(JSON.stringify(mockData));
|
const mockDataOoR = JSON.parse(JSON.stringify(mockData));
|
||||||
mockDataOoR.max_hosts = 999999999999;
|
mockDataOoR.max_hosts = 999999999999;
|
||||||
const wrapper2 = mountWithContexts(
|
await act(async () => {
|
||||||
<OrganizationForm
|
wrapper2 = mountWithContexts(
|
||||||
organization={mockDataOoR}
|
<OrganizationForm
|
||||||
handleSubmit={handleSubmit}
|
organization={mockDataOoR}
|
||||||
handleCancel={jest.fn()}
|
handleSubmit={handleSubmit}
|
||||||
me={meConfig.me}
|
handleCancel={jest.fn()}
|
||||||
/>
|
me={meConfig.me}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
wrapper2.find('button[aria-label="Save"]').simulate('click');
|
wrapper2.find('button[aria-label="Save"]').simulate('click');
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(handleSubmit).not.toHaveBeenCalled();
|
expect(handleSubmit).not.toHaveBeenCalled();
|
||||||
@@ -282,14 +308,17 @@ describe('<OrganizationForm />', () => {
|
|||||||
// mount with String value (default to zero)
|
// mount with String value (default to zero)
|
||||||
const mockDataString = JSON.parse(JSON.stringify(mockData));
|
const mockDataString = JSON.parse(JSON.stringify(mockData));
|
||||||
mockDataString.max_hosts = 'Bee';
|
mockDataString.max_hosts = 'Bee';
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockDataString}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={handleSubmit}
|
<OrganizationForm
|
||||||
handleCancel={jest.fn()}
|
organization={mockDataString}
|
||||||
me={meConfig.me}
|
handleSubmit={handleSubmit}
|
||||||
/>
|
handleCancel={jest.fn()}
|
||||||
);
|
me={meConfig.me}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(handleSubmit).toHaveBeenCalledWith(
|
expect(handleSubmit).toHaveBeenCalledWith(
|
||||||
@@ -304,17 +333,20 @@ describe('<OrganizationForm />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls "handleCancel" when Cancel button is clicked', () => {
|
test('calls "handleCancel" when Cancel button is clicked', async () => {
|
||||||
const handleCancel = jest.fn();
|
const handleCancel = jest.fn();
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<OrganizationForm
|
await act(async () => {
|
||||||
organization={mockData}
|
wrapper = mountWithContexts(
|
||||||
handleSubmit={jest.fn()}
|
<OrganizationForm
|
||||||
handleCancel={handleCancel}
|
organization={mockData}
|
||||||
me={meConfig.me}
|
handleSubmit={jest.fn()}
|
||||||
/>
|
handleCancel={handleCancel}
|
||||||
);
|
me={meConfig.me}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(handleCancel).not.toHaveBeenCalled();
|
expect(handleCancel).not.toHaveBeenCalled();
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||||
expect(handleCancel).toBeCalled();
|
expect(handleCancel).toBeCalled();
|
||||||
|
|||||||
@@ -98,17 +98,19 @@ describe('<ProjectAdd />', () => {
|
|||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
const formik = wrapper.find('Formik').instance();
|
const formik = wrapper.find('Formik').instance();
|
||||||
const changeState = new Promise(resolve => {
|
await act(async () => {
|
||||||
formik.setState(
|
const changeState = new Promise(resolve => {
|
||||||
{
|
formik.setState(
|
||||||
values: {
|
{
|
||||||
...projectData,
|
values: {
|
||||||
|
...projectData,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
() => resolve()
|
||||||
() => resolve()
|
);
|
||||||
);
|
});
|
||||||
|
await changeState;
|
||||||
});
|
});
|
||||||
await changeState;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('form').simulate('submit');
|
wrapper.find('form').simulate('submit');
|
||||||
});
|
});
|
||||||
@@ -146,7 +148,9 @@ describe('<ProjectAdd />', () => {
|
|||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
}).find('ProjectAdd CardHeader');
|
}).find('ProjectAdd CardHeader');
|
||||||
});
|
});
|
||||||
wrapper.find('CardCloseButton').simulate('click');
|
await act(async () => {
|
||||||
|
wrapper.find('CardCloseButton').simulate('click');
|
||||||
|
});
|
||||||
expect(history.location.pathname).toEqual('/projects');
|
expect(history.location.pathname).toEqual('/projects');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +162,9 @@ describe('<ProjectAdd />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||||
wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click');
|
await act(async () => {
|
||||||
|
wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click');
|
||||||
|
});
|
||||||
expect(history.location.pathname).toEqual('/projects');
|
expect(history.location.pathname).toEqual('/projects');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ describe('<ProjectEdit />', () => {
|
|||||||
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
|
wrapper = mountWithContexts(<ProjectEdit project={projectData} />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
|
wrapper.find('CardCloseButton').simulate('click');
|
||||||
});
|
});
|
||||||
wrapper.find('CardCloseButton').simulate('click');
|
|
||||||
expect(history.location.pathname).toEqual('/projects/123/details');
|
expect(history.location.pathname).toEqual('/projects/123/details');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +157,9 @@ describe('<ProjectEdit />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||||
wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click');
|
await act(async () => {
|
||||||
|
wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click');
|
||||||
|
});
|
||||||
expect(history.location.pathname).toEqual('/projects/123/details');
|
expect(history.location.pathname).toEqual('/projects/123/details');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,17 +131,19 @@ describe('<ProjectForm />', () => {
|
|||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
const formik = wrapper.find('Formik').instance();
|
const formik = wrapper.find('Formik').instance();
|
||||||
const changeState = new Promise(resolve => {
|
await act(async () => {
|
||||||
formik.setState(
|
const changeState = new Promise(resolve => {
|
||||||
{
|
formik.setState(
|
||||||
values: {
|
{
|
||||||
...mockData,
|
values: {
|
||||||
|
...mockData,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
() => resolve()
|
||||||
() => resolve()
|
);
|
||||||
);
|
});
|
||||||
|
await changeState;
|
||||||
});
|
});
|
||||||
await changeState;
|
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1);
|
expect(wrapper.find('FormGroup[label="SCM URL"]').length).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
@@ -191,18 +193,20 @@ describe('<ProjectForm />', () => {
|
|||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
const formik = wrapper.find('Formik').instance();
|
const formik = wrapper.find('Formik').instance();
|
||||||
const changeState = new Promise(resolve => {
|
await act(async () => {
|
||||||
formik.setState(
|
const changeState = new Promise(resolve => {
|
||||||
{
|
formik.setState(
|
||||||
values: {
|
{
|
||||||
...mockData,
|
values: {
|
||||||
scm_type: 'insights',
|
...mockData,
|
||||||
|
scm_type: 'insights',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
() => resolve()
|
||||||
() => resolve()
|
);
|
||||||
);
|
});
|
||||||
|
await changeState;
|
||||||
});
|
});
|
||||||
await changeState;
|
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe(
|
expect(wrapper.find('FormGroup[label="Insights Credential"]').length).toBe(
|
||||||
1
|
1
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const ScmCredentialFormField = withI18n()(
|
|||||||
value={credential.value}
|
value={credential.value}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
onCredentialSelection('scm', value);
|
onCredentialSelection('scm', value);
|
||||||
form.setFieldValue('credential', value.id);
|
form.setFieldValue('credential', value ? value.id : '');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import TeamAdd from './TeamAdd';
|
import TeamAdd from './TeamAdd';
|
||||||
@@ -7,32 +8,38 @@ import { TeamsAPI } from '@api';
|
|||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<TeamAdd />', () => {
|
describe('<TeamAdd />', () => {
|
||||||
test('handleSubmit should post to api', () => {
|
test('handleSubmit should post to api', async () => {
|
||||||
const wrapper = mountWithContexts(<TeamAdd />);
|
const wrapper = mountWithContexts(<TeamAdd />);
|
||||||
const updatedTeamData = {
|
const updatedTeamData = {
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
description: 'new description',
|
description: 'new description',
|
||||||
organization: 1,
|
organization: 1,
|
||||||
};
|
};
|
||||||
wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData);
|
await act(async () => {
|
||||||
|
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData);
|
||||||
|
});
|
||||||
expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData);
|
expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to teams list when cancel is clicked', () => {
|
test('should navigate to teams list when cancel is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
const wrapper = mountWithContexts(<TeamAdd />, {
|
const wrapper = mountWithContexts(<TeamAdd />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
expect(history.location.pathname).toEqual('/teams');
|
expect(history.location.pathname).toEqual('/teams');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to teams list when close (x) is clicked', () => {
|
test('should navigate to teams list when close (x) is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
const wrapper = mountWithContexts(<TeamAdd />, {
|
const wrapper = mountWithContexts(<TeamAdd />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
wrapper.find('button[aria-label="Close"]').prop('onClick')();
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Close"]').invoke('onClick')();
|
||||||
|
});
|
||||||
expect(history.location.pathname).toEqual('/teams');
|
expect(history.location.pathname).toEqual('/teams');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,11 +62,16 @@ describe('<TeamAdd />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const wrapper = mountWithContexts(<TeamAdd />, {
|
let wrapper;
|
||||||
context: { router: { history } },
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<TeamAdd />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||||
await wrapper.find('TeamForm').prop('handleSubmit')(teamData);
|
await act(async () => {
|
||||||
|
await wrapper.find('TeamForm').invoke('handleSubmit')(teamData);
|
||||||
|
});
|
||||||
expect(history.location.pathname).toEqual('/teams/5');
|
expect(history.location.pathname).toEqual('/teams/5');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { TeamsAPI } from '@api';
|
import { TeamsAPI } from '@api';
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
@@ -19,25 +20,29 @@ describe('<TeamEdit />', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
test('handleSubmit should call api update', () => {
|
test('handleSubmit should call api update', async () => {
|
||||||
const wrapper = mountWithContexts(<TeamEdit team={mockData} />);
|
const wrapper = mountWithContexts(<TeamEdit team={mockData} />);
|
||||||
|
|
||||||
const updatedTeamData = {
|
const updatedTeamData = {
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
description: 'new description',
|
description: 'new description',
|
||||||
};
|
};
|
||||||
wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData);
|
await act(async () => {
|
||||||
|
wrapper.find('TeamForm').invoke('handleSubmit')(updatedTeamData);
|
||||||
|
});
|
||||||
|
|
||||||
expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData);
|
expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate to team detail when cancel is clicked', () => {
|
test('should navigate to team detail when cancel is clicked', async () => {
|
||||||
const history = createMemoryHistory({});
|
const history = createMemoryHistory({});
|
||||||
const wrapper = mountWithContexts(<TeamEdit team={mockData} />, {
|
const wrapper = mountWithContexts(<TeamEdit team={mockData} />, {
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
|
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
|
|
||||||
expect(history.location.pathname).toEqual('/teams/1/details');
|
expect(history.location.pathname).toEqual('/teams/1/details');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,15 +30,17 @@ describe('<TeamForm />', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changing inputs should update form values', () => {
|
test('changing inputs should update form values', async () => {
|
||||||
wrapper = mountWithContexts(
|
await act(async () => {
|
||||||
<TeamForm
|
wrapper = mountWithContexts(
|
||||||
team={mockData}
|
<TeamForm
|
||||||
handleSubmit={jest.fn()}
|
team={mockData}
|
||||||
handleCancel={jest.fn()}
|
handleSubmit={jest.fn()}
|
||||||
me={meConfig.me}
|
handleCancel={jest.fn()}
|
||||||
/>
|
me={meConfig.me}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const form = wrapper.find('Formik');
|
const form = wrapper.find('Formik');
|
||||||
wrapper.find('input#team-name').simulate('change', {
|
wrapper.find('input#team-name').simulate('change', {
|
||||||
@@ -78,17 +80,19 @@ describe('<TeamForm />', () => {
|
|||||||
expect(handleSubmit).toBeCalled();
|
expect(handleSubmit).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls handleCancel when Cancel button is clicked', () => {
|
test('calls handleCancel when Cancel button is clicked', async () => {
|
||||||
const handleCancel = jest.fn();
|
const handleCancel = jest.fn();
|
||||||
|
|
||||||
wrapper = mountWithContexts(
|
await act(async () => {
|
||||||
<TeamForm
|
wrapper = mountWithContexts(
|
||||||
team={mockData}
|
<TeamForm
|
||||||
handleSubmit={jest.fn()}
|
team={mockData}
|
||||||
handleCancel={handleCancel}
|
handleSubmit={jest.fn()}
|
||||||
me={meConfig.me}
|
handleCancel={handleCancel}
|
||||||
/>
|
me={meConfig.me}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
expect(handleCancel).not.toHaveBeenCalled();
|
expect(handleCancel).not.toHaveBeenCalled();
|
||||||
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
|
||||||
expect(handleCancel).toBeCalled();
|
expect(handleCancel).toBeCalled();
|
||||||
|
|||||||
@@ -101,19 +101,21 @@ describe('<JobTemplateAdd />', () => {
|
|||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||||
const formik = wrapper.find('Formik').instance();
|
const formik = wrapper.find('Formik').instance();
|
||||||
const changeState = new Promise(resolve => {
|
await act(async () => {
|
||||||
formik.setState(
|
const changeState = new Promise(resolve => {
|
||||||
{
|
formik.setState(
|
||||||
values: {
|
{
|
||||||
...jobTemplateData,
|
values: {
|
||||||
labels: [],
|
...jobTemplateData,
|
||||||
instanceGroups: [],
|
labels: [],
|
||||||
|
instanceGroups: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
() => resolve()
|
||||||
() => resolve()
|
);
|
||||||
);
|
});
|
||||||
|
await changeState;
|
||||||
});
|
});
|
||||||
await changeState;
|
|
||||||
wrapper.find('form').simulate('submit');
|
wrapper.find('form').simulate('submit');
|
||||||
await sleep(1);
|
await sleep(1);
|
||||||
expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData);
|
expect(JobTemplatesAPI.create).toHaveBeenCalledWith(jobTemplateData);
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class JobTemplateForm extends Component {
|
|||||||
};
|
};
|
||||||
this.handleProjectValidation = this.handleProjectValidation.bind(this);
|
this.handleProjectValidation = this.handleProjectValidation.bind(this);
|
||||||
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
|
this.loadRelatedInstanceGroups = this.loadRelatedInstanceGroups.bind(this);
|
||||||
|
this.handleProjectUpdate = this.handleProjectUpdate.bind(this);
|
||||||
|
this.setContentError = this.setContentError.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -119,6 +121,16 @@ class JobTemplateForm extends Component {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleProjectUpdate(project) {
|
||||||
|
const { setFieldValue } = this.props;
|
||||||
|
setFieldValue('project', project.id);
|
||||||
|
this.setState({ project });
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentError(contentError) {
|
||||||
|
this.setState({ contentError });
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
contentError,
|
contentError,
|
||||||
@@ -252,10 +264,7 @@ class JobTemplateForm extends Component {
|
|||||||
you want this job to execute.`)}
|
you want this job to execute.`)}
|
||||||
isValid={!form.touched.project || !form.errors.project}
|
isValid={!form.touched.project || !form.errors.project}
|
||||||
helperTextInvalid={form.errors.project}
|
helperTextInvalid={form.errors.project}
|
||||||
onChange={value => {
|
onChange={this.handleProjectUpdate}
|
||||||
form.setFieldValue('project', value.id);
|
|
||||||
this.setState({ project: value });
|
|
||||||
}}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -285,7 +294,7 @@ class JobTemplateForm extends Component {
|
|||||||
form={form}
|
form={form}
|
||||||
field={field}
|
field={field}
|
||||||
onBlur={() => form.setFieldTouched('playbook')}
|
onBlur={() => form.setFieldTouched('playbook')}
|
||||||
onError={err => this.setState({ contentError: err })}
|
onError={this.setContentError}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
@@ -305,7 +314,7 @@ class JobTemplateForm extends Component {
|
|||||||
<LabelSelect
|
<LabelSelect
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={labels => setFieldValue('labels', labels)}
|
onChange={labels => setFieldValue('labels', labels)}
|
||||||
onError={err => this.setState({ contentError: err })}
|
onError={this.setContentError}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
@@ -317,11 +326,11 @@ class JobTemplateForm extends Component {
|
|||||||
fieldId="template-credentials"
|
fieldId="template-credentials"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<MultiCredentialsLookup
|
<MultiCredentialsLookup
|
||||||
credentials={field.value}
|
value={field.value}
|
||||||
onChange={newCredentials =>
|
onChange={newCredentials =>
|
||||||
setFieldValue('credentials', newCredentials)
|
setFieldValue('credentials', newCredentials)
|
||||||
}
|
}
|
||||||
onError={err => this.setState({ contentError: err })}
|
onError={this.setContentError}
|
||||||
tooltip={i18n._(
|
tooltip={i18n._(
|
||||||
t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
|
t`Select credentials that allow Tower to access the nodes this job will be ran against. You can only select one credential of each type. For machine credentials (SSH), checking "Prompt on launch" without selecting credentials will require you to select a machine credential at run time. If you select credentials and check "Prompt on launch", the selected credential(s) become the defaults that can be updated at run time.`
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ describe('UsersList with full permissions', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('api is called to delete users for each selected user.', () => {
|
test('api is called to delete users for each selected user.', async () => {
|
||||||
UsersAPI.destroy = jest.fn();
|
UsersAPI.destroy = jest.fn();
|
||||||
wrapper.find('UsersList').setState({
|
wrapper.find('UsersList').setState({
|
||||||
users: mockUsers,
|
users: mockUsers,
|
||||||
@@ -223,7 +223,7 @@ describe('UsersList with full permissions', () => {
|
|||||||
isModalOpen: true,
|
isModalOpen: true,
|
||||||
selected: mockUsers,
|
selected: mockUsers,
|
||||||
});
|
});
|
||||||
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
|
await wrapper.find('ToolbarDeleteButton').prop('onDelete')();
|
||||||
expect(UsersAPI.destroy).toHaveBeenCalledTimes(2);
|
expect(UsersAPI.destroy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user