diff --git a/awx/ui_next/src/components/Lookup/CategoryLookup.jsx b/awx/ui_next/src/components/Lookup/CategoryLookup.jsx new file mode 100644 index 0000000000..f3e8935bdf --- /dev/null +++ b/awx/ui_next/src/components/Lookup/CategoryLookup.jsx @@ -0,0 +1,348 @@ +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, Chip, 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); + const chips = () => { + return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + credential={chip} + /> + ))} + + ) : ( + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + > + {chip.name} + + ))} + + ); + }; + return ( + + + + + + + {value ? chips(value) : null} + + + + {i18n._(t`Select`)} + , + , + ]} + > + {selectCategoryOptions && selectCategoryOptions.length > 0 && ( + + Selected Category + + + + )} + {selectedItems.length > 0 && ( + 0 + } + /> + )} + ( + i.id === item.id)} + onSelect={() => this.addItem(item)} + isRadio={ + !multiple || + (selectCategoryOptions && + selectCategoryOptions.length && + selectedCategory.value !== 'Vault') + } + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + {error ?
error: {error.message}
: ''} +
+
+ ); + } +} + +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)); diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index c720ae2363..6641294235 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -1,84 +1,114 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useState, useEffect } from 'react'; +import { arrayOf, string, func, object } from 'prop-types'; +import { withRouter } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup, Tooltip } from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; - import { InstanceGroupsAPI } from '@api'; import Lookup from '@components/Lookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; const QuestionCircleIcon = styled(PFQuestionCircleIcon)` margin-left: 10px; `; -const getInstanceGroups = async params => InstanceGroupsAPI.read(params); +const QS_CONFIG = getQSConfig('instance-groups', { + page: 1, + page_size: 5, + order_by: 'name', +}); +// const getInstanceGroups = async params => InstanceGroupsAPI.read(params); -class InstanceGroupsLookup extends React.Component { - render() { - const { value, tooltip, onChange, className, i18n } = this.props; +function InstanceGroupsLookup({ + value, + onChange, + tooltip, + className, + history, + i18n, +}) { + const [instanceGroups, setInstanceGroups] = 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 InstanceGroupsAPI.read(params); + setInstanceGroups(data.results); + setCount(data.count); + } catch (err) { + setError(err); + } + })(); + }, [history.location]); + + /* Wrapping
added to workaround PF bug: https://github.com/patternfly/patternfly-react/issues/2855 */ - return ( -
- - {tooltip && ( - - - - )} - - -
- ); - } + return ( +
+ + {tooltip && ( + + + + )} + + {error ?
error {error.message}
: ''} +
+
+ ); } InstanceGroupsLookup.propTypes = { - value: PropTypes.arrayOf(PropTypes.object).isRequired, - tooltip: PropTypes.string, - onChange: PropTypes.func.isRequired, + value: arrayOf(object).isRequired, + tooltip: string, + onChange: func.isRequired, + className: string, }; InstanceGroupsLookup.defaultProps = { tooltip: '', + className: '', }; -export default withI18n()(InstanceGroupsLookup); +export default withI18n()(withRouter(InstanceGroupsLookup)); diff --git a/awx/ui_next/src/components/Lookup/Lookup.jsx b/awx/ui_next/src/components/Lookup/Lookup.jsx index b1913e0100..7446d8f09f 100644 --- a/awx/ui_next/src/components/Lookup/Lookup.jsx +++ b/awx/ui_next/src/components/Lookup/Lookup.jsx @@ -15,20 +15,17 @@ import { ButtonVariant, InputGroup as PFInputGroup, Modal, - ToolbarItem, } from '@patternfly/react-core'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import styled from 'styled-components'; -import AnsibleSelect from '../AnsibleSelect'; import PaginatedDataList from '../PaginatedDataList'; -import VerticalSeperator from '../VerticalSeparator'; import DataListToolbar from '../DataListToolbar'; import CheckboxListItem from '../CheckboxListItem'; import SelectedList from '../SelectedList'; -import { ChipGroup, Chip, CredentialChip } from '../Chip'; -import { getQSConfig, parseQueryString } from '../../util/qs'; +import { ChipGroup, Chip } from '../Chip'; +import { QSConfig } from '@types'; const SearchButton = styled(Button)` ::after { @@ -66,42 +63,16 @@ class Lookup extends React.Component { this.state = { isModalOpen: false, selectedItems, - 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.addItem = this.addItem.bind(this); this.removeItem = this.removeItem.bind(this); this.saveModal = this.saveModal.bind(this); - this.getData = this.getData.bind(this); this.clearQSParams = this.clearQSParams.bind(this); } - componentDidMount() { - this.getData(); - } - - componentDidUpdate(prevProps) { - const { location, selectedCategory } = this.props; - if ( - location !== prevProps.location || - prevProps.selectedCategory !== selectedCategory - ) { - this.getData(); - } - } - assertCorrectValueType() { - const { multiple, value, selectCategoryOptions } = this.props; - if (selectCategoryOptions) { - return; - } + const { multiple, value } = this.props; if (!multiple && Array.isArray(value)) { throw new Error( 'Lookup value must not be an array unless `multiple` is set' @@ -112,27 +83,6 @@ class Lookup extends React.Component { } } - 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 }); - } - } - removeItem(row) { const { selectedItems } = this.state; const { onToggleItem } = this.props; @@ -163,55 +113,11 @@ class Lookup extends React.Component { } this.setState({ selectedItems: [...selectedItems, row] }); } - // toggleSelected(row) { - // const { - // name, - // onChange, - // multiple, - // onToggleItem, - // selectCategoryOptions, - // onChange, - // value - // } = this.props; - // const { - // selectedItems: updatedSelectedItems, - // isModalOpen, - // } = this.state; - - // const selectedIndex = updatedSelectedItems.findIndex( - // selectedRow => selectedRow.id === row.id - // ); - // - // if (multiple) { - // - // if (selectCategoryOptions) { - // - // onToggleItem(row, isModalOpen); - // } - // if (selectedIndex > -1) { - // - // const valueToUpdate = value.filter(itemValue => itemValue.id !==row.id ); - // onChange(valueToUpdate) - // } else { - // - // onChange([...value, row]) - // } - // } else { - // - // onChange(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) { - // onChange(updatedSelectedItems, name); - // } - // } + // TODO: cleanup handleModalToggle() { const { isModalOpen } = this.state; - const { value, multiple, selectCategory } = this.props; + const { value, multiple } = 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 @@ -223,20 +129,17 @@ class Lookup extends React.Component { this.setState({ selectedItems }); } else { this.clearQSParams(); - if (selectCategory) { - selectCategory(null, 'Machine'); - } } this.setState(prevState => ({ isModalOpen: !prevState.isModalOpen, })); } - removeItemAndSave(row) { + removeItemAndSave(item) { const { value, onChange, multiple } = this.props; if (multiple) { - onChange(value.filter(item => item.id !== row.id)); - } else if (value.id === row.id) { + onChange(value.filter(i => i.id !== item.id)); + } else if (value.id === item.id) { onChange(null); } } @@ -251,58 +154,31 @@ class Lookup extends React.Component { } clearQSParams() { - const { history } = this.props; + const { qsConfig, history } = this.props; const parts = history.location.search.replace(/^\?/, '').split('&'); - const ns = this.qsConfig.namespace; + const ns = qsConfig.namespace; const otherParts = parts.filter(param => !param.startsWith(`${ns}.`)); history.push(`${history.location.pathname}?${otherParts.join('&')}`); } render() { - const { isModalOpen, selectedItems, error, results, count } = this.state; + const { isModalOpen, selectedItems } = this.state; const { - form, id, lookupHeader, value, + items, + count, columns, multiple, name, onBlur, - selectCategory, required, + qsConfig, i18n, - selectCategoryOptions, - selectedCategory, } = this.props; const header = lookupHeader || i18n._(t`Items`); const canDelete = !required || (multiple && value.length > 1); - const chips = () => { - return selectCategoryOptions && selectCategoryOptions.length > 0 ? ( - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - credential={chip} - /> - ))} - - ) : ( - - {(multiple ? value : [value]).map(chip => ( - this.removeItemAndSave(chip)} - isReadOnly={!canDelete} - > - {chip.name} - - ))} - - ); - }; return ( @@ -315,7 +191,17 @@ class Lookup extends React.Component { - {value ? chips(value) : null} + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + > + {chip.name} + + ))} + , ]} > - {selectCategoryOptions && selectCategoryOptions.length > 0 && ( - - Selected Category - - - - )} {selectedItems.length > 0 && ( 0 - } /> )} ( i.id === item.id)} onSelect={() => this.addItem(item)} - isRadio={ - !multiple || - (selectCategoryOptions && - selectCategoryOptions.length && - selectedCategory.value !== 'Vault') - } + isRadio={!multiple} /> )} renderToolbar={props => } showPageSizeOptions={false} /> - {error ?
error
: ''}
); @@ -406,15 +268,16 @@ const Item = shape({ Lookup.propTypes = { id: string, - getItems: func.isRequired, + items: arrayOf(shape({})).isRequired, + count: number.isRequired, + // TODO: change to `header` lookupHeader: string, name: string, onChange: func.isRequired, value: oneOfType([Item, arrayOf(Item)]), - sortedColumnKey: string.isRequired, multiple: bool, required: bool, - qsNamespace: string, + qsConfig: QSConfig.isRequired, }; Lookup.defaultProps = { @@ -424,7 +287,6 @@ Lookup.defaultProps = { value: null, multiple: false, required: false, - qsNamespace: 'lookup', }; export { Lookup as _Lookup }; diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 9e40ebccec..0277aa0ad3 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -1,22 +1,29 @@ import React from 'react'; +import { withRouter } from 'react-router-dom'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { FormGroup, Tooltip } from '@patternfly/react-core'; import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; - import { CredentialsAPI, CredentialTypesAPI } from '@api'; -import Lookup from '@components/Lookup'; +import CategoryLookup from '@components/Lookup/CategoryLookup'; +import { getQSConfig, parseQueryString } from '@util/qs'; const QuestionCircleIcon = styled(PFQuestionCircleIcon)` margin-left: 10px; `; + +const QS_CONFIG = getQSConfig('credentials', { + page: 1, + page_size: 5, + order_by: 'name', +}); + function toggleCredentialSelection(credentialsToUpdate, newCredential) { let newCredentialsList; const isSelectedCredentialInState = - credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > - 0; + credentialsToUpdate.filter(cred => cred.id === newCredential.id).length > 0; if (isSelectedCredentialInState) { newCredentialsList = credentialsToUpdate.filter( @@ -31,6 +38,7 @@ function toggleCredentialSelection(credentialsToUpdate, newCredential) { } return newCredentialsList; } + class MultiCredentialsLookup extends React.Component { constructor(props) { super(props); @@ -48,6 +56,7 @@ class MultiCredentialsLookup extends React.Component { componentDidMount() { this.loadCredentialTypes(); + this.loadCredentials(); } async loadCredentialTypes() { @@ -80,23 +89,38 @@ class MultiCredentialsLookup extends React.Component { } } - async loadCredentials(params) { + 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; - return CredentialsAPI.read(params); + 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.setState({ selectedCredentialType: selectedType[0] }, () => { + this.loadCredentials(); + }); } render() { - const { selectedCredentialType, credentialTypes } = this.state; - const { tooltip, i18n, credentials, onChange } = this.props; + const { + selectedCredentialType, + credentialTypes, + credentials, + count, + } = this.state; + const { tooltip, i18n, value, onChange } = this.props; return ( {tooltip && ( @@ -105,7 +129,7 @@ class MultiCredentialsLookup extends React.Component { )} {credentialTypes && ( - )} @@ -137,7 +161,7 @@ class MultiCredentialsLookup extends React.Component { MultiCredentialsLookup.propTypes = { tooltip: PropTypes.string, - credentials: PropTypes.arrayOf( + value: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -152,8 +176,8 @@ MultiCredentialsLookup.propTypes = { MultiCredentialsLookup.defaultProps = { tooltip: '', - credentials: [], + value: [], }; export { MultiCredentialsLookup as _MultiCredentialsLookup }; -export default withI18n()(MultiCredentialsLookup); +export default withI18n()(withRouter(MultiCredentialsLookup)); diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index cde48e2bcd..5e2959c00e 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -1,4 +1,5 @@ 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'; diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index 8d7c716ef9..683404d35f 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -28,6 +28,7 @@ class SelectedList extends Component { isReadOnly, isCredentialList, } = this.props; + // TODO: replace isCredentialList with renderChip ? const chips = isCredentialList ? selected.map(item => ( null, isReadOnly: false, + isCredentialList: false, }; export default SelectedList; diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index 846d7f4fb8..67e94ced20 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -317,7 +317,7 @@ class JobTemplateForm extends Component { fieldId="template-credentials" render={({ field }) => ( setFieldValue('credentials', newCredentials) }