diff --git a/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js b/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js index 653cda5278..a8fdb2cad9 100644 --- a/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js +++ b/packages/core/admin/admin/src/content-manager/components/RelationInput/RelationInput.js @@ -2,6 +2,7 @@ import React, { useRef, useState, useMemo, useEffect } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { FixedSizeList as List } from 'react-window'; +import { useIntl } from 'react-intl'; import { ReactSelect } from '@strapi/helper-plugin'; import { Status } from '@strapi/design-system/Status'; @@ -12,6 +13,7 @@ import { FieldLabel, FieldError, FieldHint, Field } from '@strapi/design-system/ import { TextButton } from '@strapi/design-system/TextButton'; import { Typography } from '@strapi/design-system/Typography'; import { Tooltip } from '@strapi/design-system/Tooltip'; +import { VisuallyHidden } from '@strapi/design-system/VisuallyHidden'; import Cross from '@strapi/icons/Cross'; import Refresh from '@strapi/icons/Refresh'; @@ -22,6 +24,8 @@ import { RelationList } from './components/RelationList'; import { Option } from './components/Option'; import { RELATION_GUTTER, RELATION_ITEM_HEIGHT } from './constants'; +import { getTrad } from '../../utils'; + const LinkEllipsis = styled(Link)` white-space: nowrap; overflow: hidden; @@ -76,9 +80,13 @@ const RelationInput = ({ size, }) => { const [value, setValue] = useState(null); + const [overflow, setOverflow] = useState(''); + const [liveText, setLiveText] = useState(''); + const listRef = useRef(); const outerListRef = useRef(); - const [overflow, setOverflow] = useState(''); + + const { formatMessage } = useIntl(); const { data } = searchResults; @@ -213,12 +221,68 @@ const RelationInput = ({ onSearch(); }; + /** + * + * @param {number} newIndex + * @param {number} currentIndex + * + * @returns {void} + */ const handleUpdatePositionOfRelation = (newIndex, currentIndex) => { - if (onRelationReorder) { + if (onRelationReorder && newIndex >= 0 && newIndex < relations.length) { onRelationReorder(currentIndex, newIndex); + + const item = relations[currentIndex]; + setLiveText(`${item.mainField ?? item.id}. New position in list: ${getItemPos(newIndex)}`); } }; + /** + * + * @param {number} index + * @returns {string} + */ + const getItemPos = (index) => `${index + 1} of ${relations.length}`; + + /** + * + * @param {number} index + * @returns {void} + */ + const handleGrabItem = (index) => { + const item = relations[index]; + + setLiveText( + `${item.mainField ?? item.id}, grabbed. Current position in list: ${getItemPos( + index + )}. Press up and down arrow to change position, Spacebar to drop, Escape to cancel.` + ); + }; + + /** + * + * @param {number} index + * @returns {void} + */ + const handleDropItem = (index) => { + const item = relations[index]; + + setLiveText( + `${item.mainField ?? item.id}, dropped. Final position in list: ${getItemPos(index)}` + ); + }; + + /** + * + * @param {number} index + * @returns {void} + */ + const handleCancel = (index) => { + const item = relations[index]; + + setLiveText(`${item.mainField ?? item.id}, dropped. Re-order cancelled.`); + }; + return ( + + {formatMessage({ + id: getTrad('components.RelationInput.instructions'), + defaultMessage: `Press spacebar to grab and re-order`, + })} + + {liveText} { const { + ariaDescribedBy, disabled, + handleCancel, + handleDropItem, + handleGrabItem, labelDisconnectRelation, onRelationDisconnect, publicationStateTranslations, @@ -409,11 +488,11 @@ const ListItem = ({ data, index, style }) => { return ( { } + onCancel={handleCancel} + onDrop={handleDropItem} + onGrab={handleGrabItem} style={{ ...style, bottom: style.bottom ?? 0 + RELATION_GUTTER, height: style.height ?? 0 - RELATION_GUTTER, }} + updatePositionOfRelation={updatePositionOfRelation} > @@ -462,7 +545,11 @@ ListItem.defaultProps = { ListItem.propTypes = { data: PropTypes.shape({ + ariaDescribedBy: PropTypes.string.isRequired, disabled: PropTypes.bool.isRequired, + handleCancel: PropTypes.func, + handleDropItem: PropTypes.func, + handleGrabItem: PropTypes.func, labelDisconnectRelation: PropTypes.string.isRequired, onRelationDisconnect: PropTypes.func.isRequired, publicationStateTranslations: PropTypes.shape({ diff --git a/packages/core/admin/admin/src/content-manager/components/RelationInput/components/RelationItem.js b/packages/core/admin/admin/src/content-manager/components/RelationInput/components/RelationItem.js index 524cf617c3..f3481ce526 100644 --- a/packages/core/admin/admin/src/content-manager/components/RelationInput/components/RelationItem.js +++ b/packages/core/admin/admin/src/content-manager/components/RelationInput/components/RelationItem.js @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { useDrag, useDrop } from 'react-dnd'; @@ -10,6 +10,7 @@ import { IconButton } from '@strapi/design-system/IconButton'; import Drag from '@strapi/icons/Drag'; import { composeRefs } from '../../../utils'; +import { RELATION_GUTTER } from '../constants'; const ChildrenWrapper = styled(Flex)` width: 100%; @@ -20,6 +21,7 @@ const ChildrenWrapper = styled(Flex)` const RELATION_ITEM_DRAG_TYPE = 'RelationItem'; export const RelationItem = ({ + ariaDescribedBy, children, canDrag, disabled, @@ -27,9 +29,13 @@ export const RelationItem = ({ style, id, index, + onCancel, + onDropItem, + onGrabItem, updatePositionOfRelation, ...props }) => { + const [isSelected, setIsSelected] = useState(false); const relationRef = useRef(null); const [{ handlerId }, dropRef] = useDrop({ @@ -85,8 +91,84 @@ export const RelationItem = ({ const composedRefs = composeRefs(relationRef, dragRef); + /** + * @type {(movement: 'UP' | 'DOWN') => void})} + */ + const handleMove = (movement) => { + if (!isSelected) { + return; + } + + if (movement === 'UP') { + updatePositionOfRelation(index - 1, index); + } else if (movement === 'DOWN') { + updatePositionOfRelation(index + 1, index); + } + }; + + const handleDragClick = () => { + if (isSelected) { + if (onDropItem) { + onDropItem(index); + } + setIsSelected(false); + } else { + if (onGrabItem) { + onGrabItem(index); + } + setIsSelected(true); + } + }; + + const handleCancel = () => { + setIsSelected(false); + + if (onCancel) { + onCancel(index); + } + }; + + /** + * @type {React.KeyboardEventHandler} + */ + const handleKeyDown = (e) => { + if (e.key === 'Tab' && !isSelected) { + return; + } + + e.preventDefault(); + + switch (e.key) { + case ' ': + handleDragClick(); + break; + + case 'Escape': + handleCancel(); + break; + + case 'ArrowDown': + case 'ArrowRight': + handleMove('DOWN'); + break; + + case 'ArrowUp': + case 'ArrowLeft': + handleMove('UP'); + break; + + default: + } + }; + return ( - + {isDragging ? ( ) : ( {/* TODO: swap this out for using children when DS is updated */} {canDrag ? ( - } /> + } + onClick={handleDragClick} + onKeyDown={handleKeyDown} + /> ) : null} {children} {endAction && {endAction}} @@ -129,20 +218,28 @@ export const RelationItem = ({ }; RelationItem.defaultProps = { + ariaDescribedBy: '', canDrag: false, disabled: false, endAction: undefined, + onCancel: undefined, + onDropItem: undefined, + onGrabItem: undefined, style: undefined, updatePositionOfRelation: undefined, }; RelationItem.propTypes = { + ariaDescribedBy: PropTypes.string, canDrag: PropTypes.bool, children: PropTypes.node.isRequired, disabled: PropTypes.bool, endAction: PropTypes.node, id: PropTypes.number.isRequired, index: PropTypes.number.isRequired, + onCancel: PropTypes.func, + onDropItem: PropTypes.func, + onGrabItem: PropTypes.func, style: PropTypes.shape({ height: PropTypes.number, left: PropTypes.number, diff --git a/packages/core/admin/admin/src/content-manager/components/RelationInput/components/tests/RelationItem.test.js b/packages/core/admin/admin/src/content-manager/components/RelationInput/components/tests/RelationItem.test.js index a14030aeb9..1396d4bfaf 100644 --- a/packages/core/admin/admin/src/content-manager/components/RelationInput/components/tests/RelationItem.test.js +++ b/packages/core/admin/admin/src/content-manager/components/RelationInput/components/tests/RelationItem.test.js @@ -1,18 +1,23 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { RelationItem } from '../RelationItem'; -const setup = ({ endAction }) => +const setup = ({ endAction, testingDnd = false, ...props }) => render( - + First relation + {testingDnd ? ( + + Second relation + + ) : null} ); @@ -29,4 +34,58 @@ describe('Content-Manager || RelationInput || RelationItem', () => { expect(getByText('end action here')).toBeInTheDocument(); }); + + describe('Reordering relations', () => { + it('should not move with arrow keys if the button is not pressed first', () => { + const updatePositionOfRelationMock = jest.fn(); + + setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true }); + + const [draggedItem] = screen.getAllByLabelText('Drag'); + + fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' }); + + expect(updatePositionOfRelationMock).not.toBeCalled(); + }); + + it('should move with the arrow keys if the button has been activated first', () => { + const updatePositionOfRelationMock = jest.fn(); + + setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true }); + + const [draggedItem] = screen.getAllByLabelText('Drag'); + + fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); + fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' }); + + expect(updatePositionOfRelationMock).toBeCalledWith(1, 0); + }); + + it('should not fire reorderRelation if the item is trying to go up and is the first item', () => { + const updatePositionOfRelationMock = jest.fn(); + + setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true }); + + const [draggedItem] = screen.getAllByLabelText('Drag'); + + fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); + fireEvent.keyDown(draggedItem, { key: 'ArrowUp', code: 'ArrowUp' }); + + expect(updatePositionOfRelationMock).not.toBeCalled(); + }); + + it('should exit drag and drop mode when the escape key is pressed', () => { + const updatePositionOfRelationMock = jest.fn(); + + setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true }); + + const [draggedItem] = screen.getAllByLabelText('Drag'); + + fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' }); + fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' }); + fireEvent.keyDown(draggedItem, { key: 'ArrowUp', code: 'ArrowUp' }); + + expect(updatePositionOfRelationMock).not.toBeCalled(); + }); + }); }); diff --git a/packages/core/admin/admin/src/content-manager/components/RelationInputDataManager/tests/RelationInputDataManger.test.js b/packages/core/admin/admin/src/content-manager/components/RelationInputDataManager/tests/RelationInputDataManger.test.js index 52343e2b68..f8050e1123 100644 --- a/packages/core/admin/admin/src/content-manager/components/RelationInputDataManager/tests/RelationInputDataManger.test.js +++ b/packages/core/admin/admin/src/content-manager/components/RelationInputDataManager/tests/RelationInputDataManger.test.js @@ -389,7 +389,7 @@ describe('RelationInputDataManager', () => { ); }); - test('Reorder an entity', () => { + test('reordering an entity', () => { const { reorderRelation } = useCMEditViewDataManager(); setup(); diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 2f40be8739..4ebfdcf469 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -554,6 +554,7 @@ "content-manager.components.LeftMenu.single-types": "Single Types", "content-manager.components.LimitSelect.itemsPerPage": "Items per page", "content-manager.components.NotAllowedInput.text": "No permissions to see this field", + "content-manager.components.RelationInput.instructions": "Press spacebar to grab and re-order", "content-manager.components.RepeatableComponent.error-message": "The component(s) contain error(s)", "content-manager.components.Search.placeholder": "Search for an entry...", "content-manager.components.Select.draft-info-title": "State: Draft",