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
12 changed files with 329 additions and 70 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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