diff --git a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx index 65958e47e8..9508672789 100644 --- a/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx +++ b/awx/ui_next/src/components/CheckboxListItem/CheckboxListItem.jsx @@ -16,6 +16,7 @@ const CheckboxListItem = ({ label, isSelected, onSelect, + onDeselect, isRadio, }) => { const CheckboxRadio = isRadio ? DataListRadio : DataListCheck; @@ -25,7 +26,7 @@ const CheckboxListItem = ({ 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 ( @@ -231,7 +205,16 @@ class CategoryLookup extends React.Component { - {value ? chips(value) : null} + + {(multiple ? value : [value]).map(chip => ( + this.removeItemAndSave(chip)} + isReadOnly={!canDelete} + credential={chip} + /> + ))} + , ]} > - {selectedItems.length > 0 && ( - - )} - this.setState({ selectedItems: newVal })} + options={items} + optionCount={count} + columns={columns} + multiple={multiple} + header={lookupHeader} + name={name} qsConfig={qsConfig} - toolbarColumns={columns} - renderItem={item => ( - i.id === item.id)} - onSelect={() => this.addItem(item)} - isRadio={!multiple} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} + readOnly={!canDelete} /> diff --git a/awx/ui_next/src/components/Lookup/NewLookup.jsx b/awx/ui_next/src/components/Lookup/NewLookup.jsx new file mode 100644 index 0000000000..0721e4e4d1 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/NewLookup.jsx @@ -0,0 +1,192 @@ +import React, { Fragment, useReducer, useEffect } 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, +} from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; + +import reducer, { initReducer } from './shared/reducer'; +import SelectList from './shared/SelectList'; +import { ChipGroup, Chip } 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; +`; + +function Lookup(props) { + const { + id, + items, + count, + header, + name, + onChange, + onBlur, + columns, + value, + multiple, + required, + qsConfig, + i18n, + } = props; + const [state, dispatch] = useReducer(reducer, props, initReducer); + + useEffect(() => { + dispatch({ type: 'SET_MULTIPLE', value: multiple }); + }, [multiple]); + + useEffect(() => { + dispatch({ type: 'SET_VALUE', value }); + }, [value]); + + const save = () => { + const { selectedItems } = state; + const val = multiple ? selectedItems : selectedItems[0] || null; + onChange(val); + dispatch({ type: 'CLOSE_MODAL' }); + }; + + const removeItem = item => { + if (multiple) { + onChange(value.filter(i => i.id !== item.id)); + } else { + onChange(null); + } + }; + + const { isModalOpen, selectedItems } = state; + + const canDelete = !required || (multiple && value.length > 1); + return ( + + + dispatch({ type: 'TOGGLE_MODAL' })} + variant={ButtonVariant.tertiary} + > + + + + + {(multiple ? value : [value]).map(item => ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.name} + + ))} + + + + dispatch({ type: 'TOGGLE_MODAL' })} + actions={[ + , + , + ]} + > + + + + ); +} + +const Item = shape({ + id: number.isRequired, +}); + +Lookup.propTypes = { + id: string, + items: arrayOf(shape({})).isRequired, + count: number.isRequired, + // TODO: change to `header` + header: string, + name: string, + onChange: func.isRequired, + value: oneOfType([Item, arrayOf(Item)]), + multiple: bool, + required: bool, + onBlur: func, + qsConfig: QSConfig.isRequired, +}; + +Lookup.defaultProps = { + id: 'lookup-search', + header: null, + name: null, + value: null, + multiple: false, + required: false, + onBlur: () => {}, +}; + +export { Lookup as _Lookup }; +export default withI18n()(withRouter(Lookup)); diff --git a/awx/ui_next/src/components/Lookup/README.md b/awx/ui_next/src/components/Lookup/README.md new file mode 100644 index 0000000000..4d5dc69674 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/README.md @@ -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 diff --git a/awx/ui_next/src/components/Lookup/shared/SelectList.jsx b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx new file mode 100644 index 0000000000..96db387c72 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/SelectList.jsx @@ -0,0 +1,84 @@ +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 SelectList({ + value, + options, + optionCount, + columns, + multiple, + header, + name, + qsConfig, + readOnly, + dispatch, + i18n, +}) { + return ( +
+ {value.length > 0 && ( + dispatch({ type: 'DESELECT_ITEM', item })} + isReadOnly={readOnly} + /> + )} + ( + i.id === item.id)} + onSelect={() => dispatch({ type: 'SELECT_ITEM', item })} + onDeselect={() => dispatch({ type: 'DESELECT_ITEM', item })} + isRadio={!multiple} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
+ ); +} + +const Item = shape({ + id: oneOfType([number, string]).isRequired, +}); +SelectList.propTypes = { + value: arrayOf(Item).isRequired, + options: arrayOf(Item).isRequired, + optionCount: number.isRequired, + columns: arrayOf(shape({})).isRequired, + multiple: bool, + qsConfig: QSConfig.isRequired, + dispatch: func.isRequired, +}; +SelectList.defaultProps = { + multiple: false, +}; + +export default withI18n()(SelectList); diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.js b/awx/ui_next/src/components/Lookup/shared/reducer.js new file mode 100644 index 0000000000..2e2c88f096 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/reducer.js @@ -0,0 +1,110 @@ +export default function reducer(state, action) { + // console.log(action, state); + 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 }; + 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); + } + return { + ...state, + isModalOpen: !isModalOpen, + selectedItems: multiple ? [...value] : [value], + }; +} + +function closeModal(state) { + // TODO clear QSParams & push history state? + // state.clearQSParams(); + return { + ...state, + 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({ + id, + items, + count, + header, + name, + onChange, + value, + multiple = false, + required = false, + qsConfig, +}) { + assertCorrectValueType(value, multiple); + let selectedItems = []; + if (value) { + selectedItems = multiple ? [...value] : [value]; + } + return { + selectedItems, + value, + multiple, + isModalOpen: false, + required, + onChange, + }; +} + +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'); + } +} diff --git a/awx/ui_next/src/components/Lookup/shared/reducer.test.js b/awx/ui_next/src/components/Lookup/shared/reducer.test.js new file mode 100644 index 0000000000..22bf9da106 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/reducer.test.js @@ -0,0 +1,262 @@ +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 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, + }); + }); +});