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:
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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};
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
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]);
|
}, [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 {
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
|
@ -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",
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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"
|
||||||
|
@ -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");
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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", () => {
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
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
|
// @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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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",
|
|
||||||
"You’ve not starred any documents yet.": "You’ve not starred any documents yet.",
|
"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.",
|
"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.",
|
||||||
|
Reference in New Issue
Block a user