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 <tom.moor@gmail.com>
This commit is contained in:
Nan Yu
2020-12-31 12:51:12 -08:00
committed by GitHub
parent ba61091c4c
commit 2cc45187e6
22 changed files with 435 additions and 109 deletions

View File

@ -87,7 +87,11 @@ class DropToImport extends React.Component<Props> {
isDragAccept, isDragAccept,
isDragReject, isDragReject,
}) => ( }) => (
<DropzoneContainer {...getRootProps()} {...{ isDragActive }}> <DropzoneContainer
{...getRootProps()}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} /> <input {...getInputProps()} />
{this.isImporting && <LoadingIndicator />} {this.isImporting && <LoadingIndicator />}
{this.props.children} {this.props.children}

View File

@ -1,6 +1,9 @@
// @flow // @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components";
import Flex from "components/Flex";
import DropdownMenu from "./DropdownMenu"; import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem"; import DropdownMenuItem from "./DropdownMenuItem";
@ -9,18 +12,21 @@ type MenuItem =
title: React.Node, title: React.Node,
to: string, to: string,
visible?: boolean, visible?: boolean,
selected?: boolean,
disabled?: boolean, disabled?: boolean,
|} |}
| {| | {|
title: React.Node, title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>, onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean, visible?: boolean,
selected?: boolean,
disabled?: boolean, disabled?: boolean,
|} |}
| {| | {|
title: React.Node, title: React.Node,
href: string, href: string,
visible?: boolean, visible?: boolean,
selected?: boolean,
disabled?: boolean, disabled?: boolean,
|} |}
| {| | {|
@ -45,6 +51,10 @@ type Props = {|
items: MenuItem[], items: MenuItem[],
|}; |};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
`;
export default function DropdownMenuItems({ items }: Props): React.Node { export default function DropdownMenuItems({ items }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false); let filtered = items.filter((item) => item.visible !== false);
@ -71,6 +81,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
to={item.to} to={item.to}
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected}
> >
{item.title} {item.title}
</DropdownMenuItem> </DropdownMenuItem>
@ -83,6 +94,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
href={item.href} href={item.href}
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected}
target="_blank" target="_blank"
> >
{item.title} {item.title}
@ -95,6 +107,7 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
<DropdownMenuItem <DropdownMenuItem
onClick={item.onClick} onClick={item.onClick}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected}
key={index} key={index}
> >
{item.title} {item.title}
@ -108,7 +121,10 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
style={item.style} style={item.style}
label={ label={
<DropdownMenuItem disabled={item.disabled}> <DropdownMenuItem disabled={item.disabled}>
<Flex justify="space-between" align="center" auto>
{item.title} {item.title}
<Disclosure color="currentColor" />
</Flex>
</DropdownMenuItem> </DropdownMenuItem>
} }
hover={item.hover} hover={item.hover}

View File

@ -9,7 +9,8 @@ import { Outline, LabelText } from "./Input";
const Select = styled.select` const Select = styled.select`
border: 0; border: 0;
flex: 1; flex: 1;
padding: 8px 12px; padding: 8px 0;
margin: 0 12px;
outline: none; outline: none;
background: none; background: none;
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};

View File

@ -8,6 +8,7 @@ import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport"; import DropToImport from "components/DropToImport";
import DocumentLink from "./DocumentLink"; import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle"; import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink"; import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
@ -39,11 +40,13 @@ function CollectionLink({
const { documents, policies } = useStores(); const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId; const expanded = collection.id === ui.activeCollectionId;
const manualSort = collection.sort.field === "index";
// Droppable // Drop to re-parent
const [{ isOver, canDrop }, drop] = useDrop({ const [{ isOver, canDrop }, drop] = useDrop({
accept: "document", accept: "document",
drop: (item, monitor) => { drop: (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return; if (!collection) return;
documents.move(item.id, collection.id); documents.move(item.id, collection.id);
}, },
@ -51,14 +54,26 @@ function CollectionLink({
return policies.abilities(collection.id).update; return policies.abilities(collection.id).update;
}, },
collect: (monitor) => ({ collect: (monitor) => ({
isOver: !!monitor.isOver(), isOver: !!monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(), 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 ( return (
<> <>
<div ref={drop}> <div ref={drop} style={{ position: "relative" }}>
<DropToImport key={collection.id} collectionId={collection.id}> <DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLink <SidebarLink
key={collection.id} key={collection.id}
@ -88,10 +103,13 @@ function CollectionLink({
} }
></SidebarLink> ></SidebarLink>
</DropToImport> </DropToImport>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div> </div>
{expanded && {expanded &&
collection.documents.map((node) => ( collection.documents.map((node, index) => (
<DocumentLink <DocumentLink
key={node.id} key={node.id}
node={node} node={node}
@ -100,6 +118,7 @@ function CollectionLink({
prefetchDocument={prefetchDocument} prefetchDocument={prefetchDocument}
canUpdate={canUpdate} canUpdate={canUpdate}
depth={1.5} depth={1.5}
index={index}
/> />
))} ))}
</> </>

View File

@ -9,6 +9,7 @@ import Collection from "models/Collection";
import Document from "models/Document"; import Document from "models/Document";
import DropToImport from "components/DropToImport"; import DropToImport from "components/DropToImport";
import Fade from "components/Fade"; import Fade from "components/Fade";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle"; import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink"; import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
@ -23,16 +24,20 @@ type Props = {|
activeDocumentRef?: (?HTMLElement) => void, activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise<void>, prefetchDocument: (documentId: string) => Promise<void>,
depth: number, depth: number,
index: number,
parentId?: string,
|}; |};
function DocumentLink({ function DocumentLink({
node, node,
canUpdate,
collection, collection,
activeDocument, activeDocument,
activeDocumentRef, activeDocumentRef,
prefetchDocument, prefetchDocument,
depth, depth,
canUpdate, index,
parentId,
}: Props) { }: Props) {
const { documents, policies } = useStores(); const { documents, policies } = useStores();
const { t } = useTranslation(); const { t } = useTranslation();
@ -76,6 +81,14 @@ function DocumentLink({
} }
}, [showChildren]); }, [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( const handleDisclosureClick = React.useCallback(
(ev: SyntheticEvent<>) => { (ev: SyntheticEvent<>) => {
ev.preventDefault(); ev.preventDefault();
@ -108,6 +121,7 @@ function DocumentLink({
const [menuOpen, setMenuOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id; const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
// Draggable // Draggable
const [{ isDragging }, drag] = useDrag({ const [{ isDragging }, drag] = useDrag({
@ -120,30 +134,51 @@ function DocumentLink({
}, },
}); });
// Droppable // Drop to re-parent
const [{ isOver, canDrop }, drop] = useDrop({ const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
accept: "document", accept: "document",
drop: async (item, monitor) => { drop: async (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return; if (!collection) return;
documents.move(item.id, collection.id, node.id); documents.move(item.id, collection.id, node.id);
}, },
canDrop: (item, monitor) => canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id), pathToNode && !pathToNode.includes(monitor.getItem().id),
collect: (monitor) => ({ collect: (monitor) => ({
isOver: !!monitor.isOver(), isOverReparent: !!monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(), 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 ( return (
<> <>
<div style={{ position: "relative" }}>
<Draggable <Draggable
key={node.id} key={node.id}
ref={drag} ref={drag}
$isDragging={isDragging} $isDragging={isDragging}
$isMoving={isMoving} $isMoving={isMoving}
> >
<div ref={drop}> <div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone"> <DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink <SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined} innerRef={isActiveDocument ? activeDocumentRef : undefined}
@ -167,7 +202,7 @@ function DocumentLink({
/> />
</> </>
} }
isActiveDrop={isOver && canDrop} isActiveDrop={isOverReparent && canDropToReparent}
depth={depth} depth={depth}
exact={false} exact={false}
menuOpen={menuOpen} menuOpen={menuOpen}
@ -187,10 +222,13 @@ function DocumentLink({
</DropToImport> </DropToImport>
</div> </div>
</Draggable> </Draggable>
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded && !isDragging && ( {expanded && !isDragging && (
<> <>
{node.children.map((childNode) => ( {node.children.map((childNode, index) => (
<ObservedDocumentLink <ObservedDocumentLink
key={childNode.id} key={childNode.id}
collection={collection} collection={collection}
@ -199,6 +237,8 @@ function DocumentLink({
prefetchDocument={prefetchDocument} prefetchDocument={prefetchDocument}
depth={depth + 1} depth={depth + 1}
canUpdate={canUpdate} canUpdate={canUpdate}
index={index}
parentId={node.id}
/> />
))} ))}
</> </>

View File

@ -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<any>,
theme: Theme,
}) {
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
}
// 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);

View File

@ -48,16 +48,20 @@ function SidebarLink({
}, [depth]); }, [depth]);
const activeStyle = { const activeStyle = {
color: theme.text,
fontWeight: 600, fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground, background: theme.sidebarItemBackground,
...style, ...style,
}; };
const activeFontWeightOnly = {
fontWeight: 600,
};
return ( return (
<StyledNavLink <StyledNavLink
$isActiveDrop={isActiveDrop} $isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? undefined : activeStyle} activeStyle={isActiveDrop ? activeFontWeightOnly : activeStyle}
style={active ? activeStyle : style} style={active ? activeStyle : style}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
@ -106,6 +110,7 @@ const StyledNavLink = styled(NavLink)`
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 4px 16px; padding: 4px 16px;
border-radius: 4px; border-radius: 4px;
transition: background 50ms, color 50ms;
background: ${(props) => background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"}; props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) => color: ${(props) =>
@ -115,6 +120,7 @@ const StyledNavLink = styled(NavLink)`
svg { svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")} ${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
transition: fill 50ms
} }
&:hover { &:hover {

View File

@ -26,6 +26,7 @@ type Props = {
documents: DocumentsStore, documents: DocumentsStore,
collection: Collection, collection: Collection,
history: RouterHistory, history: RouterHistory,
showSort?: boolean,
onOpen?: () => void, onOpen?: () => void,
onClose?: () => void, onClose?: () => void,
t: TFunction, t: TFunction,
@ -70,6 +71,15 @@ class CollectionMenu extends React.Component<Props> {
} }
}; };
handleChangeSort = (field: string) => {
return this.props.collection.save({
sort: {
field,
direction: "asc",
},
});
};
handleEditCollectionOpen = (ev: SyntheticEvent<>) => { handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault(); ev.preventDefault();
this.showCollectionEdit = true; this.showCollectionEdit = true;
@ -112,6 +122,7 @@ class CollectionMenu extends React.Component<Props> {
documents, documents,
collection, collection,
position, position,
showSort,
onOpen, onOpen,
onClose, onClose,
t, t,
@ -147,12 +158,12 @@ class CollectionMenu extends React.Component<Props> {
items={[ items={[
{ {
title: t("New document"), title: t("New document"),
visible: !!(collection && can.update), visible: can.update,
onClick: this.onNewDocument, onClick: this.onNewDocument,
}, },
{ {
title: t("Import document"), title: t("Import document"),
visible: !!(collection && can.update), visible: can.update,
onClick: this.onImportDocument, onClick: this.onImportDocument,
}, },
{ {
@ -160,12 +171,12 @@ class CollectionMenu extends React.Component<Props> {
}, },
{ {
title: `${t("Edit")}`, title: `${t("Edit")}`,
visible: !!(collection && can.update), visible: can.update,
onClick: this.handleEditCollectionOpen, onClick: this.handleEditCollectionOpen,
}, },
{ {
title: `${t("Permissions")}`, title: `${t("Permissions")}`,
visible: !!(collection && can.update), visible: can.update,
onClick: this.handleMembersModalOpen, onClick: this.handleMembersModalOpen,
}, },
{ {
@ -173,6 +184,34 @@ class CollectionMenu extends React.Component<Props> {
visible: !!(collection && can.export), visible: !!(collection && can.export),
onClick: this.handleExportCollectionOpen, 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")}`, title: `${t("Delete")}`,
visible: !!(collection && can.delete), visible: !!(collection && can.delete),

View File

@ -200,7 +200,7 @@ class DocumentMenu extends React.Component<Props> {
onClick: this.handleRestore, onClick: this.handleRestore,
}, },
{ {
title: `${t("Restore")}`, title: t("Restore"),
visible: !collection && !!can.restore, visible: !collection && !!can.restore,
style: { style: {
left: -170, left: -170,

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { pick } from "lodash"; import { pick, trim } from "lodash";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import BaseModel from "models/BaseModel"; import BaseModel from "models/BaseModel";
import Document from "models/Document"; import Document from "models/Document";
@ -20,6 +20,7 @@ export default class Collection extends BaseModel {
createdAt: ?string; createdAt: ?string;
updatedAt: ?string; updatedAt: ?string;
deletedAt: ?string; deletedAt: ?string;
sort: { field: string, direction: "asc" | "desc" };
url: string; url: string;
@computed @computed
@ -45,6 +46,11 @@ export default class Collection extends BaseModel {
return results; return results;
} }
@computed
get hasDescription(): string {
return !!trim(this.description, "\\").trim();
}
@action @action
updateDocument(document: Document) { updateDocument(document: Document) {
const travelDocuments = (documentList, path) => const travelDocuments = (documentList, path) =>
@ -108,6 +114,7 @@ export default class Collection extends BaseModel {
"description", "description",
"icon", "icon",
"private", "private",
"sort",
]); ]);
}; };

View File

@ -164,7 +164,7 @@ class CollectionScene extends React.Component<Props> {
</> </>
)} )}
<Action> <Action>
<CollectionMenu collection={this.collection} /> <CollectionMenu collection={this.collection} showSort={false} />
</Action> </Action>
</Actions> </Actions>
); );
@ -179,9 +179,10 @@ class CollectionScene extends React.Component<Props> {
const pinnedDocuments = this.collection const pinnedDocuments = this.collection
? documents.pinnedInCollection(this.collection.id) ? documents.pinnedInCollection(this.collection.id)
: []; : [];
const hasPinnedDocuments = !!pinnedDocuments.length;
const collection = this.collection; const collection = this.collection;
const collectionName = collection ? collection.name : ""; const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
const hasDescription = collection ? collection.hasDescription : false;
return ( return (
<CenteredContent> <CenteredContent>
@ -240,7 +241,7 @@ class CollectionScene extends React.Component<Props> {
{collection.name} {collection.name}
</Heading> </Heading>
{collection.description && ( {hasDescription && (
<React.Suspense fallback={<p>Loading</p>}> <React.Suspense fallback={<p>Loading</p>}>
<Editor <Editor
id={collection.id} id={collection.id}

View File

@ -2,7 +2,7 @@
import { observable } from "mobx"; import { observable } from "mobx";
import { inject, observer } from "mobx-react"; import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { withTranslation, Trans, type TFunction } from "react-i18next";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
import Collection from "models/Collection"; import Collection from "models/Collection";
import Button from "components/Button"; import Button from "components/Button";
@ -11,6 +11,7 @@ import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker"; import IconPicker from "components/IconPicker";
import Input from "components/Input"; import Input from "components/Input";
import InputRich from "components/InputRich"; import InputRich from "components/InputRich";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch"; import Switch from "components/Switch";
type Props = { type Props = {
@ -27,6 +28,8 @@ class CollectionEdit extends React.Component<Props> {
@observable icon: string = this.props.collection.icon; @observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E"; @observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private; @observable private: boolean = this.props.collection.private;
@observable sort: { field: string, direction: "asc" | "desc" } = this.props
.collection.sort;
@observable isSaving: boolean; @observable isSaving: boolean;
handleSubmit = async (ev: SyntheticEvent<*>) => { handleSubmit = async (ev: SyntheticEvent<*>) => {
@ -41,6 +44,7 @@ class CollectionEdit extends React.Component<Props> {
icon: this.icon, icon: this.icon,
color: this.color, color: this.color,
private: this.private, private: this.private,
sort: this.sort,
}); });
this.props.onSubmit(); this.props.onSubmit();
this.props.ui.showToast(t("The collection was updated")); this.props.ui.showToast(t("The collection was updated"));
@ -51,6 +55,14 @@ class CollectionEdit extends React.Component<Props> {
} }
}; };
handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
const [field, direction] = ev.target.value.split(".");
if (direction === "asc" || direction === "desc") {
this.sort = { field, direction };
}
};
handleDescriptionChange = (getValue: () => string) => { handleDescriptionChange = (getValue: () => string) => {
this.description = getValue(); this.description = getValue();
}; };
@ -75,9 +87,10 @@ class CollectionEdit extends React.Component<Props> {
<Flex column> <Flex column>
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<HelpText> <HelpText>
{t( <Trans>
"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.
</Trans>
</HelpText> </HelpText>
<Flex> <Flex>
<Input <Input
@ -105,6 +118,15 @@ class CollectionEdit extends React.Component<Props> {
minHeight={68} minHeight={68}
maxHeight={200} maxHeight={200}
/> />
<InputSelect
label={t("Sort in sidebar")}
options={[
{ label: t("Alphabetical"), value: "title.asc" },
{ label: t("Manual sort"), value: "index.asc" },
]}
value={`${this.sort.field}.${this.sort.direction}`}
onChange={this.handleSortChange}
/>
<Switch <Switch
id="private" id="private"
label={t("Private collection")} label={t("Private collection")}
@ -112,9 +134,9 @@ class CollectionEdit extends React.Component<Props> {
checked={this.private} checked={this.private}
/> />
<HelpText> <HelpText>
{t( <Trans>
"A private collection will only be visible to invited team members." A private collection will only be visible to invited team members.
)} </Trans>
</HelpText> </HelpText>
<Button <Button
type="submit" type="submit"

View File

@ -453,7 +453,8 @@ export default class DocumentsStore extends BaseStore<Document> {
move = async ( move = async (
documentId: string, documentId: string,
collectionId: string, collectionId: string,
parentDocumentId: ?string parentDocumentId: ?string,
index: ?number
) => { ) => {
this.movingDocumentId = documentId; this.movingDocumentId = documentId;
@ -462,6 +463,7 @@ export default class DocumentsStore extends BaseStore<Document> {
id: documentId, id: documentId,
collectionId, collectionId,
parentDocumentId, parentDocumentId,
index: index,
}); });
invariant(res && res.data, "Data not available"); invariant(res && res.data, "Data not available");

View File

@ -30,7 +30,13 @@ const { authorize } = policy;
const router = new Router(); const router = new Router();
router.post("collections.create", auth(), async (ctx) => { router.post("collections.create", auth(), async (ctx) => {
const { name, color, description, icon } = ctx.body; const {
name,
color,
description,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
const isPrivate = ctx.body.private; const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required"); ctx.assertPresent(name, "name is required");
@ -49,6 +55,7 @@ router.post("collections.create", auth(), async (ctx) => {
teamId: user.teamId, teamId: user.teamId,
creatorId: user.id, creatorId: user.id,
private: isPrivate, private: isPrivate,
sort,
}); });
await Event.create({ await Event.create({
@ -445,16 +452,14 @@ router.post("collections.export_all", auth(), async (ctx) => {
}); });
router.post("collections.update", auth(), async (ctx) => { router.post("collections.update", auth(), async (ctx) => {
const { id, name, description, icon, color } = ctx.body; let { id, name, description, icon, color, sort } = ctx.body;
const isPrivate = ctx.body.private; const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
if (color) { if (color) {
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)"); ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
} }
const user = ctx.state.user; const user = ctx.state.user;
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(id); }).findByPk(id);
@ -478,11 +483,24 @@ router.post("collections.update", auth(), async (ctx) => {
const isPrivacyChanged = isPrivate !== collection.private; const isPrivacyChanged = isPrivate !== collection.private;
if (name !== undefined) {
collection.name = name; collection.name = name;
}
if (description !== undefined) {
collection.description = description; collection.description = description;
}
if (icon !== undefined) {
collection.icon = icon; collection.icon = icon;
}
if (color !== undefined) {
collection.color = color; collection.color = color;
}
if (isPrivate !== undefined) {
collection.private = isPrivate; collection.private = isPrivate;
}
if (sort !== undefined) {
collection.sort = sort;
}
await collection.save(); await collection.save();

View File

@ -864,6 +864,8 @@ describe("#collections.create", () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy(); expect(body.data.id).toBeTruthy();
expect(body.data.name).toBe("Test"); expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc");
expect(body.policies.length).toBe(1); expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy(); expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy(); expect(body.policies[0].abilities.export).toBeTruthy();
@ -916,6 +918,29 @@ describe("#collections.update", () => {
expect(body.policies.length).toBe(1); expect(body.policies.length).toBe(1);
}); });
it("allows editing sort", async () => {
const { user, collection } = await seed();
const sort = { field: "index", direction: "desc" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("desc");
});
it("allows editing individual fields", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, private: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.private).toBe(true);
expect(body.data.name).toBe(collection.name);
});
it("allows editing from non-private to private collection", async () => { it("allows editing from non-private to private collection", async () => {
const { user, collection } = await seed(); const { user, collection } = await seed();
const res = await server.post("/api/collections.update", { const res = await server.post("/api/collections.update", {
@ -1027,6 +1052,24 @@ describe("#collections.update", () => {
}); });
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
}); });
it("does not allow setting unknown sort fields", async () => {
const { user, collection } = await seed();
const sort = { field: "blah", direction: "desc" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
expect(res.status).toEqual(400);
});
it("does not allow setting unknown sort directions", async () => {
const { user, collection } = await seed();
const sort = { field: "title", direction: "blah" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
expect(res.status).toEqual(400);
});
}); });
describe("#collections.delete", () => { describe("#collections.delete", () => {

View File

@ -6,7 +6,7 @@ export default async function documentMover({
user, user,
document, document,
collectionId, collectionId,
parentDocumentId, parentDocumentId = null, // convert undefined to null so parentId comparison treats them as equal
index, index,
ip, ip,
}: { }: {
@ -42,12 +42,24 @@ export default async function documentMover({
transaction, transaction,
paranoid: false, paranoid: false,
}); });
const documentJson = await collection.removeDocumentInStructure( const [
document, documentJson,
{ fromIndex,
] = await collection.removeDocumentInStructure(document, {
save: false, save: false,
} });
);
// if we're reordering from within the same parent
// the original and destination collection are the same,
// so when the initial item is removed above, the list will reduce by 1.
// We need to compensate for this when reordering
const toIndex =
index !== undefined &&
document.parentDocumentId === parentDocumentId &&
document.collectionId === collectionId &&
fromIndex < index
? index - 1
: index;
// if the collection is the same then it will get saved below, this // if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring. // line prevents a pointless intermediate save from occurring.
@ -62,7 +74,7 @@ export default async function documentMover({
const newCollection: Collection = collectionChanged const newCollection: Collection = collectionChanged
? await Collection.findByPk(collectionId, { transaction }) ? await Collection.findByPk(collectionId, { transaction })
: collection; : collection;
await newCollection.addDocumentToStructure(document, index, { await newCollection.addDocumentToStructure(document, toIndex, {
documentJson, documentJson,
}); });
result.collections.push(collection); result.collections.push(collection);

View File

@ -37,7 +37,7 @@ export default function validation() {
}; };
ctx.assertPositiveInteger = (value, message) => { ctx.assertPositiveInteger = (value, message) => {
if (!validator.isInt(value, { min: 0 })) { if (!validator.isInt(String(value), { min: 0 })) {
throw new ValidationError(message); throw new ValidationError(message);
} }
}; };

View File

@ -0,0 +1,14 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'sort', {
type: Sequelize.JSONB,
allowNull: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'sort');
}
};

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { find, concat, remove, uniq } from "lodash"; import { find, findIndex, concat, remove, uniq } from "lodash";
import randomstring from "randomstring"; import randomstring from "randomstring";
import slug from "slug"; import slug from "slug";
import { DataTypes, sequelize } from "../sequelize"; import { DataTypes, sequelize } from "../sequelize";
@ -24,6 +24,27 @@ const Collection = sequelize.define(
private: DataTypes.BOOLEAN, private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN, maintainerApprovalRequired: DataTypes.BOOLEAN,
documentStructure: DataTypes.JSONB, documentStructure: DataTypes.JSONB,
sort: {
type: DataTypes.JSONB,
validate: {
isSort(value) {
if (
typeof value !== "object" ||
!value.direction ||
!value.field ||
Object.keys(value).length !== 2
) {
throw new Error("Sort must be an object with field,direction");
}
if (!["asc", "desc"].includes(value.direction)) {
throw new Error("Sort direction must be one of asc,desc");
}
if (!["title", "index"].includes(value.field)) {
throw new Error("Sort field must be one of title,index");
}
},
},
},
}, },
{ {
tableName: "collections", tableName: "collections",
@ -41,6 +62,11 @@ const Collection = sequelize.define(
} }
); );
Collection.DEFAULT_SORT = {
field: "index",
direction: "asc",
};
Collection.addHook("beforeSave", async (model) => { Collection.addHook("beforeSave", async (model) => {
if (model.icon === "collection") { if (model.icon === "collection") {
model.icon = null; model.icon = null;
@ -350,7 +376,7 @@ Collection.prototype.removeDocumentInStructure = async function (
const match = find(children, { id }); const match = find(children, { id });
if (match) { if (match) {
if (!returnValue) returnValue = match; if (!returnValue) returnValue = [match, findIndex(children, { id })];
remove(children, { id }); remove(children, { id });
} }

View File

@ -145,6 +145,7 @@ Team.prototype.provisionFirstCollection = async function (userId) {
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!", "This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
teamId: this.id, teamId: this.id,
creatorId: userId, creatorId: userId,
sort: Collection.DEFAULT_SORT,
}); });
// For the first collection we go ahead and create some intitial documents to get // For the first collection we go ahead and create some intitial documents to get

View File

@ -9,12 +9,14 @@ type Document = {
url: string, url: string,
}; };
const sortDocuments = (documents: Document[]): Document[] => { const sortDocuments = (documents: Document[], sort): Document[] => {
const orderedDocs = naturalSort(documents, "title"); const orderedDocs = naturalSort(documents, sort.field, {
direction: sort.direction,
});
return orderedDocs.map((document) => ({ return orderedDocs.map((document) => ({
...document, ...document,
children: sortDocuments(document.children), children: sortDocuments(document.children, sort),
})); }));
}; };
@ -24,17 +26,26 @@ export default function present(collection: Collection) {
url: collection.url, url: collection.url,
name: collection.name, name: collection.name,
description: collection.description, description: collection.description,
sort: collection.sort,
icon: collection.icon, icon: collection.icon,
color: collection.color || "#4E5C6E", color: collection.color || "#4E5C6E",
private: collection.private, private: collection.private,
createdAt: collection.createdAt, createdAt: collection.createdAt,
updatedAt: collection.updatedAt, updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt, deletedAt: collection.deletedAt,
documents: undefined, documents: collection.documentStructure || [],
}; };
// Force alphabetical sorting // Handle the "sort" field being empty here for backwards compatability
data.documents = sortDocuments(collection.documentStructure); if (!data.sort) {
data.sort = { field: "title", direction: "asc" };
}
// "index" field is manually sorted and is represented by the documentStructure
// already saved in the database, no further sort is needed
if (data.sort.field !== "index") {
data.documents = sortDocuments(collection.documentStructure, data.sort);
}
return data; return data;
} }

View File

@ -120,6 +120,9 @@
"Edit": "Edit", "Edit": "Edit",
"Permissions": "Permissions", "Permissions": "Permissions",
"Export": "Export", "Export": "Export",
"Sort in sidebar": "Sort in sidebar",
"Alphabetical": "Alphabetical",
"Manual sort": "Manual sort",
"Delete": "Delete", "Delete": "Delete",
"Edit collection": "Edit collection", "Edit collection": "Edit collection",
"Delete collection": "Delete collection", "Delete collection": "Delete collection",
@ -287,7 +290,6 @@
"Delete Account": "Delete Account", "Delete Account": "Delete Account",
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable", "You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
"Delete account": "Delete account", "Delete account": "Delete account",
"Alphabetical": "Alphabetical",
"Youve not starred any documents yet.": "Youve not starred any documents yet.", "Youve not starred any documents yet.": "Youve not starred any documents yet.",
"There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.": "There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.", "There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.": "There are no templates just yet. You can create templates to help your team create consistent and accurate documentation.",
"Trash is empty at the moment.": "Trash is empty at the moment.", "Trash is empty at the moment.": "Trash is empty at the moment.",