Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH MutliLinkField sorting #166

Merged
merged 1 commit into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
}
93 changes: 88 additions & 5 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 @@ -46,6 +51,17 @@ const LinkField = ({
const [data, setData] = useState({});
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;
Expand Down Expand Up @@ -73,13 +89,17 @@ 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]);

/**
* Unset the editing ID when the editing modal is closed
Expand Down Expand Up @@ -148,13 +168,13 @@ const LinkField = ({
const renderLinks = () => {
const links = [];

for (const linkID of linkIDs) {
for (let i = 0; i < linkIDs.length; i++) {
const linkID = linkIDs[i];
// Only render items we have data for
const linkData = data[linkID];
if (!linkData) {
continue;
}

const type = types.hasOwnProperty(data[linkID]?.typeKey) ? types[data[linkID]?.typeKey] : {};
links.push(<LinkPickerTitle
key={linkID}
Expand All @@ -167,27 +187,90 @@ const LinkField = ({
onDelete={onDelete}
onClick={() => { setEditingID(linkID); }}
canDelete={data[linkID]?.canDelete ? true : false}
isMulti={isMulti}
isFirst={i === 0}
isLast={i === linkIDs.length - 1}
isSorting={isSorting}
/>);
}
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) {
setIsSorting(false);
return;
}
// Update the local entwine state via onChange so that sorting looks correct on the frontend
// and make a request to the server to update the database
// Note that setIsSorting is not set to true here, instead it's set in the useEffect() block
// higher up in this file
const fromIndex = linkIDs.indexOf(active.id);
const toIndex = linkIDs.indexOf(over.id);
const newLinkIDs = arrayMove(linkIDs, fromIndex, toIndex);
onChange(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
1 change: 0 additions & 1 deletion client/src/components/LinkPicker/LinkPicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import LinkPickerMenu from './LinkPickerMenu';
import LinkType from 'types/LinkType';
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved
import LinkModalContainer from 'containers/LinkModalContainer';

/**
Expand Down
55 changes: 42 additions & 13 deletions client/src/components/LinkPicker/LinkPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,14 @@
margin-right: 0;
justify-content: space-between;
position: relative;

&:not(:last-child) {
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}

&:not(:first-child) {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;

&:hover, &:focus {
background: $gray-100;
emteknetnz marked this conversation as resolved.
Show resolved Hide resolved
text-decoration: none;
color: inherit;
}
Expand Down Expand Up @@ -107,6 +100,20 @@
}
}

.link-picker__link--is-first,
.link-picker__link--is-sorting {
border-top: 1px solid $gray-200;
border-top-left-radius: 0.23rem;
border-top-right-radius: 0.23rem;
}

.link-picker__link--is-last,
.link-picker__link--is-sorting {
border-bottom: 1px solid $gray-200;
border-bottom-left-radius: 0.23rem;
border-bottom-right-radius: 0.23rem;
}

.link-picker__button {
display: flex;
align-items: center;
Expand All @@ -126,6 +133,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
55 changes: 43 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,56 @@ const LinkPickerTitle = ({
typeIcon,
onDelete,
onClick,
canDelete
canDelete,
isMulti,
isFirst,
isLast,
isSorting,
}) => {
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,
'link-picker__link--is-first': isFirst,
'link-picker__link--is-last': isLast,
'link-picker__link--is-sorting': isSorting,
'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 +109,10 @@ LinkPickerTitle.propTypes = {
onDelete: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired,
canDelete: PropTypes.bool.isRequired,
isMulti: PropTypes.bool.isRequired,
isFirst: PropTypes.bool.isRequired,
isLast: PropTypes.bool.isRequired,
isSorting: PropTypes.bool.isRequired,
};

export default LinkPickerTitle;
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ function makeProps(obj = {}) {
canDelete: true,
onDelete: () => {},
onClick: () => {},
isMulti: false,
isFirst: false,
isLast: false,
isSorting: false,
...obj
};
}
Expand Down
Loading