Add draggable selected list to galaxy credential lookup

This commit is contained in:
Marliana Lara 2021-06-22 17:19:13 -04:00 committed by Shane McDonald
parent 673f722b71
commit 9fddf7c5cf
No known key found for this signature in database
GPG Key ID: 6F374AF6E9EB9374
12 changed files with 329 additions and 70 deletions

View File

@ -63,6 +63,7 @@
"aria-labelledby",
"aria-hidden",
"aria-controls",
"aria-pressed",
"sortKey",
"ouiaId",
"credentialTypeNamespace",

View File

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

View File

@ -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,

View File

@ -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}
/>
)}
/>

View File

@ -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

View File

@ -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: [],

View File

@ -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;

View File

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

View File

@ -1 +1,2 @@
export { default } from './SelectedList';
export { default as SelectedList } from './SelectedList';
export { default as DraggableSelectedList } from './DraggableSelectedList';

View File

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

View File

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

View File

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