Add draggable selected list to galaxy credential lookup

This commit is contained in:
Marliana Lara
2021-06-28 10:51:01 -04:00
committed by Shane McDonald
parent 673f722b71
commit 9fddf7c5cf
12 changed files with 329 additions and 70 deletions
+1
View File
@@ -63,6 +63,7 @@
"aria-labelledby", "aria-labelledby",
"aria-hidden", "aria-hidden",
"aria-controls", "aria-controls",
"aria-pressed",
"sortKey", "sortKey",
"ouiaId", "ouiaId",
"credentialTypeNamespace", "credentialTypeNamespace",
@@ -6,7 +6,7 @@ import useRequest from '../../util/useRequest';
import { SearchColumns, SortColumns } from '../../types'; import { SearchColumns, SortColumns } from '../../types';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem'; import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList'; import { SelectedList } from '../SelectedList';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable'; import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import CheckboxCard from './CheckboxCard'; import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList'; import { SelectedList } from '../SelectedList';
function RolesStep({ function RolesStep({
onRolesClick, onRolesClick,
@@ -29,22 +29,24 @@ const QS_CONFIG = getQSConfig('credentials', {
}); });
function CredentialLookup({ function CredentialLookup({
helperTextInvalid, autoPopulate,
label,
isValid,
onBlur,
onChange,
required,
credentialTypeId, credentialTypeId,
credentialTypeKind, credentialTypeKind,
credentialTypeNamespace, credentialTypeNamespace,
value, draggable,
tooltip,
isDisabled,
autoPopulate,
multiple,
validate,
fieldName, fieldName,
helperTextInvalid,
isDisabled,
isValid,
label,
modalDescription,
multiple,
onBlur,
onChange,
required,
tooltip,
validate,
value,
}) { }) {
const history = useHistory(); const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange); const autoPopulateLookup = useAutoPopulateLookup(onChange);
@@ -174,6 +176,7 @@ function CredentialLookup({
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isDisabled={isDisabled} isDisabled={isDisabled}
multiple={multiple} multiple={multiple}
modalDescription={modalDescription}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
value={state.selectedItems} value={state.selectedItems}
@@ -208,7 +211,11 @@ function CredentialLookup({
name="credential" name="credential"
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
sortSelectedItems={selectedItems =>
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
}
multiple={multiple} multiple={multiple}
draggable={draggable}
/> />
)} )}
/> />
@@ -49,6 +49,7 @@ function Lookup(props) {
onDebounce, onDebounce,
fieldName, fieldName,
validate, validate,
modalDescription,
} = props; } = props;
const [typedText, setTypedText] = useState(''); const [typedText, setTypedText] = useState('');
const debounceRequest = useDebounce(onDebounce, 1000); const debounceRequest = useDebounce(onDebounce, 1000);
@@ -166,6 +167,7 @@ function Lookup(props) {
aria-label={t`Lookup modal`} aria-label={t`Lookup modal`}
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={closeModal} onClose={closeModal}
description={state?.selectedItems?.length > 0 && modalDescription}
actions={[ actions={[
<Button <Button
ouiaId="modal-select-button" ouiaId="modal-select-button"
@@ -204,6 +206,7 @@ const Item = shape({
Lookup.propTypes = { Lookup.propTypes = {
id: string, id: string,
header: string, header: string,
modalDescription: string,
onChange: func.isRequired, onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]), value: oneOfType([Item, arrayOf(Item)]),
multiple: bool, multiple: bool,
@@ -224,6 +227,7 @@ Lookup.defaultProps = {
value: null, value: null,
multiple: false, multiple: false,
required: false, required: false,
modalDescription: '',
onBlur: () => {}, onBlur: () => {},
renderItemChip: ({ item, removeItem, canDelete }) => ( renderItemChip: ({ item, removeItem, canDelete }) => (
<Chip <Chip
@@ -10,7 +10,7 @@ import {
} from 'prop-types'; } from 'prop-types';
import styled from 'styled-components'; import styled from 'styled-components';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import SelectedList from '../SelectedList'; import { SelectedList, DraggableSelectedList } from '../SelectedList';
import CheckboxListItem from '../CheckboxListItem'; import CheckboxListItem from '../CheckboxListItem';
import DataListToolbar from '../DataListToolbar'; import DataListToolbar from '../DataListToolbar';
import { QSConfig, SearchColumns, SortColumns } from '../../types'; import { QSConfig, SearchColumns, SortColumns } from '../../types';
@@ -23,28 +23,39 @@ const ModalList = styled.div`
`; `;
function OptionsList({ function OptionsList({
value,
contentError, contentError,
options, deselectItem,
optionCount, displayKey,
searchColumns, draggable,
sortColumns,
searchableKeys,
relatedSearchableKeys,
multiple,
header, header,
isLoading,
multiple,
name, name,
optionCount,
options,
qsConfig, qsConfig,
readOnly, readOnly,
selectItem, relatedSearchableKeys,
deselectItem,
renderItemChip, renderItemChip,
isLoading, searchColumns,
displayKey, searchableKeys,
selectItem,
sortColumns,
sortSelectedItems,
value,
}) { }) {
return ( let selectionPreview = null;
<ModalList> if (value.length > 0) {
{value.length > 0 && ( if (draggable) {
selectionPreview = (
<DraggableSelectedList
onRemove={deselectItem}
onRowDrag={sortSelectedItems}
selected={value}
/>
);
} else {
selectionPreview = (
<SelectedList <SelectedList
label={t`Selected`} label={t`Selected`}
selected={value} selected={value}
@@ -53,7 +64,13 @@ function OptionsList({
renderItemChip={renderItemChip} renderItemChip={renderItemChip}
displayKey={displayKey} displayKey={displayKey}
/> />
)} );
}
}
return (
<ModalList>
{selectionPreview}
<PaginatedTable <PaginatedTable
contentError={contentError} contentError={contentError}
items={options} items={options}
@@ -99,6 +116,7 @@ const Item = shape({
OptionsList.propTypes = { OptionsList.propTypes = {
deselectItem: func.isRequired, deselectItem: func.isRequired,
displayKey: string, displayKey: string,
draggable: bool,
multiple: bool, multiple: bool,
optionCount: number.isRequired, optionCount: number.isRequired,
options: arrayOf(Item).isRequired, options: arrayOf(Item).isRequired,
@@ -110,6 +128,7 @@ OptionsList.propTypes = {
value: arrayOf(Item).isRequired, value: arrayOf(Item).isRequired,
}; };
OptionsList.defaultProps = { OptionsList.defaultProps = {
draggable: false,
multiple: false, multiple: false,
renderItemChip: null, renderItemChip: null,
searchColumns: [], searchColumns: [],
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
DataList,
DataListAction,
DataListItem,
DataListCell,
DataListItemRow,
DataListControl,
DataListDragButton,
DataListItemCells,
} from '@patternfly/react-core';
import { TimesIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { t } from '@lingui/macro';
const RemoveActionSection = styled(DataListAction)`
&& {
align-items: center;
padding: 0;
}
`;
function DraggableSelectedList({ selected, onRemove, onRowDrag }) {
const [liveText, setLiveText] = useState('');
const [id, setId] = React.useState('');
const onDragStart = newId => {
setId(newId);
setLiveText(t`Dragging tarted for item id: ${newId}.`);
};
const onDragMove = (oldIndex, newIndex) => {
setLiveText(
t`Dragging item ${id}. Item with index ${oldIndex} in now ${newIndex}.`
);
};
const onDragCancel = () => {
setLiveText(t`Dragging cancelled. List is unchanged.`);
};
const onDragFinish = newItemOrder => {
const selectedItems = newItemOrder.map(item =>
selected.find(i => i.name === item)
);
onRowDrag(selectedItems);
};
const removeItem = item => {
onRemove(selected.find(i => i.name === item));
};
if (selected.length <= 0) {
return null;
}
const orderedList = selected.map(item => item.name);
return (
<>
<DataList
aria-label={t`Draggable list to reorder and remove selected items.`}
itemOrder={orderedList}
onDragCancel={onDragCancel}
onDragFinish={onDragFinish}
onDragMove={onDragMove}
onDragStart={onDragStart}
>
{orderedList.map((label, index) => {
const rowPosition = index + 1;
return (
<DataListItem id={label} key={rowPosition}>
<DataListItemRow>
<DataListControl>
<DataListDragButton
aria-label={t`Reorder`}
aria-labelledby={rowPosition}
aria-describedby={t`Press space or enter to begin dragging,
and use the arrow keys to navigate up or down.
Press enter to confirm the drag, or any other key to
cancel the drag operation.`}
aria-pressed="false"
/>
</DataListControl>
<DataListItemCells
dataListCells={[
<DataListCell key={label}>
<span id={rowPosition}>
{rowPosition}. {label}
</span>
</DataListCell>,
]}
/>
<RemoveActionSection aria-label={t`Actions`} id={rowPosition}>
<Button
onClick={() => removeItem(label)}
variant="plain"
aria-label={t`Remove`}
ouiaId="draggable-list-remove"
>
<TimesIcon />
</Button>
</RemoveActionSection>
</DataListItemRow>
</DataListItem>
);
})}
</DataList>
<div className="pf-screen-reader" aria-live="assertive">
{liveText}
</div>
</>
);
}
const ListItem = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
});
DraggableSelectedList.propTypes = {
onRemove: PropTypes.func,
onRowDrag: PropTypes.func,
selected: PropTypes.arrayOf(ListItem),
};
DraggableSelectedList.defaultProps = {
onRemove: () => null,
onRowDrag: () => null,
selected: [],
};
export default DraggableSelectedList;
@@ -0,0 +1,75 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import DraggableSelectedList from './DraggableSelectedList';
describe('<DraggableSelectedList />', () => {
let wrapper;
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render expected rows', () => {
const mockSelected = [
{
id: 1,
name: 'foo',
},
{
id: 2,
name: 'bar',
},
];
wrapper = mountWithContexts(
<DraggableSelectedList
selected={mockSelected}
onRemove={() => {}}
onRowDrag={() => {}}
/>
);
expect(wrapper.find('DraggableSelectedList').length).toBe(1);
expect(wrapper.find('DataListItem').length).toBe(2);
expect(
wrapper
.find('DataListItem DataListCell')
.first()
.containsMatchingElement(<span>1. foo</span>)
).toEqual(true);
expect(
wrapper
.find('DataListItem DataListCell')
.last()
.containsMatchingElement(<span>2. bar</span>)
).toEqual(true);
});
test('should not render when selected list is empty', () => {
wrapper = mountWithContexts(
<DraggableSelectedList
selected={[]}
onRemove={() => {}}
onRowDrag={() => {}}
/>
);
expect(wrapper.find('DataList').length).toBe(0);
});
test('should call onRemove callback prop on remove button click', () => {
const onRemove = jest.fn();
const mockSelected = [
{
id: 1,
name: 'foo',
},
];
wrapper = mountWithContexts(
<DraggableSelectedList selected={mockSelected} onRemove={onRemove} />
);
wrapper
.find('DataListItem[id="foo"] Button[aria-label="Remove"]')
.simulate('click');
expect(onRemove).toBeCalledWith({
id: 1,
name: 'foo',
});
});
});
@@ -1 +1,2 @@
export { default } from './SelectedList'; export { default as SelectedList } from './SelectedList';
export { default as DraggableSelectedList } from './DraggableSelectedList';
@@ -42,14 +42,20 @@ function OrganizationAdd() {
default_environment: values.default_environment?.id, default_environment: values.default_environment?.id,
}); });
await Promise.all( await Promise.all(
groupsToAssociate groupsToAssociate.map(id =>
.map(id => OrganizationsAPI.associateInstanceGroup(response.id, id)) OrganizationsAPI.associateInstanceGroup(response.id, id)
.concat( )
values.galaxy_credentials.map(({ id: credId }) =>
OrganizationsAPI.associateGalaxyCredential(response.id, credId)
)
)
); );
/* eslint-disable no-await-in-loop, no-restricted-syntax */
// Resolve Promises sequentially to maintain order and avoid race condition
for (const credential of values.galaxy_credentials) {
await OrganizationsAPI.associateGalaxyCredential(
response.id,
credential.id
);
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
history.push(`/organizations/${response.id}`); history.push(`/organizations/${response.id}`);
} catch (error) { } catch (error) {
setFormError(error); setFormError(error);
@@ -3,9 +3,14 @@ import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
import { getAddedAndRemoved } from '../../../util/lists';
import OrganizationForm from '../shared/OrganizationForm'; import OrganizationForm from '../shared/OrganizationForm';
const isEqual = (array1, array2) =>
array1.length === array2.length &&
array1.every((element, index) => {
return element.id === array2[index].id;
});
function OrganizationEdit({ organization }) { function OrganizationEdit({ organization }) {
const detailsUrl = `/organizations/${organization.id}/details`; const detailsUrl = `/organizations/${organization.id}/details`;
const history = useHistory(); const history = useHistory();
@@ -17,43 +22,39 @@ function OrganizationEdit({ organization }) {
groupsToDisassociate groupsToDisassociate
) => { ) => {
try { try {
const {
added: addedCredentials,
removed: removedCredentials,
} = getAddedAndRemoved(
organization.galaxy_credentials,
values.galaxy_credentials
);
const addedCredentialIds = addedCredentials.map(({ id }) => id);
const removedCredentialIds = removedCredentials.map(({ id }) => id);
await OrganizationsAPI.update(organization.id, { await OrganizationsAPI.update(organization.id, {
...values, ...values,
default_environment: values.default_environment?.id || null, default_environment: values.default_environment?.id || null,
}); });
await Promise.all( await Promise.all(
groupsToAssociate groupsToAssociate.map(id =>
.map(id => OrganizationsAPI.associateInstanceGroup(organization.id, id)
OrganizationsAPI.associateInstanceGroup(organization.id, id) )
)
.concat(
addedCredentialIds.map(id =>
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
)
)
); );
await Promise.all( await Promise.all(
groupsToDisassociate groupsToDisassociate.map(id =>
.map(id => OrganizationsAPI.disassociateInstanceGroup(organization.id, id)
OrganizationsAPI.disassociateInstanceGroup(organization.id, id) )
)
.concat(
removedCredentialIds.map(id =>
OrganizationsAPI.disassociateGalaxyCredential(organization.id, id)
)
)
); );
/* eslint-disable no-await-in-loop, no-restricted-syntax */
// Resolve Promises sequentially to avoid race condition
if (
!isEqual(organization.galaxy_credentials, values.galaxy_credentials)
) {
for (const credential of organization.galaxy_credentials) {
await OrganizationsAPI.disassociateGalaxyCredential(
organization.id,
credential.id
);
}
for (const credential of values.galaxy_credentials) {
await OrganizationsAPI.associateGalaxyCredential(
organization.id,
credential.id
);
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
history.push(detailsUrl); history.push(detailsUrl);
} catch (error) { } catch (error) {
setFormError(error); setFormError(error);
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import { OrganizationsAPI } from '../../../api'; import { OrganizationsAPI } from '../../../api';
@@ -106,7 +106,20 @@ function OrganizationFormFields({
onChange={handleCredentialUpdate} onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value} value={galaxyCredentialsField.value}
multiple multiple
draggable
fieldName="galaxy_credentials" fieldName="galaxy_credentials"
modalDescription={
<>
<b>
<Trans>Selected</Trans>
</b>
<br />
<Trans>
Note: The order of these credentials sets precedence for the sync
and lookup of the content.
</Trans>
</>
}
/> />
</> </>
); );