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 {
- {t( - "You can edit the name and other details at any time, however doing so often might confuse your team mates." - )} + + You can edit the name and other details at any time, however doing + so often might confuse your team mates. + { minHeight={68} maxHeight={200} /> + { checked={this.private} /> - {t( - "A private collection will only be visible to invited team members." - )} + + A private collection will only be visible to invited team members. +