1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 15:21:13 +03:00

fix credential chips in SelectedList, MultiCredential cleanup

This commit is contained in:
Keith Grant 2019-11-25 14:22:34 -08:00
parent 4341d67fb0
commit 639b297027
7 changed files with 69 additions and 428 deletions

View File

@ -36,12 +36,12 @@ class AnsibleSelect extends React.Component {
aria-label={i18n._(t`Select Input`)}
isValid={isValid}
>
{data.map(datum => (
{data.map(option => (
<FormSelectOption
key={datum.key}
value={datum.value}
label={datum.label}
isDisabled={datum.isDisabled}
key={option.id}
value={option.value}
label={option.label}
isDisabled={option.isDisabled}
/>
))}
</FormSelect>
@ -49,6 +49,13 @@ class AnsibleSelect extends React.Component {
}
}
const Option = shape({
id: oneOfType([string, number]).isRequired,
value: oneOfType([string, number]).isRequired,
label: string.isRequired,
isDisabled: bool,
});
AnsibleSelect.defaultProps = {
data: [],
isValid: true,
@ -56,7 +63,7 @@ AnsibleSelect.defaultProps = {
};
AnsibleSelect.propTypes = {
data: arrayOf(shape()),
data: arrayOf(Option),
id: string.isRequired,
isValid: bool,
onBlur: func,

View File

@ -1,331 +0,0 @@
import React, { Fragment } from 'react';
import {
string,
bool,
arrayOf,
func,
number,
oneOfType,
shape,
} from 'prop-types';
import { withRouter } from 'react-router-dom';
import { SearchIcon } from '@patternfly/react-icons';
import {
Button,
ButtonVariant,
InputGroup as PFInputGroup,
Modal,
ToolbarItem,
} from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import AnsibleSelect from '../AnsibleSelect';
import PaginatedDataList from '../PaginatedDataList';
import VerticalSeperator from '../VerticalSeparator';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { ChipGroup, CredentialChip } from '../Chip';
import { QSConfig } from '@types';
const SearchButton = styled(Button)`
::after {
border: var(--pf-c-button--BorderWidth) solid
var(--pf-global--BorderColor--200);
}
`;
const InputGroup = styled(PFInputGroup)`
${props =>
props.multiple &&
`
--pf-c-form-control--Height: 90px;
overflow-y: auto;
`}
`;
const ChipHolder = styled.div`
--pf-c-form-control--BorderTopColor: var(--pf-global--BorderColor--200);
--pf-c-form-control--BorderRightColor: var(--pf-global--BorderColor--200);
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
`;
class CategoryLookup extends React.Component {
constructor(props) {
super(props);
// this.assertCorrectValueType();
let selectedItems = [];
if (props.value) {
selectedItems = props.multiple ? [...props.value] : [props.value];
}
this.state = {
isModalOpen: false,
selectedItems,
error: null,
};
this.handleModalToggle = this.handleModalToggle.bind(this);
this.addItem = this.addItem.bind(this);
this.removeItem = this.removeItem.bind(this);
this.saveModal = this.saveModal.bind(this);
this.clearQSParams = this.clearQSParams.bind(this);
}
// assertCorrectValueType() {
// const { multiple, value, selectCategoryOptions } = this.props;
// if (selectCategoryOptions) {
// return;
// }
// if (!multiple && Array.isArray(value)) {
// throw new Error(
// 'CategoryLookup value must not be an array unless `multiple` is set'
// );
// }
// if (multiple && !Array.isArray(value)) {
// throw new Error(
// 'CategoryLookup value must be an array if `multiple` is set'
// );
// }
// }
removeItem(row) {
const { selectedItems } = this.state;
const { onToggleItem } = this.props;
if (onToggleItem) {
this.setState({ selectedItems: onToggleItem(selectedItems, row) });
return;
}
this.setState({
selectedItems: selectedItems.filter(item => item.id !== row.id),
});
}
addItem(row) {
const { selectedItems } = this.state;
const { multiple, onToggleItem } = this.props;
if (onToggleItem) {
this.setState({ selectedItems: onToggleItem(selectedItems, row) });
return;
}
const index = selectedItems.findIndex(item => item.id === row.id);
if (!multiple) {
this.setState({ selectedItems: [row] });
return;
}
if (index > -1) {
return;
}
this.setState({ selectedItems: [...selectedItems, row] });
}
// TODO: clean up
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 selectedItems = [];
if (value) {
selectedItems = multiple ? [...value] : [value];
}
this.setState({ selectedItems });
} else {
this.clearQSParams();
if (selectCategory) {
selectCategory(null, 'Machine');
}
}
this.setState(prevState => ({
isModalOpen: !prevState.isModalOpen,
}));
}
removeItemAndSave(row) {
const { value, onChange, multiple } = this.props;
if (multiple) {
onChange(value.filter(item => item.id !== row.id));
} else if (value.id === row.id) {
onChange(null);
}
}
saveModal() {
const { onChange, multiple } = this.props;
const { selectedItems } = this.state;
const value = multiple ? selectedItems : selectedItems[0] || null;
this.handleModalToggle();
onChange(value);
}
clearQSParams() {
const { qsConfig, history } = this.props;
const parts = history.location.search.replace(/^\?/, '').split('&');
const ns = qsConfig.namespace;
const otherParts = parts.filter(param => !param.startsWith(`${ns}.`));
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
}
render() {
const { isModalOpen, selectedItems, error } = this.state;
const {
id,
items,
count,
lookupHeader,
value,
columns,
multiple,
name,
onBlur,
qsConfig,
required,
selectCategory,
selectCategoryOptions,
selectedCategory,
i18n,
} = this.props;
const header = lookupHeader || i18n._(t`Items`);
const canDelete = !required || (multiple && value.length > 1);
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">
<ChipGroup>
{(multiple ? value : [value]).map(chip => (
<CredentialChip
key={chip.id}
onClick={() => this.removeItemAndSave(chip)}
isReadOnly={!canDelete}
credential={chip}
/>
))}
</ChipGroup>
</ChipHolder>
</InputGroup>
<Modal
className="awx-c-modal"
title={i18n._(t`Select ${header}`)}
isOpen={isModalOpen}
onClose={this.handleModalToggle}
actions={[
<Button
key="select"
variant="primary"
onClick={this.saveModal}
style={selectedItems.length === 0 ? { display: 'none' } : {}}
>
{i18n._(t`Select`)}
</Button>,
<Button
key="cancel"
variant="secondary"
onClick={this.handleModalToggle}
>
{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}
/>
</ToolbarItem>
)}
{selectedItems.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={selectedItems}
showOverflowAfter={5}
onRemove={this.removeItem}
isReadOnly={!canDelete}
isCredentialList={
selectCategoryOptions && selectCategoryOptions.length > 0
}
/>
)}
<PaginatedDataList
items={items}
itemCount={count}
pluralizedItemName={lookupHeader}
qsConfig={qsConfig}
toolbarColumns={columns}
renderItem={item => (
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
isSelected={selectedItems.some(i => i.id === item.id)}
onSelect={() => this.addItem(item)}
isRadio={
!multiple ||
(selectCategoryOptions &&
selectCategoryOptions.length &&
selectedCategory.value !== 'Vault')
}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
{error ? <div>error: {error.message}</div> : ''}
</Modal>
</Fragment>
);
}
}
const Item = shape({
id: number.isRequired,
});
CategoryLookup.propTypes = {
id: string,
items: arrayOf(shape({})).isRequired,
// TODO: change to `header`
lookupHeader: string,
name: string,
onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]),
multiple: bool,
required: bool,
qsConfig: QSConfig.isRequired,
selectCategory: func.isRequired,
selectCategoryOptions: oneOfType(shape({})).isRequired,
selectedCategory: shape({}).isRequired,
};
CategoryLookup.defaultProps = {
id: 'lookup-search',
lookupHeader: null,
name: null,
value: null,
multiple: false,
required: false,
};
export { CategoryLookup as _CategoryLookup };
export default withI18n()(withRouter(CategoryLookup));

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { Fragment, useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
@ -12,7 +12,6 @@ import VerticalSeperator from '@components/VerticalSeparator';
import { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './NewLookup';
import SelectList from './shared/SelectList';
import multiCredentialReducer from './shared/multiCredentialReducer';
const QS_CONFIG = getQSConfig('credentials', {
page: 1,
@ -20,50 +19,10 @@ const QS_CONFIG = getQSConfig('credentials', {
order_by: 'name',
});
// TODO: move into reducer
function toggleCredentialSelection(credentialsToUpdate, newCredential) {
let newCredentialsList;
const isSelectedCredentialInState =
credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > 0;
if (isSelectedCredentialInState) {
newCredentialsList = credentialsToUpdate.filter(
cred => cred.id !== newCredential.id
);
} else {
newCredentialsList = credentialsToUpdate.filter(
credential =>
credential.kind === 'vault' || credential.kind !== newCredential.kind
);
newCredentialsList = [...newCredentialsList, newCredential];
}
return newCredentialsList;
}
async function loadCredentialTypes() {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
const credentialTypes = [];
// TODO: cleanup
data.results.forEach(cred => {
acceptableTypes.forEach(aT => {
if (aT === cred.kind) {
// This object has several repeated values as some of it's children
// require different field values.
cred = {
id: cred.id,
key: cred.id,
kind: cred.kind,
type: cred.namespace,
value: cred.name,
label: cred.name,
isDisabled: false,
};
credentialTypes.push(cred);
}
});
});
return credentialTypes;
return data.results.filter(type => acceptableTypes.includes(type.kind));
}
async function loadCredentials(params, selectedCredentialTypeId) {
@ -78,13 +37,16 @@ function MultiCredentialsLookup(props) {
const [selectedType, setSelectedType] = useState(null);
const [credentials, setCredentials] = useState([]);
const [credentialsCount, setCredentialsCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
try {
const types = await loadCredentialTypes();
setCredentialTypes(types);
setSelectedType(types[0]);
setSelectedType(
types.find(type => type.name === 'Machine') || types[0]
);
} catch (err) {
onError(err);
}
@ -98,10 +60,12 @@ function MultiCredentialsLookup(props) {
}
try {
const params = parseQueryString(QS_CONFIG, history.location.search);
setIsLoading(true);
const { results, count } = await loadCredentials(
params,
selectedType.id
);
setIsLoading(false);
setCredentials(results);
setCredentialsCount(count);
} catch (err) {
@ -111,29 +75,29 @@ function MultiCredentialsLookup(props) {
}, [selectedType]);
const isMultiple = selectedType && selectedType.value === 'Vault';
const renderChip = ({ item, removeItem, canDelete }) => (
<CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
);
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
reducer={multiCredentialReducer}
onToggleItem={toggleCredentialSelection}
id="multiCredential"
lookupHeader={i18n._(t`Credentials`)}
value={value}
multiple
onChange={onChange}
qsConfig={QS_CONFIG}
renderItemChip={({ item, removeItem, canDelete }) => (
<CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/>
)}
renderItemChip={renderChip}
renderSelectList={({ state, dispatch, canDelete }) => {
return (
<>
<Fragment>
{credentialTypes && credentialTypes.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
@ -142,11 +106,16 @@ function MultiCredentialsLookup(props) {
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label={i18n._(t`Selected Category`)}
data={credentialTypes}
value={selectedType && selectedType.label}
onChange={(e, label) => {
data={credentialTypes.map(type => ({
id: type.id,
value: type.id,
label: type.name,
isDisabled: false,
}))}
value={selectedType && selectedType.id}
onChange={(e, id) => {
setSelectedType(
credentialTypes.find(o => o.label === label)
credentialTypes.find(o => o.id === parseInt(id, 10))
);
}}
/>
@ -183,8 +152,9 @@ function MultiCredentialsLookup(props) {
});
}}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
renderItemChip={renderChip}
/>
</>
</Fragment>
);
}}
/>

View File

@ -50,13 +50,9 @@ const ChipHolder = styled.div`
function Lookup(props) {
const {
id,
// items,
// count,
header,
// name,
onChange,
onBlur,
// columns,
value,
multiple,
required,

View File

@ -1,5 +1,4 @@
export { default } from './Lookup';
export { default as CategoryLookup } from './CategoryLookup';
export { default as InstanceGroupsLookup } from './InstanceGroupsLookup';
export { default as InventoryLookup } from './InventoryLookup';
export { default as ProjectLookup } from './ProjectLookup';

View File

@ -28,6 +28,7 @@ function SelectList({
readOnly,
selectItem,
deselectItem,
renderItemChip,
i18n,
}) {
return (
@ -39,6 +40,7 @@ function SelectList({
showOverflowAfter={5}
onRemove={item => deselectItem(item)}
isReadOnly={readOnly}
renderItemChip={renderItemChip}
/>
)}
<PaginatedDataList
@ -78,9 +80,11 @@ SelectList.propTypes = {
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
deselectItem: func.isRequired,
renderItemChip: func,
};
SelectList.defaultProps = {
multiple: false,
renderItemChip: null,
};
export default withI18n()(SelectList);

View File

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