mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 02:19:58 -03:30
Add draggable selected list to galaxy credential lookup
This commit is contained in:
parent
673f722b71
commit
9fddf7c5cf
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user