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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 435 additions and 109 deletions

View File

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

View File

@ -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<void>,
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}
</DropdownMenuItem>
@ -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 {
<DropdownMenuItem
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
key={index}
>
{item.title}
@ -108,7 +121,10 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
{item.title}
<Flex justify="space-between" align="center" auto>
{item.title}
<Disclosure color="currentColor" />
</Flex>
</DropdownMenuItem>
}
hover={item.hover}

View File

@ -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};

View File

@ -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 (
<>
<div ref={drop}>
<div ref={drop} style={{ position: "relative" }}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLink
key={collection.id}
@ -88,10 +103,13 @@ function CollectionLink({
}
></SidebarLink>
</DropToImport>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded &&
collection.documents.map((node) => (
collection.documents.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
@ -100,6 +118,7 @@ function CollectionLink({
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
index={index}
/>
))}
</>

View File

@ -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<void>,
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 (
<>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={drop}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
<div style={{ position: "relative" }}>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
isActiveDrop={isOver && canDrop}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>
</>
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded && !isDragging && (
<>
{node.children.map((childNode) => (
{node.children.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
@ -199,6 +237,8 @@ function DocumentLink({
prefetchDocument={prefetchDocument}
depth={depth + 1}
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]);
const activeStyle = {
color: theme.text,
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
const activeFontWeightOnly = {
fontWeight: 600,
};
return (
<StyledNavLink
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? undefined : activeStyle}
activeStyle={isActiveDrop ? activeFontWeightOnly : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
@ -106,6 +110,7 @@ const StyledNavLink = styled(NavLink)`
text-overflow: ellipsis;
padding: 4px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
background: ${(props) =>
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 {

View File

@ -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<Props> {
}
};
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<Props> {
documents,
collection,
position,
showSort,
onOpen,
onClose,
t,
@ -147,12 +158,12 @@ class CollectionMenu extends React.Component<Props> {
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<Props> {
},
{
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<Props> {
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),

View File

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

View File

@ -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",
]);
};

View File

@ -164,7 +164,7 @@ class CollectionScene extends React.Component<Props> {
</>
)}
<Action>
<CollectionMenu collection={this.collection} />
<CollectionMenu collection={this.collection} showSort={false} />
</Action>
</Actions>
);
@ -179,9 +179,10 @@ class CollectionScene extends React.Component<Props> {
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 (
<CenteredContent>
@ -240,7 +241,7 @@ class CollectionScene extends React.Component<Props> {
{collection.name}
</Heading>
{collection.description && (
{hasDescription && (
<React.Suspense fallback={<p>Loading</p>}>
<Editor
id={collection.id}

View File

@ -2,7 +2,7 @@
import { observable } from "mobx";
import { inject, observer } from "mobx-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 Collection from "models/Collection";
import Button from "components/Button";
@ -11,6 +11,7 @@ import HelpText from "components/HelpText";
import IconPicker from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
import InputSelect from "components/InputSelect";
import Switch from "components/Switch";
type Props = {
@ -27,6 +28,8 @@ class CollectionEdit extends React.Component<Props> {
@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<Props> {
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<Props> {
}
};
handleSortChange = (ev: SyntheticInputEvent<HTMLSelectElement>) => {
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<Props> {
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
{t(
"You can edit the name and other details at any time, however doing so often might confuse your team mates."
)}
<Trans>
You can edit the name and other details at any time, however doing
so often might confuse your team mates.
</Trans>
</HelpText>
<Flex>
<Input
@ -105,6 +118,15 @@ class CollectionEdit extends React.Component<Props> {
minHeight={68}
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
id="private"
label={t("Private collection")}
@ -112,9 +134,9 @@ class CollectionEdit extends React.Component<Props> {
checked={this.private}
/>
<HelpText>
{t(
"A private collection will only be visible to invited team members."
)}
<Trans>
A private collection will only be visible to invited team members.
</Trans>
</HelpText>
<Button
type="submit"

View File

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

View File

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

View File

@ -864,6 +864,8 @@ describe("#collections.create", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
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[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy();
@ -916,6 +918,29 @@ describe("#collections.update", () => {
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 () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
@ -1027,6 +1052,24 @@ describe("#collections.update", () => {
});
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", () => {

View File

@ -6,7 +6,7 @@ export default async function documentMover({
user,
document,
collectionId,
parentDocumentId,
parentDocumentId = null, // convert undefined to null so parentId comparison treats them as equal
index,
ip,
}: {
@ -42,12 +42,24 @@ export default async function documentMover({
transaction,
paranoid: false,
});
const documentJson = await collection.removeDocumentInStructure(
document,
{
save: false,
}
);
const [
documentJson,
fromIndex,
] = await collection.removeDocumentInStructure(document, {
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
// line prevents a pointless intermediate save from occurring.
@ -62,7 +74,7 @@ export default async function documentMover({
const newCollection: Collection = collectionChanged
? await Collection.findByPk(collectionId, { transaction })
: collection;
await newCollection.addDocumentToStructure(document, index, {
await newCollection.addDocumentToStructure(document, toIndex, {
documentJson,
});
result.collections.push(collection);

View File

@ -37,7 +37,7 @@ export default function validation() {
};
ctx.assertPositiveInteger = (value, message) => {
if (!validator.isInt(value, { min: 0 })) {
if (!validator.isInt(String(value), { min: 0 })) {
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
import { find, concat, remove, uniq } from "lodash";
import { find, findIndex, concat, remove, uniq } from "lodash";
import randomstring from "randomstring";
import slug from "slug";
import { DataTypes, sequelize } from "../sequelize";
@ -24,6 +24,27 @@ const Collection = sequelize.define(
private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN,
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",
@ -41,6 +62,11 @@ const Collection = sequelize.define(
}
);
Collection.DEFAULT_SORT = {
field: "index",
direction: "asc",
};
Collection.addHook("beforeSave", async (model) => {
if (model.icon === "collection") {
model.icon = null;
@ -350,7 +376,7 @@ Collection.prototype.removeDocumentInStructure = async function (
const match = find(children, { id });
if (match) {
if (!returnValue) returnValue = match;
if (!returnValue) returnValue = [match, findIndex(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!",
teamId: this.id,
creatorId: userId,
sort: Collection.DEFAULT_SORT,
});
// For the first collection we go ahead and create some intitial documents to get

View File

@ -9,12 +9,14 @@ type Document = {
url: string,
};
const sortDocuments = (documents: Document[]): Document[] => {
const orderedDocs = naturalSort(documents, "title");
const sortDocuments = (documents: Document[], sort): Document[] => {
const orderedDocs = naturalSort(documents, sort.field, {
direction: sort.direction,
});
return orderedDocs.map((document) => ({
...document,
children: sortDocuments(document.children),
children: sortDocuments(document.children, sort),
}));
};
@ -24,17 +26,26 @@ export default function present(collection: Collection) {
url: collection.url,
name: collection.name,
description: collection.description,
sort: collection.sort,
icon: collection.icon,
color: collection.color || "#4E5C6E",
private: collection.private,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt,
documents: undefined,
documents: collection.documentStructure || [],
};
// Force alphabetical sorting
data.documents = sortDocuments(collection.documentStructure);
// Handle the "sort" field being empty here for backwards compatability
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;
}

View File

@ -120,6 +120,9 @@
"Edit": "Edit",
"Permissions": "Permissions",
"Export": "Export",
"Sort in sidebar": "Sort in sidebar",
"Alphabetical": "Alphabetical",
"Manual sort": "Manual sort",
"Delete": "Delete",
"Edit collection": "Edit collection",
"Delete collection": "Delete collection",
@ -287,7 +290,6 @@
"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",
"Delete account": "Delete account",
"Alphabetical": "Alphabetical",
"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.",
"Trash is empty at the moment.": "Trash is empty at the moment.",