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-hidden",
"aria-controls",
"aria-pressed",
"sortKey",
"ouiaId",
"credentialTypeNamespace",
@@ -6,7 +6,7 @@ import useRequest from '../../util/useRequest';
import { SearchColumns, SortColumns } from '../../types';
import DataListToolbar from '../DataListToolbar';
import CheckboxListItem from '../CheckboxListItem';
import SelectedList from '../SelectedList';
import { SelectedList } from '../SelectedList';
import { getQSConfig, parseQueryString } from '../../util/qs';
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { t } from '@lingui/macro';
import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList';
import { SelectedList } from '../SelectedList';
function RolesStep({
onRolesClick,
@@ -29,22 +29,24 @@ const QS_CONFIG = getQSConfig('credentials', {
});
function CredentialLookup({
helperTextInvalid,
label,
isValid,
onBlur,
onChange,
required,
autoPopulate,
credentialTypeId,
credentialTypeKind,
credentialTypeNamespace,
value,
tooltip,
isDisabled,
autoPopulate,
multiple,
validate,
draggable,
fieldName,
helperTextInvalid,
isDisabled,
isValid,
label,
modalDescription,
multiple,
onBlur,
onChange,
required,
tooltip,
validate,
value,
}) {
const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange);
@@ -174,6 +176,7 @@ function CredentialLookup({
qsConfig={QS_CONFIG}
isDisabled={isDisabled}
multiple={multiple}
modalDescription={modalDescription}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
@@ -208,7 +211,11 @@ function CredentialLookup({
name="credential"
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
sortSelectedItems={selectedItems =>
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
}
multiple={multiple}
draggable={draggable}
/>
)}
/>
@@ -49,6 +49,7 @@ function Lookup(props) {
onDebounce,
fieldName,
validate,
modalDescription,
} = props;
const [typedText, setTypedText] = useState('');
const debounceRequest = useDebounce(onDebounce, 1000);
@@ -166,6 +167,7 @@ function Lookup(props) {
aria-label={t`Lookup modal`}
isOpen={isModalOpen}
onClose={closeModal}
description={state?.selectedItems?.length > 0 && modalDescription}
actions={[
<Button
ouiaId="modal-select-button"
@@ -204,6 +206,7 @@ const Item = shape({
Lookup.propTypes = {
id: string,
header: string,
modalDescription: string,
onChange: func.isRequired,
value: oneOfType([Item, arrayOf(Item)]),
multiple: bool,
@@ -224,6 +227,7 @@ Lookup.defaultProps = {
value: null,
multiple: false,
required: false,
modalDescription: '',
onBlur: () => {},
renderItemChip: ({ item, removeItem, canDelete }) => (
<Chip
@@ -10,7 +10,7 @@ import {
} from 'prop-types';
import styled from 'styled-components';
import { t } from '@lingui/macro';
import SelectedList from '../SelectedList';
import { SelectedList, DraggableSelectedList } from '../SelectedList';
import CheckboxListItem from '../CheckboxListItem';
import DataListToolbar from '../DataListToolbar';
import { QSConfig, SearchColumns, SortColumns } from '../../types';
@@ -23,28 +23,39 @@ const ModalList = styled.div`
`;
function OptionsList({
value,
contentError,
options,
optionCount,
searchColumns,
sortColumns,
searchableKeys,
relatedSearchableKeys,
multiple,
deselectItem,
displayKey,
draggable,
header,
isLoading,
multiple,
name,
optionCount,
options,
qsConfig,
readOnly,
selectItem,
deselectItem,
relatedSearchableKeys,
renderItemChip,
isLoading,
displayKey,
searchColumns,
searchableKeys,
selectItem,
sortColumns,
sortSelectedItems,
value,
}) {
return (
<ModalList>
{value.length > 0 && (
let selectionPreview = null;
if (value.length > 0) {
if (draggable) {
selectionPreview = (
<DraggableSelectedList
onRemove={deselectItem}
onRowDrag={sortSelectedItems}
selected={value}
/>
);
} else {
selectionPreview = (
<SelectedList
label={t`Selected`}
selected={value}
@@ -53,7 +64,13 @@ function OptionsList({
renderItemChip={renderItemChip}
displayKey={displayKey}
/>
)}
);
}
}
return (
<ModalList>
{selectionPreview}
<PaginatedTable
contentError={contentError}
items={options}
@@ -99,6 +116,7 @@ const Item = shape({
OptionsList.propTypes = {
deselectItem: func.isRequired,
displayKey: string,
draggable: bool,
multiple: bool,
optionCount: number.isRequired,
options: arrayOf(Item).isRequired,
@@ -110,6 +128,7 @@ OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
};
OptionsList.defaultProps = {
draggable: false,
multiple: false,
renderItemChip: null,
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,
});
await Promise.all(
groupsToAssociate
.map(id => OrganizationsAPI.associateInstanceGroup(response.id, id))
.concat(
values.galaxy_credentials.map(({ id: credId }) =>
OrganizationsAPI.associateGalaxyCredential(response.id, credId)
)
)
groupsToAssociate.map(id =>
OrganizationsAPI.associateInstanceGroup(response.id, id)
)
);
/* 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}`);
} catch (error) {
setFormError(error);
@@ -3,9 +3,14 @@ import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { CardBody } from '../../../components/Card';
import { OrganizationsAPI } from '../../../api';
import { getAddedAndRemoved } from '../../../util/lists';
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 }) {
const detailsUrl = `/organizations/${organization.id}/details`;
const history = useHistory();
@@ -17,43 +22,39 @@ function OrganizationEdit({ organization }) {
groupsToDisassociate
) => {
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, {
...values,
default_environment: values.default_environment?.id || null,
});
await Promise.all(
groupsToAssociate
.map(id =>
OrganizationsAPI.associateInstanceGroup(organization.id, id)
)
.concat(
addedCredentialIds.map(id =>
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
)
)
groupsToAssociate.map(id =>
OrganizationsAPI.associateInstanceGroup(organization.id, id)
)
);
await Promise.all(
groupsToDisassociate
.map(id =>
OrganizationsAPI.disassociateInstanceGroup(organization.id, id)
)
.concat(
removedCredentialIds.map(id =>
OrganizationsAPI.disassociateGalaxyCredential(organization.id, id)
)
)
groupsToDisassociate.map(id =>
OrganizationsAPI.disassociateInstanceGroup(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);
} catch (error) {
setFormError(error);
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Formik, useField, useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import { t, Trans } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import { OrganizationsAPI } from '../../../api';
@@ -106,7 +106,20 @@ function OrganizationFormFields({
onChange={handleCredentialUpdate}
value={galaxyCredentialsField.value}
multiple
draggable
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>
</>
}
/>
</>
);