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 17, 2024
1 parent f18b830 commit 9d53967
Show file tree
Hide file tree
Showing 16 changed files with 471 additions and 223 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"
}
107 changes: 99 additions & 8 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 @@ -148,13 +175,14 @@ const LinkField = ({
const renderLinks = () => {
const links = [];

for (const linkID of linkIDs) {
// 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 +195,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 state via setLinkIDs so 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);
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
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';
import LinkModalContainer from 'containers/LinkModalContainer';

/**
Expand Down
48 changes: 39 additions & 9 deletions client/src/components/LinkPicker/LinkPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,28 @@
justify-content: space-between;
position: relative;

&:not(:last-child) {
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-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;

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

&:not(:first-child) {
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
&--is-last,
&--is-sorting {
border-bottom: 1px solid $gray-200;
border-bottom-left-radius: 0.23rem;
border-bottom-right-radius: 0.23rem;
}

&:hover, &:focus {
background: $gray-100;
text-decoration: none;
color: inherit;
}
Expand Down Expand Up @@ -126,6 +134,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

0 comments on commit 9d53967

Please sign in to comment.