Skip to content

Commit

Permalink
ENH MutliLinkField sorting
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Jan 16, 2024
1 parent f18b830 commit 6b5f197
Show file tree
Hide file tree
Showing 15 changed files with 431 additions and 212 deletions.
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion client/lang/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ if (typeof(ss) === 'undefined' || typeof(ss.i18n) === 'undefined') {
"LinkField.CANNOT_CREATE_LINK": "Cannot create link",
"LinkField.FAILED_TO_LOAD_LINKS": "Failed to load links",
"LinkField.FAILED_TO_SAVE_LINK": "Failed to save link",
"LinkField.SAVE_RECORD_FIRST": "Cannot add links until the record has been saved"
"LinkField.SAVE_RECORD_FIRST": "Cannot add links until the record has been saved",
"LinkField.SORT_SUCCESS": "Updated link sort order",
"LinkField.SORT_ERROR": "Unable to sort links"
});
}
4 changes: 3 additions & 1 deletion client/lang/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@
"LinkField.CANNOT_CREATE_LINK": "Cannot create link",
"LinkField.FAILED_TO_LOAD_LINKS": "Failed to load links",
"LinkField.FAILED_TO_SAVE_LINK": "Failed to save link",
"LinkField.SAVE_RECORD_FIRST": "Cannot add links until the record has been saved"
"LinkField.SAVE_RECORD_FIRST": "Cannot add links until the record has been saved",
"LinkField.SORT_SUCCESS": "Updated link sort order",
"LinkField.SORT_ERROR": "Unable to sort links"
}
94 changes: 88 additions & 6 deletions client/src/components/LinkField/LinkField.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
import React, { useState, useEffect, createContext } from 'react';
import { bindActionCreators, compose } from 'redux';
import { connect } from 'react-redux';
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers';
import { injectGraphql } from 'lib/Injector';
import fieldHolder from 'components/FieldHolder/FieldHolder';
import LinkPicker from 'components/LinkPicker/LinkPicker';
import LinkPickerTitle from 'components/LinkPicker/LinkPickerTitle';
Expand All @@ -15,6 +19,7 @@ import PropTypes from 'prop-types';
import i18n from 'i18n';
import url from 'url';
import qs from 'qs';
import classnames from 'classnames';

export const LinkFieldContext = createContext(null);

Expand Down Expand Up @@ -44,17 +49,29 @@ const LinkField = ({
ownerRelation,
}) => {
const [data, setData] = useState({});
const [linkIDs, setLinkIDs] = useState(value);
const [editingID, setEditingID] = useState(0);
const [loading, setLoading] = useState(false);
const [forceFetch, setForceFetch] = useState(0);
const [isSorting, setIsSorting] = useState(false);
const [linksClassName, setLinksClassName] = useState(classnames({'link-picker-links': true}));

const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10
}
})
);

// Ensure we have a valid array
let linkIDs = value;
// let linkIDs = value;
if (!Array.isArray(linkIDs)) {
if (typeof linkIDs === 'number' && linkIDs != 0) {
linkIDs = [linkIDs];
setLinkIDs([linkIDs]);
}
if (!linkIDs) {
linkIDs = [];
setLinkIDs([]);
}
}

Expand All @@ -73,13 +90,23 @@ const LinkField = ({
.then(responseJson => {
setData(responseJson);
setLoading(false);
// isSorting is set to true on drag start and only set to false here to prevent
// the loading indicator for flickering
setIsSorting(false);
})
.catch(() => {
actions.toasts.error(i18n._t('LinkField.FAILED_TO_LOAD_LINKS', 'Failed to load links'))
setLoading(false);
setIsSorting(false);
});
}
}, [editingID, value && value.length]);
}, [editingID, value && value.length, forceFetch]);

// Force a re-render if the value prop (and thus linkIDs) was not an array
// this re-render needs to have all of the initial hooks call or there will be an error
if (!Array.isArray(linkIDs)) {
return;
}

/**
* Unset the editing ID when the editing modal is closed
Expand Down Expand Up @@ -167,27 +194,82 @@ const LinkField = ({
onDelete={onDelete}
onClick={() => { setEditingID(linkID); }}
canDelete={data[linkID]?.canDelete ? true : false}
isMulti={isMulti}
/>);
}
return links;
};

const handleDragStart = (event) => {
setLinksClassName(classnames({
'link-picker__links': true,
'link-picker__links--dragging': true,
}));
setIsSorting(true);
}

/**
* Drag and drop handler for MultiLinkField's
*/
const handleDragEnd = (event) => {
const {active, over} = event;
setLinksClassName(classnames({
'link-picker__links': true,
'link-picker__links--dragging': false,
}));
if (active.id === over.id) {
return;
}
const fromIndex = linkIDs.indexOf(active.id);
const toIndex = linkIDs.indexOf(over.id);
const newLinkIDs = arrayMove(linkIDs, fromIndex, toIndex);
setLinkIDs(newLinkIDs);
let endpoint = `${Config.getSection(section).form.linkForm.sortUrl}`;
// CSRF token 'X-SecurityID' headers needs to be present
backend.post(endpoint, { newLinkIDs }, { 'X-SecurityID': Config.get('SecurityID') })
.then(async () => {
onChange(newLinkIDs);
actions.toasts.success(i18n._t('LinkField.SORT_SUCCESS', 'Updated link sort order'));
// Force a rerender so that links are retched so that versionState badges are up to date
setForceFetch(forceFetch + 1);
})
.catch(() => {
actions.toasts.error(i18n._t('LinkField.SORT_ERROR', 'Failed to sort links'));
});
}

const saveRecordFirst = ownerID === 0;
const renderPicker = !saveRecordFirst && (isMulti || Object.keys(data).length === 0);
const renderModal = !saveRecordFirst && Boolean(editingID);
const saveRecordFirstText = i18n._t('LinkField.SAVE_RECORD_FIRST', 'Cannot add links until the record has been saved');
const links = renderLinks();

return <LinkFieldContext.Provider value={{ ownerID, ownerClass, ownerRelation, actions, loading }}>
<div className="link-field__container">
{ saveRecordFirst && <div className="link-field__save-record-first">{saveRecordFirstText}</div>}
{ loading && !saveRecordFirst && <Loading containerClass="link-field__loading"/> }
{ loading && !isSorting && !saveRecordFirst && <Loading containerClass="link-field__loading"/> }
{ renderPicker && <LinkPicker
onModalSuccess={onModalSuccess}
onModalClosed={onModalClosed}
types={types}
canCreate={canCreate}
/> }
<div> { renderLinks() } </div>
{ isMulti && <div className={linksClassName}>
<DndContext modifiers={[restrictToVerticalAxis, restrictToParentElement]}
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={linkIDs}
strategy={verticalListSortingStrategy}
>
{links}
</SortableContext>
</DndContext>
</div> }
{ !isMulti && <div>{links}</div>}
{ renderModal && <LinkModalContainer
types={types}
typeKey={data[editingID]?.typeKey}
Expand Down
23 changes: 22 additions & 1 deletion client/src/components/LinkPicker/LinkPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
}

&:hover, &:focus {
background: $gray-100;
text-decoration: none;
color: inherit;
}
Expand Down Expand Up @@ -126,6 +125,28 @@
}
}

.link-picker__drag-handle {
display: none;
left: 5px;
position: absolute;
z-index: 100;

&:hover {
cursor: grab;
}
}

.link-picker__link:hover {
.link-picker__drag-handle {
display: block;
}
}

// This selector ensures the cursor does not flicker between grabbing and pointer when sorting
.link-picker__links--dragging * {
cursor: grabbing !important;
}

.link-picker__link-detail {
flex-grow: 1;
width: 100%;
Expand Down
46 changes: 34 additions & 12 deletions client/src/components/LinkPicker/LinkPickerTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import classnames from 'classnames';
import i18n from 'i18n';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import {Button} from 'reactstrap';
import { LinkFieldContext } from 'components/LinkField/LinkField';
import { Button } from 'reactstrap';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

const stopPropagation = (fn) => (e) => {
e.nativeEvent.stopImmediatePropagation();
Expand Down Expand Up @@ -39,31 +41,50 @@ const LinkPickerTitle = ({
typeIcon,
onDelete,
onClick,
canDelete
canDelete,
isMulti,
}) => {
const { loading } = useContext(LinkFieldContext);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({id});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const classes = {
'link-picker__link': true,
'form-control': true,
};
if (versionState) {
classes[` link-picker__link--${versionState}`] = true;
classes[`link-picker__link--${versionState}`] = true;
}
const className = classnames(classes);
const deleteText = ['unversioned', 'unsaved'].includes(versionState)
? i18n._t('LinkField.DELETE', 'Delete')
: i18n._t('LinkField.ARCHIVE', 'Archive');
return <div className={className}>
return <div
className={className}
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
{ isMulti && <div className="link-picker__drag-handle"><i className="font-icon-drag-handle"></i></div> }
<Button disabled={loading} className={`link-picker__button ${typeIcon}`} color="secondary" onClick={stopPropagation(onClick)}>
<div className="link-picker__link-detail">
<div className="link-picker__title">
<span className="link-picker__title-text">{title}</span>
{getVersionedBadge(versionState)}
</div>
<small className="link-picker__type">
{typeTitle}:&nbsp;
<span className="link-picker__url">{description}</span>
</small>
<div className="link-picker__title">
<span className="link-picker__title-text">{title}</span>
{getVersionedBadge(versionState)}
</div>
<small className="link-picker__type">
{typeTitle}:&nbsp;
<span className="link-picker__url">{description}</span>
</small>
</div>
</Button>
{canDelete &&
Expand All @@ -82,6 +103,7 @@ LinkPickerTitle.propTypes = {
onDelete: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired,
canDelete: PropTypes.bool.isRequired,
isMulti: PropTypes.bool.isRequired,
};

export default LinkPickerTitle;
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function makeProps(obj = {}) {
canDelete: true,
onDelete: () => {},
onClick: () => {},
isMulti: false,
...obj
};
}
Expand Down
13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
{
"name": "silverstripe-link",
"name": "silverstripe-linkfield",
"version": "0.0.0",
"description": "Link management for the SilverStripe CMS",
"main": "./client/src/boot/index.js",
"license": "BSD-3-Clause",
"repository": {
"type": "git",
"url": "git+https://github.com/silverstripe/silverstripe-link.git"
"url": "git+https://github.com/silverstripe/silverstripe-linkfield.git"
},
"homepage": "https://github.com/silverstripe/silverstripNe-link",
"homepage": "https://github.com/silverstripe/silverstripNe-linkfield",
"bugs": {
"url": "https://github.com/silverstripe/silverstripe-link/issues"
"url": "https://github.com/silverstripe/silverstripe-linkfield/issues"
},
"author": "SilverStripe Ltd",
"engines": {
Expand Down Expand Up @@ -61,14 +61,15 @@
},
"dependencies": {
"@apollo/client": "^3.7.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"bootstrap": "^4.6.2",
"classnames": "^2.2.5",
"core-js": "^3.26.0",
"prop-types": "^15.8.1",
"qs": "^6.11.0",
"react": "^18.2.0",
"react-dnd": "^5.0.0",
"react-dnd-html5-backend": "^5.0.1",
"react-dom": "^18.2.0",
"react-redux": "^8.0.4",
"react-router": "^6.4.2",
Expand Down
Loading

0 comments on commit 6b5f197

Please sign in to comment.