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

start Lookup reducer

This commit is contained in:
Keith Grant 2019-11-18 15:20:58 -08:00
parent 5a207f155e
commit 8ec856f3b6
9 changed files with 696 additions and 78 deletions

View File

@ -16,6 +16,7 @@ const CheckboxListItem = ({
label,
isSelected,
onSelect,
onDeselect,
isRadio,
}) => {
const CheckboxRadio = isRadio ? DataListRadio : DataListCheck;
@ -25,7 +26,7 @@ const CheckboxListItem = ({
<CheckboxRadio
id={`selected-${itemId}`}
checked={isSelected}
onChange={onSelect}
onChange={isSelected ? onDeselect : onSelect}
aria-labelledby={`check-action-item-${itemId}`}
name={name}
value={itemId}
@ -60,6 +61,7 @@ CheckboxListItem.propTypes = {
label: PropTypes.string.isRequired,
isSelected: PropTypes.bool.isRequired,
onSelect: PropTypes.func.isRequired,
onDeselect: PropTypes.func.isRequired,
};
export default CheckboxListItem;

View File

@ -27,7 +27,7 @@ import VerticalSeperator from '../VerticalSeparator';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { ChipGroup, Chip, CredentialChip } from '../Chip';
import { ChipGroup, CredentialChip } from '../Chip';
import { QSConfig } from '@types';
const SearchButton = styled(Button)`
@ -57,7 +57,7 @@ class CategoryLookup extends React.Component {
constructor(props) {
super(props);
this.assertCorrectValueType();
// this.assertCorrectValueType();
let selectedItems = [];
if (props.value) {
selectedItems = props.multiple ? [...props.value] : [props.value];
@ -74,22 +74,22 @@ class CategoryLookup extends React.Component {
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'
);
}
}
// 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;
@ -193,32 +193,6 @@ class CategoryLookup extends React.Component {
} = this.props;
const header = lookupHeader || i18n._(t`Items`);
const canDelete = !required || (multiple && value.length > 1);
const chips = () => {
return selectCategoryOptions && selectCategoryOptions.length > 0 ? (
<ChipGroup>
{(multiple ? value : [value]).map(chip => (
<CredentialChip
key={chip.id}
onClick={() => this.removeItemAndSave(chip)}
isReadOnly={!canDelete}
credential={chip}
/>
))}
</ChipGroup>
) : (
<ChipGroup>
{(multiple ? value : [value]).map(chip => (
<Chip
key={chip.id}
onClick={() => this.removeItemAndSave(chip)}
isReadOnly={!canDelete}
>
{chip.name}
</Chip>
))}
</ChipGroup>
);
};
return (
<Fragment>
<InputGroup onBlur={onBlur}>
@ -231,7 +205,16 @@ class CategoryLookup extends React.Component {
<SearchIcon />
</SearchButton>
<ChipHolder className="pf-c-form-control">
{value ? chips(value) : null}
<ChipGroup>
{(multiple ? value : [value]).map(chip => (
<CredentialChip
key={chip.id}
onClick={() => this.removeItemAndSave(chip)}
isReadOnly={!canDelete}
credential={chip}
/>
))}
</ChipGroup>
</ChipHolder>
</InputGroup>
<Modal

View File

@ -7,8 +7,8 @@ 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';
import Lookup from './NewLookup';
const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px;

View File

@ -20,10 +20,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import PaginatedDataList from '../PaginatedDataList';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import SelectList from './shared/SelectList';
import { ChipGroup, Chip } from '../Chip';
import { QSConfig } from '@types';
@ -227,34 +224,17 @@ class Lookup extends React.Component {
</Button>,
]}
>
{selectedItems.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={selectedItems}
showOverflowAfter={5}
onRemove={this.removeItem}
isReadOnly={!canDelete}
/>
)}
<PaginatedDataList
items={items}
itemCount={count}
pluralizedItemName={lookupHeader}
<SelectList
value={selectedItems}
onChange={newVal => this.setState({ selectedItems: newVal })}
options={items}
optionCount={count}
columns={columns}
multiple={multiple}
header={lookupHeader}
name={name}
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}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
readOnly={!canDelete}
/>
</Modal>
</Fragment>

View File

@ -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 (
<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>
{(multiple ? value : [value]).map(item => (
<Chip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
>
{item.name}
</Chip>
))}
</ChipGroup>
</ChipHolder>
</InputGroup>
<Modal
className="awx-c-modal"
title={i18n._(t`Select ${header || i18n._(t`Items`)}`)}
isOpen={isModalOpen}
onClose={() => dispatch({ type: 'TOGGLE_MODAL' })}
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={() => dispatch({ type: 'TOGGLE_MODAL' })}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
<SelectList
value={selectedItems}
options={items}
optionCount={count}
columns={columns}
multiple={multiple}
header={header}
name={name}
qsConfig={qsConfig}
readOnly={!canDelete}
dispatch={dispatch}
/>
</Modal>
</Fragment>
);
}
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));

View 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

View File

@ -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 (
<div>
{value.length > 0 && (
<SelectedList
label={i18n._(t`Selected`)}
selected={value}
showOverflowAfter={5}
onRemove={item => dispatch({ type: 'DESELECT_ITEM', item })}
isReadOnly={readOnly}
/>
)}
<PaginatedDataList
items={options}
itemCount={optionCount}
pluralizedItemName={header}
qsConfig={qsConfig}
toolbarColumns={columns}
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={() => dispatch({ type: 'SELECT_ITEM', item })}
onDeselect={() => dispatch({ type: 'DESELECT_ITEM', item })}
isRadio={!multiple}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</div>
);
}
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);

View File

@ -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');
}
}

View File

@ -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,
});
});
});