flushing out new approach to MultiCredentialsLookup

This commit is contained in:
Keith Grant
2019-11-21 16:11:19 -08:00
parent 8ec856f3b6
commit 6260633974
5 changed files with 305 additions and 254 deletions

View File

@@ -3,32 +3,21 @@ import { arrayOf, string, func, object } from 'prop-types';
import { withRouter } from 'react-router-dom'; 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 { getQSConfig, parseQueryString } from '@util/qs'; import { getQSConfig, parseQueryString } from '@util/qs';
import { FieldTooltip } from '@components/FormField';
import Lookup from './NewLookup'; import Lookup from './NewLookup';
import SelectList from './shared/SelectList';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` const QS_CONFIG = getQSConfig('instance_groups', {
margin-left: 10px;
`;
const QS_CONFIG = getQSConfig('instance-groups', {
page: 1, page: 1,
page_size: 5, page_size: 5,
order_by: 'name', order_by: 'name',
}); });
// const getInstanceGroups = async params => InstanceGroupsAPI.read(params);
function InstanceGroupsLookup({ function InstanceGroupsLookup(props) {
value, const { value, onChange, tooltip, className, history, i18n } = props;
onChange,
tooltip,
className,
history,
i18n,
}) {
const [instanceGroups, setInstanceGroups] = useState([]); const [instanceGroups, setInstanceGroups] = useState([]);
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -46,56 +35,62 @@ function InstanceGroupsLookup({
})(); })();
}, [history.location]); }, [history.location]);
/*
Wrapping <div> added to workaround PF bug:
https://github.com/patternfly/patternfly-react/issues/2855
*/
return ( return (
<div className={className}> <FormGroup
<FormGroup className={className}
label={i18n._(t`Instance Groups`)} label={i18n._(t`Instance Groups`)}
fieldId="org-instance-groups" fieldId="org-instance-groups"
> >
{tooltip && ( {tooltip && <FieldTooltip content={tooltip} />}
<Tooltip position="right" content={tooltip}> <Lookup
<QuestionCircleIcon /> id="org-instance-groups"
</Tooltip> header={i18n._(t`Instance Groups`)}
// name="instanceGroups"
value={value}
onChange={onChange}
// items={instanceGroups}
// count={count}
qsConfig={QS_CONFIG}
multiple
// columns={}
sortedColumnKey="name"
renderSelectList={({ state, dispatch, canDelete }) => (
<SelectList
value={state.selectedItems}
options={instanceGroups}
optionCount={count}
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: 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`Instance Groups`)}
name="instanceGroups"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)} )}
<Lookup />
id="org-instance-groups" {error ? <div>error {error.message}</div> : ''}
lookupHeader={i18n._(t`Instance Groups`)} </FormGroup>
name="instanceGroups"
value={value}
onChange={onChange}
items={instanceGroups}
count={count}
qsConfig={QS_CONFIG}
multiple
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: false,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: false,
isNumeric: true,
},
]}
sortedColumnKey="name"
/>
{error ? <div>error {error.message}</div> : ''}
</FormGroup>
</div>
); );
} }

View File

@@ -1,18 +1,18 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom'; 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 CategoryLookup from '@components/Lookup/CategoryLookup'; 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 { getQSConfig, parseQueryString } from '@util/qs';
import Lookup from './NewLookup';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)` import SelectList from './shared/SelectList';
margin-left: 10px; import multiCredentialReducer from './shared/multiCredentialReducer';
`;
const QS_CONFIG = getQSConfig('credentials', { const QS_CONFIG = getQSConfig('credentials', {
page: 1, page: 1,
@@ -20,6 +20,7 @@ const QS_CONFIG = getQSConfig('credentials', {
order_by: 'name', order_by: 'name',
}); });
// TODO: move into reducer
function toggleCredentialSelection(credentialsToUpdate, newCredential) { function toggleCredentialSelection(credentialsToUpdate, newCredential) {
let newCredentialsList; let newCredentialsList;
const isSelectedCredentialInState = const isSelectedCredentialInState =
@@ -39,124 +40,164 @@ function toggleCredentialSelection(credentialsToUpdate, newCredential) {
return newCredentialsList; return newCredentialsList;
} }
class MultiCredentialsLookup extends React.Component { async function loadCredentialTypes() {
constructor(props) { const { data } = await CredentialTypesAPI.read();
super(props); const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
const credentialTypes = [];
this.state = { // TODO: cleanup
selectedCredentialType: { label: 'Machine', id: 1, kind: 'ssh' }, data.results.forEach(cred => {
credentialTypes: [], acceptableTypes.forEach(aT => {
}; if (aT === cred.kind) {
this.loadCredentialTypes = this.loadCredentialTypes.bind(this); // This object has several repeated values as some of it's children
this.handleCredentialTypeSelect = this.handleCredentialTypeSelect.bind( // require different field values.
this cred = {
); id: cred.id,
this.loadCredentials = this.loadCredentials.bind(this); key: cred.id,
} kind: cred.kind,
type: cred.namespace,
componentDidMount() { value: cred.name,
this.loadCredentialTypes(); label: cred.name,
this.loadCredentials(); isDisabled: false,
} };
credentialTypes.push(cred);
async loadCredentialTypes() { }
const { onError } = this.props;
try {
const { data } = await CredentialTypesAPI.read();
const acceptableTypes = ['machine', 'cloud', 'net', 'ssh', 'vault'];
const credentialTypes = [];
data.results.forEach(cred => {
acceptableTypes.forEach(aT => {
if (aT === cred.kind) {
// This object has several repeated values as some of it's children
// require different field values.
cred = {
id: cred.id,
key: cred.id,
kind: cred.kind,
type: cred.namespace,
value: cred.name,
label: cred.name,
isDisabled: false,
};
credentialTypes.push(cred);
}
});
});
this.setState({ credentialTypes });
} catch (err) {
onError(err);
}
}
async loadCredentials() {
const { history, onError } = this.props;
const { selectedCredentialType } = this.state;
const params = parseQueryString(QS_CONFIG, history.location.search);
params.credential_type = selectedCredentialType.id || 1;
try {
const { data } = await CredentialsAPI.read(params);
this.setState({
credentials: data.results,
count: data.count,
});
} catch (err) {
onError(err);
}
}
handleCredentialTypeSelect(value, type) {
const { credentialTypes } = this.state;
const selectedType = credentialTypes.filter(item => item.label === type);
this.setState({ selectedCredentialType: selectedType[0] }, () => {
this.loadCredentials();
}); });
} });
return credentialTypes;
}
render() { async function loadCredentials(params, selectedCredentialTypeId) {
const { params.credential_type = selectedCredentialTypeId || 1;
selectedCredentialType, const { data } = await CredentialsAPI.read(params);
credentialTypes, return data;
credentials, }
count,
} = this.state; function MultiCredentialsLookup(props) {
const { tooltip, i18n, value, onChange } = this.props; const { history, tooltip, value, onChange, onError, i18n } = props;
return ( const [credentialTypes, setCredentialTypes] = useState([]);
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential"> const [selectedType, setSelectedType] = useState(null);
{tooltip && ( const [credentials, setCredentials] = useState([]);
<Tooltip position="right" content={tooltip}> const [credentialsCount, setCredentialsCount] = useState(0);
<QuestionCircleIcon />
</Tooltip> useEffect(() => {
)} (async () => {
{credentialTypes && ( try {
<CategoryLookup const types = await loadCredentialTypes();
selectCategoryOptions={credentialTypes} setCredentialTypes(types);
selectCategory={this.handleCredentialTypeSelect} setSelectedType(types[0]);
selectedCategory={selectedCredentialType} } catch (err) {
onToggleItem={toggleCredentialSelection} onError(err);
onloadCategories={this.loadCredentialTypes} }
id="multiCredential" })();
lookupHeader={i18n._(t`Credentials`)} }, []);
name="credentials"
value={value} useEffect(() => {
multiple console.log('useEffect', selectedType);
onChange={onChange} (async () => {
items={credentials} if (!selectedType) {
count={count} return;
qsConfig={QS_CONFIG} }
columns={[ try {
{ const params = parseQueryString(QS_CONFIG, history.location.search);
name: i18n._(t`Name`), const { results, count } = await loadCredentials(
key: 'name', params,
isSortable: true, selectedType.id
isSearchable: true, );
}, setCredentials(results);
]} setCredentialsCount(count);
} catch (err) {
onError(err);
}
})();
}, [selectedType]);
// handleCredentialTypeSelect(value, type) {
// const { credentialTypes } = this.state;
// const selectedType = credentialTypes.filter(item => item.label === type);
// this.setState({ selectedCredentialType: selectedType[0] }, () => {
// this.loadCredentials();
// });
// }
// const {
// selectedCredentialType,
// credentialTypes,
// credentials,
// credentialsCount,
// } = state;
return (
<FormGroup label={i18n._(t`Credentials`)} fieldId="multiCredential">
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
reducer={multiCredentialReducer}
onToggleItem={toggleCredentialSelection}
id="multiCredential"
lookupHeader={i18n._(t`Credentials`)}
// name="credentials"
value={value}
multiple
onChange={onChange}
// items={credentials}
// count={credentialsCount}
qsConfig={QS_CONFIG}
// columns={}
// TODO bind removeItem
renderItemChip={({ item, removeItem, canDelete }) => (
<CredentialChip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
credential={item}
/> />
)} )}
</FormGroup> renderSelectList={({ state, dispatch, canDelete }) => {
); return (
} <>
{credentialTypes && credentialTypes.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;">
<div css="flex: 0 0 25%;">{i18n._(t`Selected Category`)}</div>
<VerticalSeperator />
<AnsibleSelect
css="flex: 1 1 75%;"
id="multiCredentialsLookUp-select"
label={i18n._(t`Selected Category`)}
data={credentialTypes}
value={selectedType && selectedType.label}
onChange={(e, label) => {
setSelectedType(
credentialTypes.find(o => o.label === label)
);
}}
/>
</ToolbarItem>
)}
<SelectList
value={state.selectedItems}
options={credentials}
optionCount={credentialsCount}
columns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
multiple={selectedType && selectedType.value === 'Vault'}
header={i18n._(t`Credentials`)}
name="credentials"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={() => {}}
deselectItem={() => {}}
/>
</>
);
}}
/>
</FormGroup>
);
} }
MultiCredentialsLookup.propTypes = { MultiCredentialsLookup.propTypes = {
@@ -178,6 +219,6 @@ MultiCredentialsLookup.defaultProps = {
tooltip: '', tooltip: '',
value: [], value: [],
}; };
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export { MultiCredentialsLookup as _MultiCredentialsLookup };
export default withI18n()(withRouter(MultiCredentialsLookup)); export default withI18n()(withRouter(MultiCredentialsLookup));

View File

@@ -21,7 +21,6 @@ import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import reducer, { initReducer } from './shared/reducer'; import reducer, { initReducer } from './shared/reducer';
import SelectList from './shared/SelectList';
import { ChipGroup, Chip } from '../Chip'; import { ChipGroup, Chip } from '../Chip';
import { QSConfig } from '@types'; import { QSConfig } from '@types';
@@ -51,20 +50,28 @@ const ChipHolder = styled.div`
function Lookup(props) { function Lookup(props) {
const { const {
id, id,
items, // items,
count, // count,
header, header,
name, // name,
onChange, onChange,
onBlur, onBlur,
columns, // columns,
value, value,
multiple, multiple,
required, required,
qsConfig, qsConfig,
renderItemChip,
renderSelectList,
history,
i18n, i18n,
} = props; } = props;
const [state, dispatch] = useReducer(reducer, props, initReducer);
const [state, dispatch] = useReducer(
reducer,
{ value, multiple, required },
initReducer
);
useEffect(() => { useEffect(() => {
dispatch({ type: 'SET_MULTIPLE', value: multiple }); dispatch({ type: 'SET_MULTIPLE', value: multiple });
@@ -74,10 +81,18 @@ function Lookup(props) {
dispatch({ type: 'SET_VALUE', value }); dispatch({ type: 'SET_VALUE', value });
}, [value]); }, [value]);
const clearQSParams = () => {
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('&')}`);
};
const save = () => { const save = () => {
const { selectedItems } = state; const { selectedItems } = state;
const val = multiple ? selectedItems : selectedItems[0] || null; const val = multiple ? selectedItems : selectedItems[0] || null;
onChange(val); onChange(val);
clearQSParams();
dispatch({ type: 'CLOSE_MODAL' }); dispatch({ type: 'CLOSE_MODAL' });
}; };
@@ -89,8 +104,12 @@ function Lookup(props) {
} }
}; };
const { isModalOpen, selectedItems } = state; const closeModal = () => {
clearQSParams();
dispatch({ type: 'CLOSE_MODAL' });
};
const { isModalOpen, selectedItems } = state;
const canDelete = !required || (multiple && value.length > 1); const canDelete = !required || (multiple && value.length > 1);
return ( return (
<Fragment> <Fragment>
@@ -105,15 +124,13 @@ function Lookup(props) {
</SearchButton> </SearchButton>
<ChipHolder className="pf-c-form-control"> <ChipHolder className="pf-c-form-control">
<ChipGroup> <ChipGroup>
{(multiple ? value : [value]).map(item => ( {(multiple ? value : [value]).map(item =>
<Chip renderItemChip({
key={item.id} item,
onClick={() => removeItem(item)} removeItem,
isReadOnly={!canDelete} canDelete,
> })
{item.name} )}
</Chip>
))}
</ChipGroup> </ChipGroup>
</ChipHolder> </ChipHolder>
</InputGroup> </InputGroup>
@@ -121,7 +138,7 @@ function Lookup(props) {
className="awx-c-modal" className="awx-c-modal"
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)} title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={() => dispatch({ type: 'TOGGLE_MODAL' })} onClose={closeModal}
actions={[ actions={[
<Button <Button
key="select" key="select"
@@ -133,27 +150,16 @@ function Lookup(props) {
> >
{i18n._(t`Select`)} {i18n._(t`Select`)}
</Button>, </Button>,
<Button <Button key="cancel" variant="secondary" onClick={closeModal}>
key="cancel"
variant="secondary"
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
>
{i18n._(t`Cancel`)} {i18n._(t`Cancel`)}
</Button>, </Button>,
]} ]}
> >
<SelectList {renderSelectList({
value={selectedItems} state,
options={items} dispatch,
optionCount={count} canDelete,
columns={columns} })}
multiple={multiple}
header={header}
name={name}
qsConfig={qsConfig}
readOnly={!canDelete}
dispatch={dispatch}
/>
</Modal> </Modal>
</Fragment> </Fragment>
); );
@@ -165,27 +171,38 @@ const Item = shape({
Lookup.propTypes = { Lookup.propTypes = {
id: string, id: string,
items: arrayOf(shape({})).isRequired, // items: arrayOf(shape({})).isRequired,
count: number.isRequired, // count: number.isRequired,
// TODO: change to `header` // TODO: change to `header`
header: string, header: string,
name: string, // name: string,
onChange: func.isRequired, onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]), value: oneOfType([Item, arrayOf(Item)]),
multiple: bool, multiple: bool,
required: bool, required: bool,
onBlur: func, onBlur: func,
qsConfig: QSConfig.isRequired, qsConfig: QSConfig.isRequired,
renderItemChip: func,
renderSelectList: func.isRequired,
}; };
Lookup.defaultProps = { Lookup.defaultProps = {
id: 'lookup-search', id: 'lookup-search',
header: null, header: null,
name: null, // name: null,
value: null, value: null,
multiple: false, multiple: false,
required: false, required: false,
onBlur: () => {}, onBlur: () => {},
renderItemChip: ({ item, removeItem, canDelete }) => (
<Chip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
>
{item.name}
</Chip>
),
}; };
export { Lookup as _Lookup }; export { Lookup as _Lookup };

View File

@@ -26,7 +26,8 @@ function SelectList({
name, name,
qsConfig, qsConfig,
readOnly, readOnly,
dispatch, selectItem,
deselectItem,
i18n, i18n,
}) { }) {
return ( return (
@@ -36,7 +37,7 @@ function SelectList({
label={i18n._(t`Selected`)} label={i18n._(t`Selected`)}
selected={value} selected={value}
showOverflowAfter={5} showOverflowAfter={5}
onRemove={item => dispatch({ type: 'DESELECT_ITEM', item })} onRemove={item => deselectItem(item)}
isReadOnly={readOnly} isReadOnly={readOnly}
/> />
)} )}
@@ -53,8 +54,8 @@ function SelectList({
name={multiple ? item.name : name} name={multiple ? item.name : name}
label={item.name} label={item.name}
isSelected={value.some(i => i.id === item.id)} isSelected={value.some(i => i.id === item.id)}
onSelect={() => dispatch({ type: 'SELECT_ITEM', item })} onSelect={() => selectItem(item)}
onDeselect={() => dispatch({ type: 'DESELECT_ITEM', item })} onDeselect={() => deselectItem(item)}
isRadio={!multiple} isRadio={!multiple}
/> />
)} )}
@@ -75,7 +76,8 @@ SelectList.propTypes = {
columns: arrayOf(shape({})).isRequired, columns: arrayOf(shape({})).isRequired,
multiple: bool, multiple: bool,
qsConfig: QSConfig.isRequired, qsConfig: QSConfig.isRequired,
dispatch: func.isRequired, selectItem: func.isRequired,
deselectItem: func.isRequired,
}; };
SelectList.defaultProps = { SelectList.defaultProps = {
multiple: false, multiple: false,

View File

@@ -1,3 +1,5 @@
// import { useReducer, useEffect } from 'react';
export default function reducer(state, action) { export default function reducer(state, action) {
// console.log(action, state); // console.log(action, state);
switch (action.type) { switch (action.type) {
@@ -56,33 +58,13 @@ function toggleModal(state) {
} }
function closeModal(state) { function closeModal(state) {
// TODO clear QSParams & push history state?
// state.clearQSParams();
return { return {
...state, ...state,
isModalOpen: false, isModalOpen: false,
}; };
} }
// 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('&')}`);
// }
export function initReducer({ export function initReducer({ value, multiple = false, required = false }) {
id,
items,
count,
header,
name,
onChange,
value,
multiple = false,
required = false,
qsConfig,
}) {
assertCorrectValueType(value, multiple); assertCorrectValueType(value, multiple);
let selectedItems = []; let selectedItems = [];
if (value) { if (value) {
@@ -94,7 +76,6 @@ export function initReducer({
multiple, multiple,
isModalOpen: false, isModalOpen: false,
required, required,
onChange,
}; };
} }
@@ -108,3 +89,18 @@ function assertCorrectValueType(value, multiple) {
throw new Error('Lookup value must be an array if `multiple` is set'); throw new Error('Lookup value must be an array if `multiple` is set');
} }
} }
//
// export function useLookup(config) {
// const { value, multiple, required, onChange, history } = config;
// const [state, dispatch] = useReducer(
// config.reducer || reducer,
// {
// value,
// multiple,
// required,
// },
// config.initReducer || initReducer
// );
//
// return [state, dispatch];
// }