From 2cc45187e6b9378c1696f2f0ede5847281a0995a Mon Sep 17 00:00:00 2001
From: Nan Yu
Date: Thu, 31 Dec 2020 12:51:12 -0800
Subject: [PATCH] feat: reordering documents in collection (#1722)
* tweaking effect details
* wrap work on this feature
* adds correct color to drop cursor
* simplify logic for early return
* much better comment so Tom doesn't fire me
* feat: Allow changing sort order of collections
* refactor: Move validation to model
feat: Make custom order the default (in prep for dnd)
* feat: Add sort choice to edit collection modal
fix: Improved styling of generic InputSelect
* fix: Vertical space left after removing previous collection description
* chore: Tweak language, menu contents, add auto-disclosure on sub menus
* only show drop-to-reorder cursor when sort is set to manual
Co-authored-by: Tom Moor
---
app/components/DropToImport.js | 6 +-
.../DropdownMenu/DropdownMenuItems.js | 18 ++-
app/components/InputSelect.js | 3 +-
.../Sidebar/components/CollectionLink.js | 27 +++-
.../Sidebar/components/DocumentLink.js | 152 +++++++++++-------
.../Sidebar/components/DropCursor.js | 42 +++++
.../Sidebar/components/SidebarLink.js | 10 +-
app/menus/CollectionMenu.js | 47 +++++-
app/menus/DocumentMenu.js | 2 +-
app/models/Collection.js | 9 +-
app/scenes/Collection.js | 7 +-
app/scenes/CollectionEdit.js | 36 ++++-
app/stores/DocumentsStore.js | 4 +-
server/api/collections.js | 36 +++--
server/api/collections.test.js | 43 +++++
server/commands/documentMover.js | 28 +++-
server/middlewares/validation.js | 2 +-
.../20201230031607-collection-sort.js | 14 ++
server/models/Collection.js | 30 +++-
server/models/Team.js | 1 +
server/presenters/collection.js | 23 ++-
shared/i18n/locales/en_US/translation.json | 4 +-
22 files changed, 435 insertions(+), 109 deletions(-)
create mode 100644 app/components/Sidebar/components/DropCursor.js
create mode 100644 server/migrations/20201230031607-collection-sort.js
diff --git a/app/components/DropToImport.js b/app/components/DropToImport.js
index f5dc68e6..bdb33dbd 100644
--- a/app/components/DropToImport.js
+++ b/app/components/DropToImport.js
@@ -87,7 +87,11 @@ class DropToImport extends React.Component {
isDragAccept,
isDragReject,
}) => (
-
+
{this.isImporting && }
{this.props.children}
diff --git a/app/components/DropdownMenu/DropdownMenuItems.js b/app/components/DropdownMenu/DropdownMenuItems.js
index b8f33cd2..3752c4ec 100644
--- a/app/components/DropdownMenu/DropdownMenuItems.js
+++ b/app/components/DropdownMenu/DropdownMenuItems.js
@@ -1,6 +1,9 @@
// @flow
+import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
+import styled from "styled-components";
+import Flex from "components/Flex";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
@@ -9,18 +12,21 @@ type MenuItem =
title: React.Node,
to: string,
visible?: boolean,
+ selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise,
visible?: boolean,
+ selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
+ selected?: boolean,
disabled?: boolean,
|}
| {|
@@ -45,6 +51,10 @@ type Props = {|
items: MenuItem[],
|};
+const Disclosure = styled(ExpandedIcon)`
+ transform: rotate(270deg);
+`;
+
export default function DropdownMenuItems({ items }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
@@ -71,6 +81,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
to={item.to}
key={index}
disabled={item.disabled}
+ selected={item.selected}
>
{item.title}
@@ -83,6 +94,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
href={item.href}
key={index}
disabled={item.disabled}
+ selected={item.selected}
target="_blank"
>
{item.title}
@@ -95,6 +107,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
{item.title}
@@ -108,7 +121,10 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
style={item.style}
label={
- {item.title}
+
+ {item.title}
+
+
}
hover={item.hover}
diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js
index e9e6cdc5..d53bbf5c 100644
--- a/app/components/InputSelect.js
+++ b/app/components/InputSelect.js
@@ -9,7 +9,8 @@ import { Outline, LabelText } from "./Input";
const Select = styled.select`
border: 0;
flex: 1;
- padding: 8px 12px;
+ padding: 8px 0;
+ margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js
index 9be2a589..6a2759ca 100644
--- a/app/components/Sidebar/components/CollectionLink.js
+++ b/app/components/Sidebar/components/CollectionLink.js
@@ -8,6 +8,7 @@ import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport";
import DocumentLink from "./DocumentLink";
+import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
@@ -39,11 +40,13 @@ function CollectionLink({
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
+ const manualSort = collection.sort.field === "index";
- // Droppable
+ // Drop to re-parent
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
+ if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
},
@@ -51,14 +54,26 @@ function CollectionLink({
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
- isOver: !!monitor.isOver(),
+ isOver: !!monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
+ // Drop to reorder
+ const [{ isOverReorder }, dropToReorder] = useDrop({
+ accept: "document",
+ drop: async (item, monitor) => {
+ if (!collection) return;
+ documents.move(item.id, collection.id, undefined, 0);
+ },
+ collect: (monitor) => ({
+ isOverReorder: !!monitor.isOver(),
+ }),
+ });
+
return (
<>
-
+
+ {expanded && manualSort && (
+
+ )}
{expanded &&
- collection.documents.map((node) => (
+ collection.documents.map((node, index) => (
))}
>
diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js
index bc6e80e7..0f97d90f 100644
--- a/app/components/Sidebar/components/DocumentLink.js
+++ b/app/components/Sidebar/components/DocumentLink.js
@@ -9,6 +9,7 @@ import Collection from "models/Collection";
import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
+import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
@@ -23,16 +24,20 @@ type Props = {|
activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise
,
depth: number,
+ index: number,
+ parentId?: string,
|};
function DocumentLink({
node,
+ canUpdate,
collection,
activeDocument,
activeDocumentRef,
prefetchDocument,
depth,
- canUpdate,
+ index,
+ parentId,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -76,6 +81,14 @@ function DocumentLink({
}
}, [showChildren]);
+ // when the last child document is removed,
+ // also close the local folder state to closed
+ React.useEffect(() => {
+ if (expanded && !hasChildDocuments) {
+ setExpanded(false);
+ }
+ }, [expanded, hasChildDocuments]);
+
const handleDisclosureClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
@@ -108,6 +121,7 @@ function DocumentLink({
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
+ const manualSort = collection?.sort.field === "index";
// Draggable
const [{ isDragging }, drag] = useDrag({
@@ -120,77 +134,101 @@ function DocumentLink({
},
});
- // Droppable
- const [{ isOver, canDrop }, drop] = useDrop({
+ // Drop to re-parent
+ const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
accept: "document",
drop: async (item, monitor) => {
+ if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id, node.id);
},
canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id),
collect: (monitor) => ({
- isOver: !!monitor.isOver(),
- canDrop: monitor.canDrop(),
+ isOverReparent: !!monitor.isOver({ shallow: true }),
+ canDropToReparent: monitor.canDrop(),
+ }),
+ });
+
+ // Drop to reorder
+ const [{ isOverReorder }, dropToReorder] = useDrop({
+ accept: "document",
+ drop: async (item, monitor) => {
+ if (!collection) return;
+ if (item.id === node.id) return;
+
+ if (expanded) {
+ documents.move(item.id, collection.id, node.id, 0);
+ return;
+ }
+
+ documents.move(item.id, collection.id, parentId, index + 1);
+ },
+ collect: (monitor) => ({
+ isOverReorder: !!monitor.isOver(),
}),
});
return (
<>
-
-
-
-
- {hasChildDocuments && (
-
+
+
+
+
+ {hasChildDocuments && (
+
+ )}
+
- )}
-
- >
- }
- isActiveDrop={isOver && canDrop}
- depth={depth}
- exact={false}
- menuOpen={menuOpen}
- menu={
- document && !isMoving ? (
-
- setMenuOpen(true)}
- onClose={() => setMenuOpen(false)}
- />
-
- ) : undefined
- }
- />
-
-
-
-
+ >
+ }
+ isActiveDrop={isOverReparent && canDropToReparent}
+ depth={depth}
+ exact={false}
+ menuOpen={menuOpen}
+ menu={
+ document && !isMoving ? (
+
+ setMenuOpen(true)}
+ onClose={() => setMenuOpen(false)}
+ />
+
+ ) : undefined
+ }
+ />
+
+
+
+ {manualSort && (
+
+ )}
+
{expanded && !isDragging && (
<>
- {node.children.map((childNode) => (
+ {node.children.map((childNode, index) => (
))}
>
diff --git a/app/components/Sidebar/components/DropCursor.js b/app/components/Sidebar/components/DropCursor.js
new file mode 100644
index 00000000..2eb584cf
--- /dev/null
+++ b/app/components/Sidebar/components/DropCursor.js
@@ -0,0 +1,42 @@
+// @flow
+import * as React from "react";
+import styled, { withTheme } from "styled-components";
+import { type Theme } from "types";
+
+function DropCursor({
+ isActiveDrop,
+ innerRef,
+ theme,
+}: {
+ isActiveDrop: boolean,
+ innerRef: React.Ref,
+ theme: Theme,
+}) {
+ return ;
+}
+
+// transparent hover zone with a thin visible band vertically centered
+const Cursor = styled("div")`
+ opacity: ${(props) => (props.isOver ? 1 : 0)};
+ transition: opacity 150ms;
+
+ position: absolute;
+ z-index: 1;
+
+ width: 100%;
+ height: 14px;
+ bottom: -7px;
+ background: transparent;
+
+ ::after {
+ background: ${(props) => props.theme.slateDark};
+ position: absolute;
+ top: 6px;
+ content: "";
+ height: 2px;
+ border-radius: 2px;
+ width: 100%;
+ }
+`;
+
+export default withTheme(DropCursor);
diff --git a/app/components/Sidebar/components/SidebarLink.js b/app/components/Sidebar/components/SidebarLink.js
index f311b8fa..b31c68d0 100644
--- a/app/components/Sidebar/components/SidebarLink.js
+++ b/app/components/Sidebar/components/SidebarLink.js
@@ -48,16 +48,20 @@ function SidebarLink({
}, [depth]);
const activeStyle = {
- color: theme.text,
fontWeight: 600,
+ color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
+ const activeFontWeightOnly = {
+ fontWeight: 600,
+ };
+
return (
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
@@ -115,6 +120,7 @@ const StyledNavLink = styled(NavLink)`
svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
+ transition: fill 50ms
}
&:hover {
diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js
index d00fb4c5..4d29bb70 100644
--- a/app/menus/CollectionMenu.js
+++ b/app/menus/CollectionMenu.js
@@ -26,6 +26,7 @@ type Props = {
documents: DocumentsStore,
collection: Collection,
history: RouterHistory,
+ showSort?: boolean,
onOpen?: () => void,
onClose?: () => void,
t: TFunction,
@@ -70,6 +71,15 @@ class CollectionMenu extends React.Component {
}
};
+ handleChangeSort = (field: string) => {
+ return this.props.collection.save({
+ sort: {
+ field,
+ direction: "asc",
+ },
+ });
+ };
+
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionEdit = true;
@@ -112,6 +122,7 @@ class CollectionMenu extends React.Component {
documents,
collection,
position,
+ showSort,
onOpen,
onClose,
t,
@@ -147,12 +158,12 @@ class CollectionMenu extends React.Component {
items={[
{
title: t("New document"),
- visible: !!(collection && can.update),
+ visible: can.update,
onClick: this.onNewDocument,
},
{
title: t("Import document"),
- visible: !!(collection && can.update),
+ visible: can.update,
onClick: this.onImportDocument,
},
{
@@ -160,12 +171,12 @@ class CollectionMenu extends React.Component {
},
{
title: `${t("Edit")}…`,
- visible: !!(collection && can.update),
+ visible: can.update,
onClick: this.handleEditCollectionOpen,
},
{
title: `${t("Permissions")}…`,
- visible: !!(collection && can.update),
+ visible: can.update,
onClick: this.handleMembersModalOpen,
},
{
@@ -173,6 +184,34 @@ class CollectionMenu extends React.Component {
visible: !!(collection && can.export),
onClick: this.handleExportCollectionOpen,
},
+ {
+ type: "separator",
+ },
+ {
+ title: t("Sort in sidebar"),
+ visible: can.update && showSort,
+ hover: true,
+ style: {
+ left: 170,
+ position: "relative",
+ top: -40,
+ },
+ items: [
+ {
+ title: t("Alphabetical"),
+ onClick: () => this.handleChangeSort("title"),
+ selected: collection.sort.field === "title",
+ },
+ {
+ title: t("Manual sort"),
+ onClick: () => this.handleChangeSort("index"),
+ selected: collection.sort.field === "index",
+ },
+ ],
+ },
+ {
+ type: "separator",
+ },
{
title: `${t("Delete")}…`,
visible: !!(collection && can.delete),
diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js
index a28df456..31289f0b 100644
--- a/app/menus/DocumentMenu.js
+++ b/app/menus/DocumentMenu.js
@@ -200,7 +200,7 @@ class DocumentMenu extends React.Component {
onClick: this.handleRestore,
},
{
- title: `${t("Restore")}…`,
+ title: t("Restore"),
visible: !collection && !!can.restore,
style: {
left: -170,
diff --git a/app/models/Collection.js b/app/models/Collection.js
index 0a6c0a2a..30876264 100644
--- a/app/models/Collection.js
+++ b/app/models/Collection.js
@@ -1,5 +1,5 @@
// @flow
-import { pick } from "lodash";
+import { pick, trim } from "lodash";
import { action, computed, observable } from "mobx";
import BaseModel from "models/BaseModel";
import Document from "models/Document";
@@ -20,6 +20,7 @@ export default class Collection extends BaseModel {
createdAt: ?string;
updatedAt: ?string;
deletedAt: ?string;
+ sort: { field: string, direction: "asc" | "desc" };
url: string;
@computed
@@ -45,6 +46,11 @@ export default class Collection extends BaseModel {
return results;
}
+ @computed
+ get hasDescription(): string {
+ return !!trim(this.description, "\\").trim();
+ }
+
@action
updateDocument(document: Document) {
const travelDocuments = (documentList, path) =>
@@ -108,6 +114,7 @@ export default class Collection extends BaseModel {
"description",
"icon",
"private",
+ "sort",
]);
};
diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js
index 27f086a1..c9468f98 100644
--- a/app/scenes/Collection.js
+++ b/app/scenes/Collection.js
@@ -164,7 +164,7 @@ class CollectionScene extends React.Component {
>
)}
-
+
);
@@ -179,9 +179,10 @@ class CollectionScene extends React.Component {
const pinnedDocuments = this.collection
? documents.pinnedInCollection(this.collection.id)
: [];
- const hasPinnedDocuments = !!pinnedDocuments.length;
const collection = this.collection;
const collectionName = collection ? collection.name : "";
+ const hasPinnedDocuments = !!pinnedDocuments.length;
+ const hasDescription = collection ? collection.hasDescription : false;
return (
@@ -240,7 +241,7 @@ class CollectionScene extends React.Component {
{collection.name}
- {collection.description && (
+ {hasDescription && (
Loading…
}>
{
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
+ @observable sort: { field: string, direction: "asc" | "desc" } = this.props
+ .collection.sort;
@observable isSaving: boolean;
handleSubmit = async (ev: SyntheticEvent<*>) => {
@@ -41,6 +44,7 @@ class CollectionEdit extends React.Component {
icon: this.icon,
color: this.color,
private: this.private,
+ sort: this.sort,
});
this.props.onSubmit();
this.props.ui.showToast(t("The collection was updated"));
@@ -51,6 +55,14 @@ class CollectionEdit extends React.Component {
}
};
+ handleSortChange = (ev: SyntheticInputEvent) => {
+ const [field, direction] = ev.target.value.split(".");
+
+ if (direction === "asc" || direction === "desc") {
+ this.sort = { field, direction };
+ }
+ };
+
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
@@ -75,9 +87,10 @@ class CollectionEdit extends React.Component {