Skip to content

Commit

Permalink
chore(deps): replace react-sortable-hoc with dnd-kit (#6860)
Browse files Browse the repository at this point in the history
* chore: replace react-sortable-hoc with dnd-kit in file control

* chore: update yarn.lock

* fix: allow multiple images with same path

* style: cleanup console logs

* chore: replace sortable hoc on list control

* test: update snapshots

* chore: replace dnd implementation in relation control

* chore: add dnd-kit deps to appropriate packages

* chore: harmonize sensors and clean up

* fix: remove sortable for single relation controls

* test: add restaurants entity for dnd implementations on one screen

* fix: fix file control when value is not an array or list

* refactor: merge listitem with parent, update snapshots

* test: update selector in field validation test
  • Loading branch information
demshy authored Sep 6, 2023
1 parent 45447ce commit 09665c0
Show file tree
Hide file tree
Showing 13 changed files with 683 additions and 565 deletions.
12 changes: 6 additions & 6 deletions cypress/utils/steps.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ function validateListFields({ name, description }) {
cy.contains('button', 'Save').click();
assertNotification(notifications.error.missingField);
assertFieldErrorStatus('Authors', colorError);
cy.get('div[class*=ListControl]')
cy.get('div[class*=SortableListItem]')
.eq(2)
.as('listControl');
assertFieldErrorStatus('Name', colorError, { scope: cy.get('@listControl') });
Expand Down Expand Up @@ -527,7 +527,7 @@ function validateNestedListFields() {
cy.contains('button', 'cities').click();
cy.contains('label', 'Cities')
.next()
.find('div[class*=ListControl]')
.find('div[class*=SortableListItem]')
.eq(2)
.as('secondCitiesListControl');
cy.get('@secondCitiesListControl')
Expand Down Expand Up @@ -560,22 +560,22 @@ function validateNestedListFields() {
// list control aliases
cy.contains('label', 'Hotel Locations')
.next()
.find('div[class*=ListControl]')
.find('div[class*=SortableListItem]')
.first()
.as('hotelLocationsListControl');
cy.contains('label', 'Cities')
.next()
.find('div[class*=ListControl]')
.find('div[class*=SortableListItem]')
.eq(0)
.as('firstCitiesListControl');
cy.contains('label', 'City Locations')
.next()
.find('div[class*=ListControl]')
.find('div[class*=SortableListItem]')
.eq(0)
.as('firstCityLocationsListControl');
cy.contains('label', 'Cities')
.next()
.find('div[class*=ListControl]')
.find('div[class*=SortableListItem]')
.eq(3)
.as('secondCityLocationsListControl');

Expand Down
23 changes: 23 additions & 0 deletions dev-test/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ collections: # A list of collections the CMS should be able to edit

- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }

- name: 'restaurants' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'Restaurants' # Used in the UI
label_singular: 'Restaurant' # Used in the UI, ie: "New Post"
description: >
Restaurants is an entry type used for testing galleries, relations and other widgets.
The tests must be written in such way that adding new fields does not affect previous flows.
folder: '_restaurants'
slug: '{{year}}-{{month}}-{{day}}-{{slug}}'
summary: '{{title}} -- {{year}}/{{month}}/{{day}}'
create: true # Allow users to create new documents in this collection
fields: # The fields each document in this collection have
- { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' }
- { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' }
- { name: 'gallery', widget: 'image', choose_url: true, media_library: {config: {multiple: true, max_files: 999}}}
- { name: 'post', widget: relation, collection: posts, multiple: true, search_fields: [ "title" ], display_fields: [ "title" ], value_field: "{{slug}}"}
- name: authors
label: Authors
label_singular: 'Author'
widget: list
fields:
- { label: 'Name', name: 'name', widget: 'string', hint: 'First and Last' }
- { label: 'Description', name: 'description', widget: 'markdown' }

- name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit
label: 'FAQ' # Used in the UI
folder: '_faqs'
Expand Down
1 change: 0 additions & 1 deletion packages/decap-cms-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"react-redux": "^7.2.0",
"react-router-dom": "^5.2.0",
"react-scroll-sync": "^0.9.0",
"react-sortable-hoc": "^2.0.0",
"react-split-pane": "^0.1.85",
"react-toastify": "^9.1.1",
"react-topbar-progress-indicator": "^4.0.0",
Expand Down
20 changes: 11 additions & 9 deletions packages/decap-cms-ui-default/src/ListItemTopBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,26 @@ const DragIconContainer = styled(TopBarButtonSpan)`
cursor: move;
`;

function DragHandle({ dragHandleHOC }) {
const Handle = dragHandleHOC(() => (
<DragIconContainer>
<Icon type="drag-handle" size="small" />
</DragIconContainer>
));
return <Handle />;
function DragHandle({ Wrapper, id }) {
return (
<Wrapper id={id}>
<DragIconContainer>
<Icon type="drag-handle" size="small" />
</DragIconContainer>
</Wrapper>
);
}

function ListItemTopBar({ className, collapsed, onCollapseToggle, onRemove, dragHandleHOC }) {
function ListItemTopBar(props) {
const { className, collapsed, onCollapseToggle, onRemove, dragHandle, id } = props;
return (
<TopBar className={className}>
{onCollapseToggle ? (
<TopBarButton onClick={onCollapseToggle}>
<Icon type="chevron" size="small" direction={collapsed ? 'right' : 'down'} />
</TopBarButton>
) : null}
{dragHandleHOC ? <DragHandle dragHandleHOC={dragHandleHOC} /> : null}
{dragHandle ? <DragHandle Wrapper={dragHandle} id={id} /> : null}
{onRemove ? (
<TopBarButton onClick={onRemove}>
<Icon type="close" size="small" />
Expand Down
6 changes: 4 additions & 2 deletions packages/decap-cms-widget-file/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
},
"dependencies": {
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"array-move": "4.0.0",
"common-tags": "^1.8.0",
"react-sortable-hoc": "^2.0.0"
"common-tags": "^1.8.0"
},
"peerDependencies": {
"@emotion/core": "^10.0.35",
Expand Down
127 changes: 95 additions & 32 deletions packages/decap-cms-widget-file/src/withFileControl.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,18 @@ import {
IconButton,
} from 'decap-cms-ui-default';
import { basename } from 'decap-cms-lib-util';
import { SortableContainer, SortableElement } from 'react-sortable-hoc';
import { arrayMoveImmutable as arrayMove } from 'array-move';
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToParentElement } from '@dnd-kit/modifiers';

const MAX_DISPLAY_LENGTH = 50;

Expand Down Expand Up @@ -64,9 +74,20 @@ function SortableImageButtons({ onRemove, onReplace }) {
);
}

const SortableImage = SortableElement(({ itemValue, getAsset, field, onRemove, onReplace }) => {
function SortableImage(props) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: props.id,
});

const style = {
transform: CSS.Transform.toString(transform),
transition,
};

const { itemValue, getAsset, field, onRemove, onReplace } = props;

return (
<div>
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<ImageWrapper sortable>
<Image src={getAsset(itemValue, field) || ''} />
</ImageWrapper>
Expand All @@ -77,32 +98,61 @@ const SortableImage = SortableElement(({ itemValue, getAsset, field, onRemove, o
></SortableImageButtons>
</div>
);
});

const SortableMultiImageWrapper = SortableContainer(
({ items, getAsset, field, onRemoveOne, onReplaceOne }) => {
return (
<div
css={css`
display: flex;
flex-wrap: wrap;
`}
}

function SortableMultiImageWrapper({
items,
getAsset,
field,
onSortEnd,
onRemoveOne,
onReplaceOne,
}) {
const activationConstraint = { distance: 4 };
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint }),
useSensor(TouchSensor, { activationConstraint }),
);

function handleSortEnd({ active, over }) {
onSortEnd({
oldIndex: items.findIndex(item => item.id === active.id),
newIndex: items.findIndex(item => item.id === over.id),
});
}

return (
<div
// eslint-disable-next-line react/no-unknown-property
css={css`
display: flex;
flex-wrap: wrap;
`}
>
<DndContext
modifiers={[restrictToParentElement]}
collisionDetection={closestCenter}
sensors={sensors}
onDragEnd={handleSortEnd}
>
{items.map((itemValue, index) => (
<SortableImage
key={`item-${itemValue}`}
index={index}
itemValue={itemValue}
getAsset={getAsset}
field={field}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
/>
))}
</div>
);
},
);
<SortableContext items={items}>
{items.map((item, index) => (
<SortableImage
key={item.id}
id={item.id}
index={index}
itemValue={item.value}
getAsset={getAsset}
field={field}
onRemove={onRemoveOne(index)}
onReplace={onReplaceOne(index)}
></SortableImage>
))}
</SortableContext>
</DndContext>
</div>
);
}

const FileLink = styled.a`
margin-bottom: 20px;
Expand Down Expand Up @@ -152,7 +202,20 @@ function sizeOfValue(value) {
}

function valueListToArray(value) {
return List.isList(value) ? value.toArray() : value;
return List.isList(value) ? value.toArray() : value ?? [];
}

function valueListToSortableArray(value) {
if (!isMultiple(value)) {
return value;
}

const valueArray = valueListToArray(value).map(value => ({
id: uuid(),
value,
}));

return valueArray;
}

const warnDeprecatedOptions = once(field =>
Expand Down Expand Up @@ -259,7 +322,7 @@ export default function withFileControl({ forImage } = {}) {
};

onRemoveOne = index => () => {
const { value } = this.props;
const value = valueListToArray(this.props.value);
value.splice(index, 1);
return this.props.onChange(sizeOfValue(value) > 0 ? [...value] : null);
};
Expand Down Expand Up @@ -346,11 +409,11 @@ export default function withFileControl({ forImage } = {}) {

renderImages = () => {
const { getAsset, value, field } = this.props;

const items = valueListToSortableArray(value);
if (isMultiple(value)) {
return (
<SortableMultiImageWrapper
items={value}
items={items}
onSortEnd={this.onSortEnd}
onRemoveOne={this.onRemoveOne}
onReplaceOne={this.onReplaceOne}
Expand Down
4 changes: 2 additions & 2 deletions packages/decap-cms-widget-image/src/ImagePreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ function StyledImageAsset({ getAsset, value, field }) {
function ImagePreviewContent(props) {
const { value, getAsset, field } = props;
if (Array.isArray(value) || List.isList(value)) {
return value.map(val => (
<StyledImageAsset key={val} value={val} getAsset={getAsset} field={field} />
return value.map((val, index) => (
<StyledImageAsset key={index} value={val} getAsset={getAsset} field={field} />
));
}
return <StyledImageAsset {...props} />;
Expand Down
4 changes: 3 additions & 1 deletion packages/decap-cms-widget-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
},
"dependencies": {
"react-sortable-hoc": "^2.0.0"
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2"
},
"peerDependencies": {
"@emotion/core": "^10.0.35",
Expand Down
Loading

0 comments on commit 09665c0

Please sign in to comment.