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:
parent
ba61091c4c
commit
2cc45187e6
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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};
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
42
app/components/Sidebar/components/DropCursor.js
Normal file
42
app/components/Sidebar/components/DropCursor.js
Normal 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);
|
@ -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 {
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
]);
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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");
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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", () => {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
14
server/migrations/20201230031607-collection-sort.js
Normal file
14
server/migrations/20201230031607-collection-sort.js
Normal 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');
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
"You’ve not starred any documents yet.": "You’ve 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.",
|
||||
|
Reference in New Issue
Block a user