feat: Add ability to star collection (#3327)
* Migrations, models, commands * ui * Move starred hint to location state * lint * tsc * refactor * Add collection empty state in expanded sidebar * Add empty placeholder within starred collections * Drag and drop improves, Relative refactor * fix: Starring untitled draft leaves empty space * fix: Creating draft in starred collection shouldnt open main * fix: Dupe drop cursor * Final fixes * fix: Canonical redirect replaces starred location state * fix: Don't show reorder cursor at the top of collection with no permission to edit when dragging
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
|
import {
|
||||||
|
CollectionIcon,
|
||||||
|
EditIcon,
|
||||||
|
PlusIcon,
|
||||||
|
StarredIcon,
|
||||||
|
UnstarredIcon,
|
||||||
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import stores from "~/stores";
|
import stores from "~/stores";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
@@ -73,4 +79,59 @@ export const editCollection = createAction({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rootCollectionActions = [openCollection, createCollection];
|
export const starCollection = createAction({
|
||||||
|
name: ({ t }) => t("Star"),
|
||||||
|
section: CollectionSection,
|
||||||
|
icon: <StarredIcon />,
|
||||||
|
keywords: "favorite bookmark",
|
||||||
|
visible: ({ activeCollectionId, stores }) => {
|
||||||
|
if (!activeCollectionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const collection = stores.collections.get(activeCollectionId);
|
||||||
|
return (
|
||||||
|
!collection?.isStarred &&
|
||||||
|
stores.policies.abilities(activeCollectionId).star
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: ({ activeCollectionId, stores }) => {
|
||||||
|
if (!activeCollectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = stores.collections.get(activeCollectionId);
|
||||||
|
collection?.star();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unstarCollection = createAction({
|
||||||
|
name: ({ t }) => t("Unstar"),
|
||||||
|
section: CollectionSection,
|
||||||
|
icon: <UnstarredIcon />,
|
||||||
|
keywords: "unfavorite unbookmark",
|
||||||
|
visible: ({ activeCollectionId, stores }) => {
|
||||||
|
if (!activeCollectionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const collection = stores.collections.get(activeCollectionId);
|
||||||
|
return (
|
||||||
|
!!collection?.isStarred &&
|
||||||
|
stores.policies.abilities(activeCollectionId).unstar
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: ({ activeCollectionId, stores }) => {
|
||||||
|
if (!activeCollectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = stores.collections.get(activeCollectionId);
|
||||||
|
collection?.unstar();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rootCollectionActions = [
|
||||||
|
openCollection,
|
||||||
|
createCollection,
|
||||||
|
starCollection,
|
||||||
|
unstarCollection,
|
||||||
|
];
|
||||||
|
|||||||
@@ -52,8 +52,11 @@ export const createDocument = createAction({
|
|||||||
visible: ({ activeCollectionId, stores }) =>
|
visible: ({ activeCollectionId, stores }) =>
|
||||||
!!activeCollectionId &&
|
!!activeCollectionId &&
|
||||||
stores.policies.abilities(activeCollectionId).update,
|
stores.policies.abilities(activeCollectionId).update,
|
||||||
perform: ({ activeCollectionId }) =>
|
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||||
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
|
activeCollectionId &&
|
||||||
|
history.push(newDocumentPath(activeCollectionId), {
|
||||||
|
starred: inStarredSection,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const starDocument = createAction({
|
export const starDocument = createAction({
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import fractionalIndex from "fractional-index";
|
import { Location } from "history";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
|
||||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import DocumentReparent from "~/scenes/DocumentReparent";
|
import DocumentReparent from "~/scenes/DocumentReparent";
|
||||||
import CollectionIcon from "~/components/CollectionIcon";
|
import CollectionIcon from "~/components/CollectionIcon";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import Modal from "~/components/Modal";
|
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import { createDocument } from "~/actions/definitions/documents";
|
import { createDocument } from "~/actions/definitions/documents";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
@@ -21,40 +18,36 @@ import usePolicy from "~/hooks/usePolicy";
|
|||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import CollectionMenu from "~/menus/CollectionMenu";
|
import CollectionMenu from "~/menus/CollectionMenu";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import DocumentLink from "./DocumentLink";
|
|
||||||
import DropCursor from "./DropCursor";
|
|
||||||
import DropToImport from "./DropToImport";
|
import DropToImport from "./DropToImport";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
|
import Relative from "./Relative";
|
||||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||||
|
import { useStarredContext } from "./StarredContext";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
canUpdate: boolean;
|
expanded?: boolean;
|
||||||
activeDocument: Document | null | undefined;
|
onDisclosureClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
prefetchDocument: (id: string) => Promise<Document | void>;
|
activeDocument: Document | undefined;
|
||||||
belowCollection: Collection | void;
|
isDraggingAnyCollection?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function CollectionLink({
|
const CollectionLink: React.FC<Props> = ({
|
||||||
collection,
|
collection,
|
||||||
activeDocument,
|
expanded,
|
||||||
prefetchDocument,
|
onDisclosureClick,
|
||||||
canUpdate,
|
isDraggingAnyCollection,
|
||||||
belowCollection,
|
}) => {
|
||||||
}: Props) {
|
|
||||||
const history = useHistory();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { search } = useLocation();
|
|
||||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
|
||||||
const [
|
|
||||||
permissionOpen,
|
|
||||||
handlePermissionOpen,
|
|
||||||
handlePermissionClose,
|
|
||||||
] = useBoolean();
|
|
||||||
const itemRef = React.useRef<
|
const itemRef = React.useRef<
|
||||||
NavigationNode & { depth: number; active: boolean; collectionId: string }
|
NavigationNode & { depth: number; active: boolean; collectionId: string }
|
||||||
>();
|
>();
|
||||||
|
const { dialogs, documents, collections } = useStores();
|
||||||
|
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||||
const [isEditing, setIsEditing] = React.useState(false);
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
|
const canUpdate = usePolicy(collection.id).update;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const history = useHistory();
|
||||||
|
const inStarredSection = useStarredContext();
|
||||||
|
|
||||||
const handleTitleChange = React.useCallback(
|
const handleTitleChange = React.useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
@@ -66,26 +59,6 @@ function CollectionLink({
|
|||||||
[collection, history]
|
[collection, history]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
|
|
||||||
setIsEditing(isEditing);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { ui, documents, collections } = useStores();
|
|
||||||
const [expanded, setExpanded] = React.useState(
|
|
||||||
collection.id === ui.activeCollectionId
|
|
||||||
);
|
|
||||||
|
|
||||||
const [openedOnce, setOpenedOnce] = React.useState(expanded);
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (expanded) {
|
|
||||||
setOpenedOnce(true);
|
|
||||||
}
|
|
||||||
}, [expanded]);
|
|
||||||
|
|
||||||
const manualSort = collection.sort.field === "index";
|
|
||||||
const can = usePolicy(collection.id);
|
|
||||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
|
||||||
|
|
||||||
// Drop to re-parent document
|
// Drop to re-parent document
|
||||||
const [{ isOver, canDrop }, drop] = useDrop({
|
const [{ isOver, canDrop }, drop] = useDrop({
|
||||||
accept: "document",
|
accept: "document",
|
||||||
@@ -111,14 +84,23 @@ function CollectionLink({
|
|||||||
prevCollection.permission !== collection.permission
|
prevCollection.permission !== collection.permission
|
||||||
) {
|
) {
|
||||||
itemRef.current = item;
|
itemRef.current = item;
|
||||||
handlePermissionOpen();
|
|
||||||
|
dialogs.openModal({
|
||||||
|
title: t("Move document"),
|
||||||
|
content: (
|
||||||
|
<DocumentReparent
|
||||||
|
item={item}
|
||||||
|
collection={collection}
|
||||||
|
onSubmit={dialogs.closeAllModals}
|
||||||
|
onCancel={dialogs.closeAllModals}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
documents.move(id, collection.id);
|
documents.move(id, collection.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
canDrop: () => {
|
canDrop: () => canUpdate,
|
||||||
return can.update;
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
isOver: !!monitor.isOver({
|
isOver: !!monitor.isOver({
|
||||||
shallow: true,
|
shallow: true,
|
||||||
@@ -127,224 +109,69 @@ function CollectionLink({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drop to reorder document
|
const handleTitleEditing = React.useCallback((isEditing: boolean) => {
|
||||||
const [{ isOverReorder }, dropToReorder] = useDrop({
|
setIsEditing(isEditing);
|
||||||
accept: "document",
|
}, []);
|
||||||
drop: (item: DragObject) => {
|
|
||||||
if (!collection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
documents.move(item.id, collection.id, undefined, 0);
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOverReorder: !!monitor.isOver(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drop to reorder collection
|
|
||||||
const [
|
|
||||||
{ isCollectionDropping, isDraggingAnyCollection },
|
|
||||||
dropToReorderCollection,
|
|
||||||
] = useDrop({
|
|
||||||
accept: "collection",
|
|
||||||
drop: (item: DragObject) => {
|
|
||||||
collections.move(
|
|
||||||
item.id,
|
|
||||||
fractionalIndex(collection.index, belowCollectionIndex)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
canDrop: (item) => {
|
|
||||||
return (
|
|
||||||
collection.id !== item.id &&
|
|
||||||
(!belowCollection || item.id !== belowCollection.id)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
|
|
||||||
isCollectionDropping: monitor.isOver(),
|
|
||||||
isDraggingAnyCollection: monitor.getItemType() === "collection",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag to reorder collection
|
|
||||||
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
|
|
||||||
type: "collection",
|
|
||||||
item: () => {
|
|
||||||
return {
|
|
||||||
id: collection.id,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isCollectionDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
canDrag: () => {
|
|
||||||
return can.move;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const collectionDocuments = React.useMemo(() => {
|
|
||||||
if (
|
|
||||||
activeDocument?.isActive &&
|
|
||||||
activeDocument?.isDraft &&
|
|
||||||
activeDocument?.collectionId === collection.id &&
|
|
||||||
!activeDocument?.parentDocumentId
|
|
||||||
) {
|
|
||||||
return sortNavigationNodes(
|
|
||||||
[activeDocument.asNavigationNode, ...collection.documents],
|
|
||||||
collection.sort
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return collection.documents;
|
|
||||||
}, [
|
|
||||||
activeDocument?.isActive,
|
|
||||||
activeDocument?.isDraft,
|
|
||||||
activeDocument?.collectionId,
|
|
||||||
activeDocument?.parentDocumentId,
|
|
||||||
activeDocument?.asNavigationNode,
|
|
||||||
collection.documents,
|
|
||||||
collection.id,
|
|
||||||
collection.sort,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const displayDocumentLinks = expanded && !isCollectionDragging;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// If we're viewing a starred document through the starred menu then don't
|
|
||||||
// touch the expanded / collapsed state of the collections
|
|
||||||
if (search === "?starred") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (collection.id === ui.activeCollectionId) {
|
|
||||||
setExpanded(true);
|
|
||||||
}
|
|
||||||
}, [collection.id, ui.activeCollectionId, search]);
|
|
||||||
|
|
||||||
const context = useActionContext({
|
const context = useActionContext({
|
||||||
activeCollectionId: collection.id,
|
activeCollectionId: collection.id,
|
||||||
|
inStarredSection,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Relative ref={drop}>
|
<Relative ref={drop}>
|
||||||
<Draggable
|
<DropToImport collectionId={collection.id}>
|
||||||
key={collection.id}
|
<SidebarLink
|
||||||
ref={dragToReorderCollection}
|
to={{
|
||||||
$isDragging={isCollectionDragging}
|
pathname: collection.url,
|
||||||
$isMoving={isCollectionDragging}
|
state: { starred: inStarredSection },
|
||||||
>
|
}}
|
||||||
<DropToImport collectionId={collection.id}>
|
expanded={expanded}
|
||||||
<SidebarLink
|
onDisclosureClick={onDisclosureClick}
|
||||||
to={collection.url}
|
icon={
|
||||||
expanded={displayDocumentLinks}
|
<CollectionIcon collection={collection} expanded={expanded} />
|
||||||
onDisclosureClick={(event) => {
|
}
|
||||||
event.preventDefault();
|
showActions={menuOpen}
|
||||||
setExpanded((prev) => !prev);
|
isActiveDrop={isOver && canDrop}
|
||||||
}}
|
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||||
icon={
|
!!match && location.state?.starred === inStarredSection
|
||||||
<CollectionIcon
|
}
|
||||||
collection={collection}
|
label={
|
||||||
expanded={displayDocumentLinks}
|
<EditableTitle
|
||||||
/>
|
title={collection.name}
|
||||||
}
|
onSubmit={handleTitleChange}
|
||||||
showActions={menuOpen}
|
onEditing={handleTitleEditing}
|
||||||
isActiveDrop={isOver && canDrop}
|
|
||||||
label={
|
|
||||||
<EditableTitle
|
|
||||||
title={collection.name}
|
|
||||||
onSubmit={handleTitleChange}
|
|
||||||
onEditing={handleTitleEditing}
|
|
||||||
canUpdate={canUpdate}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
exact={false}
|
|
||||||
depth={0}
|
|
||||||
menu={
|
|
||||||
!isEditing &&
|
|
||||||
!isDraggingAnyCollection && (
|
|
||||||
<Fade>
|
|
||||||
<NudeButton
|
|
||||||
tooltip={{ tooltip: t("New doc"), delay: 500 }}
|
|
||||||
action={createDocument}
|
|
||||||
context={context}
|
|
||||||
hideOnActionDisabled
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</NudeButton>
|
|
||||||
<CollectionMenu
|
|
||||||
collection={collection}
|
|
||||||
onOpen={handleMenuOpen}
|
|
||||||
onClose={handleMenuClose}
|
|
||||||
/>
|
|
||||||
</Fade>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropToImport>
|
|
||||||
</Draggable>
|
|
||||||
</Relative>
|
|
||||||
<Relative>
|
|
||||||
{openedOnce && (
|
|
||||||
<Folder $open={displayDocumentLinks}>
|
|
||||||
{manualSort && (
|
|
||||||
<DropCursor
|
|
||||||
isActiveDrop={isOverReorder}
|
|
||||||
innerRef={dropToReorder}
|
|
||||||
position="top"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{collectionDocuments.map((node, index) => (
|
|
||||||
<DocumentLink
|
|
||||||
key={node.id}
|
|
||||||
node={node}
|
|
||||||
collection={collection}
|
|
||||||
activeDocument={activeDocument}
|
|
||||||
prefetchDocument={prefetchDocument}
|
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
isDraft={node.isDraft}
|
|
||||||
depth={2}
|
|
||||||
index={index}
|
|
||||||
/>
|
/>
|
||||||
))}
|
}
|
||||||
</Folder>
|
exact={false}
|
||||||
)}
|
depth={0}
|
||||||
{isDraggingAnyCollection && (
|
menu={
|
||||||
<DropCursor
|
!isEditing &&
|
||||||
isActiveDrop={isCollectionDropping}
|
!isDraggingAnyCollection && (
|
||||||
innerRef={dropToReorderCollection}
|
<Fade>
|
||||||
|
<NudeButton
|
||||||
|
tooltip={{ tooltip: t("New doc"), delay: 500 }}
|
||||||
|
action={createDocument}
|
||||||
|
context={context}
|
||||||
|
hideOnActionDisabled
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</NudeButton>
|
||||||
|
<CollectionMenu
|
||||||
|
collection={collection}
|
||||||
|
onOpen={handleMenuOpen}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
/>
|
||||||
|
</Fade>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</DropToImport>
|
||||||
</Relative>
|
</Relative>
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={t("Move document")}
|
|
||||||
onRequestClose={handlePermissionClose}
|
|
||||||
isOpen={permissionOpen}
|
|
||||||
>
|
|
||||||
{itemRef.current && (
|
|
||||||
<DocumentReparent
|
|
||||||
item={itemRef.current}
|
|
||||||
collection={collection}
|
|
||||||
onSubmit={handlePermissionClose}
|
|
||||||
onCancel={handlePermissionClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const Relative = styled.div`
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Folder = styled.div<{ $open?: boolean }>`
|
|
||||||
display: ${(props) => (props.$open ? "block" : "none")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Draggable = styled("div")<{ $isDragging: boolean; $isMoving: boolean }>`
|
|
||||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
|
||||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default observer(CollectionLink);
|
export default observer(CollectionLink);
|
||||||
|
|||||||
91
app/components/Sidebar/components/CollectionLinkChildren.tsx
Normal file
91
app/components/Sidebar/components/CollectionLinkChildren.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useDrop } from "react-dnd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
|
import Document from "~/models/Document";
|
||||||
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import DocumentLink from "./DocumentLink";
|
||||||
|
import DropCursor from "./DropCursor";
|
||||||
|
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
|
||||||
|
import Folder from "./Folder";
|
||||||
|
import { DragObject } from "./SidebarLink";
|
||||||
|
import useCollectionDocuments from "./useCollectionDocuments";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collection: Collection;
|
||||||
|
expanded: boolean;
|
||||||
|
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CollectionLinkChildren({
|
||||||
|
collection,
|
||||||
|
expanded,
|
||||||
|
prefetchDocument,
|
||||||
|
}: Props) {
|
||||||
|
const can = usePolicy(collection.id);
|
||||||
|
const { showToast } = useToasts();
|
||||||
|
const manualSort = collection.sort.field === "index";
|
||||||
|
const { documents } = useStores();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const childDocuments = useCollectionDocuments(collection, documents.active);
|
||||||
|
|
||||||
|
// Drop to reorder document
|
||||||
|
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||||
|
accept: "document",
|
||||||
|
drop: (item: DragObject) => {
|
||||||
|
if (!manualSort) {
|
||||||
|
showToast(
|
||||||
|
t(
|
||||||
|
"You can't reorder documents in an alphabetically sorted collection"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
type: "info",
|
||||||
|
timeout: 5000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
documents.move(item.id, collection.id, undefined, 0);
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOverReorder: !!monitor.isOver(),
|
||||||
|
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Folder expanded={expanded}>
|
||||||
|
{isDraggingAnyDocument && can.update && (
|
||||||
|
<DropCursor
|
||||||
|
disabled={!manualSort}
|
||||||
|
isActiveDrop={isOverReorder}
|
||||||
|
innerRef={dropToReorder}
|
||||||
|
position="top"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{childDocuments.map((node, index) => (
|
||||||
|
<DocumentLink
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
collection={collection}
|
||||||
|
activeDocument={documents.active}
|
||||||
|
prefetchDocument={prefetchDocument}
|
||||||
|
isDraft={node.isDraft}
|
||||||
|
depth={2}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{childDocuments.length === 0 && <EmptyCollectionPlaceholder />}
|
||||||
|
</Folder>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(CollectionLinkChildren);
|
||||||
@@ -3,24 +3,24 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Fade from "~/components/Fade";
|
import Fade from "~/components/Fade";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { createCollection } from "~/actions/definitions/collections";
|
import { createCollection } from "~/actions/definitions/collections";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
import CollectionLink from "./CollectionLink";
|
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||||
import DropCursor from "./DropCursor";
|
import DropCursor from "./DropCursor";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import PlaceholderCollections from "./PlaceholderCollections";
|
import PlaceholderCollections from "./PlaceholderCollections";
|
||||||
|
import Relative from "./Relative";
|
||||||
import SidebarAction from "./SidebarAction";
|
import SidebarAction from "./SidebarAction";
|
||||||
import { DragObject } from "./SidebarLink";
|
import { DragObject } from "./SidebarLink";
|
||||||
|
|
||||||
function Collections() {
|
function Collections() {
|
||||||
const [isFetching, setFetching] = React.useState(false);
|
const [isFetching, setFetching] = React.useState(false);
|
||||||
const [fetchError, setFetchError] = React.useState();
|
const [fetchError, setFetchError] = React.useState();
|
||||||
const { policies, documents, collections } = useStores();
|
const { documents, collections } = useStores();
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const [expanded, setExpanded] = React.useState(true);
|
const [expanded, setExpanded] = React.useState(true);
|
||||||
const isPreloaded = !!collections.orderedData.length;
|
const isPreloaded = !!collections.orderedData.length;
|
||||||
@@ -82,12 +82,11 @@ function Collections() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{orderedCollections.map((collection: Collection, index: number) => (
|
{orderedCollections.map((collection: Collection, index: number) => (
|
||||||
<CollectionLink
|
<DraggableCollectionLink
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
activeDocument={documents.active}
|
activeDocument={documents.active}
|
||||||
prefetchDocument={documents.prefetchDocument}
|
prefetchDocument={documents.prefetchDocument}
|
||||||
canUpdate={policies.abilities(collection.id).update}
|
|
||||||
belowCollection={orderedCollections[index + 1]}
|
belowCollection={orderedCollections[index + 1]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -116,8 +115,4 @@ function Collections() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Relative = styled.div`
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default observer(Collections);
|
export default observer(Collections);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Location } from "history";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -13,6 +14,7 @@ import Fade from "~/components/Fade";
|
|||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
import DocumentMenu from "~/menus/DocumentMenu";
|
import DocumentMenu from "~/menus/DocumentMenu";
|
||||||
@@ -21,24 +23,25 @@ import { newDocumentPath } from "~/utils/routeHelpers";
|
|||||||
import DropCursor from "./DropCursor";
|
import DropCursor from "./DropCursor";
|
||||||
import DropToImport from "./DropToImport";
|
import DropToImport from "./DropToImport";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
|
import Folder from "./Folder";
|
||||||
|
import Relative from "./Relative";
|
||||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||||
|
import { useStarredContext } from "./StarredContext";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
node: NavigationNode;
|
node: NavigationNode;
|
||||||
canUpdate: boolean;
|
|
||||||
collection?: Collection;
|
collection?: Collection;
|
||||||
activeDocument: Document | null | undefined;
|
activeDocument: Document | null | undefined;
|
||||||
prefetchDocument: (documentId: string) => Promise<Document | void>;
|
prefetchDocument?: (documentId: string) => Promise<Document | void>;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
depth: number;
|
depth: number;
|
||||||
index: number;
|
index: number;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DocumentLink(
|
function InnerDocumentLink(
|
||||||
{
|
{
|
||||||
node,
|
node,
|
||||||
canUpdate,
|
|
||||||
collection,
|
collection,
|
||||||
activeDocument,
|
activeDocument,
|
||||||
prefetchDocument,
|
prefetchDocument,
|
||||||
@@ -52,12 +55,14 @@ function DocumentLink(
|
|||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const { documents, policies } = useStores();
|
const { documents, policies } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const canUpdate = usePolicy(node.id).update;
|
||||||
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
const isActiveDocument = activeDocument && activeDocument.id === node.id;
|
||||||
const hasChildDocuments =
|
const hasChildDocuments =
|
||||||
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
!!node.children.length || activeDocument?.parentDocumentId === node.id;
|
||||||
const document = documents.get(node.id);
|
const document = documents.get(node.id);
|
||||||
const { fetchChildDocuments } = documents;
|
const { fetchChildDocuments } = documents;
|
||||||
const [isEditing, setIsEditing] = React.useState(false);
|
const [isEditing, setIsEditing] = React.useState(false);
|
||||||
|
const inStarredSection = useStarredContext();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isActiveDocument && hasChildDocuments) {
|
if (isActiveDocument && hasChildDocuments) {
|
||||||
@@ -84,7 +89,6 @@ function DocumentLink(
|
|||||||
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
|
}, [hasChildDocuments, activeDocument, isActiveDocument, node, collection]);
|
||||||
|
|
||||||
const [expanded, setExpanded] = React.useState(showChildren);
|
const [expanded, setExpanded] = React.useState(showChildren);
|
||||||
const [openedOnce, setOpenedOnce] = React.useState(expanded);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (showChildren) {
|
if (showChildren) {
|
||||||
@@ -92,14 +96,7 @@ function DocumentLink(
|
|||||||
}
|
}
|
||||||
}, [showChildren]);
|
}, [showChildren]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
// when the last child document is removed auto-close the local folder state
|
||||||
if (expanded) {
|
|
||||||
setOpenedOnce(true);
|
|
||||||
}
|
|
||||||
}, [expanded]);
|
|
||||||
|
|
||||||
// when the last child document is removed,
|
|
||||||
// also close the local folder state to closed
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (expanded && !hasChildDocuments) {
|
if (expanded && !hasChildDocuments) {
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
@@ -116,7 +113,7 @@ function DocumentLink(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseEnter = React.useCallback(() => {
|
const handleMouseEnter = React.useCallback(() => {
|
||||||
prefetchDocument(node.id);
|
prefetchDocument?.(node.id);
|
||||||
}, [prefetchDocument, node]);
|
}, [prefetchDocument, node]);
|
||||||
|
|
||||||
const handleTitleChange = React.useCallback(
|
const handleTitleChange = React.useCallback(
|
||||||
@@ -190,7 +187,7 @@ function DocumentLink(
|
|||||||
!isDraft &&
|
!isDraft &&
|
||||||
!!pathToNode &&
|
!!pathToNode &&
|
||||||
!pathToNode.includes(monitor.getItem<DragObject>().id),
|
!pathToNode.includes(monitor.getItem<DragObject>().id),
|
||||||
hover: (item, monitor) => {
|
hover: (_item, monitor) => {
|
||||||
// Enables expansion of document children when hovering over the document
|
// Enables expansion of document children when hovering over the document
|
||||||
// for more than half a second.
|
// for more than half a second.
|
||||||
if (
|
if (
|
||||||
@@ -314,6 +311,7 @@ function DocumentLink(
|
|||||||
pathname: node.url,
|
pathname: node.url,
|
||||||
state: {
|
state: {
|
||||||
title: node.title,
|
title: node.title,
|
||||||
|
starred: inStarredSection,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
label={
|
label={
|
||||||
@@ -325,14 +323,14 @@ function DocumentLink(
|
|||||||
maxLength={MAX_TITLE_LENGTH}
|
maxLength={MAX_TITLE_LENGTH}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
isActive={(match, location) =>
|
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||||
!!match && location.search !== "?starred"
|
!!match && location.state?.starred === inStarredSection
|
||||||
}
|
}
|
||||||
isActiveDrop={isOverReparent && canDropToReparent}
|
isActiveDrop={isOverReparent && canDropToReparent}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
exact={false}
|
exact={false}
|
||||||
showActions={menuOpen}
|
showActions={menuOpen}
|
||||||
scrollIntoViewIfNeeded={!document?.isStarred}
|
scrollIntoViewIfNeeded={!inStarredSection}
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
menu={
|
menu={
|
||||||
@@ -375,41 +373,30 @@ function DocumentLink(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Relative>
|
</Relative>
|
||||||
{openedOnce && (
|
<Folder expanded={expanded && !isDragging}>
|
||||||
<Folder $open={expanded && !isDragging}>
|
{nodeChildren.map((childNode, index) => (
|
||||||
{nodeChildren.map((childNode, index) => (
|
<DocumentLink
|
||||||
<ObservedDocumentLink
|
key={childNode.id}
|
||||||
key={childNode.id}
|
collection={collection}
|
||||||
collection={collection}
|
node={childNode}
|
||||||
node={childNode}
|
activeDocument={activeDocument}
|
||||||
activeDocument={activeDocument}
|
prefetchDocument={prefetchDocument}
|
||||||
prefetchDocument={prefetchDocument}
|
isDraft={childNode.isDraft}
|
||||||
isDraft={childNode.isDraft}
|
depth={depth + 1}
|
||||||
depth={depth + 1}
|
index={index}
|
||||||
canUpdate={canUpdate}
|
parentId={node.id}
|
||||||
index={index}
|
/>
|
||||||
parentId={node.id}
|
))}
|
||||||
/>
|
</Folder>
|
||||||
))}
|
|
||||||
</Folder>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Folder = styled.div<{ $open?: boolean }>`
|
|
||||||
display: ${(props) => (props.$open ? "block" : "none")};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Relative = styled.div`
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
|
const Draggable = styled.div<{ $isDragging?: boolean; $isMoving?: boolean }>`
|
||||||
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
|
||||||
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
|
const DocumentLink = observer(React.forwardRef(InnerDocumentLink));
|
||||||
|
|
||||||
export default ObservedDocumentLink;
|
export default DocumentLink;
|
||||||
|
|||||||
148
app/components/Sidebar/components/DraggableCollectionLink.tsx
Normal file
148
app/components/Sidebar/components/DraggableCollectionLink.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import fractionalIndex from "fractional-index";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
|
import Document from "~/models/Document";
|
||||||
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import CollectionLink from "./CollectionLink";
|
||||||
|
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||||
|
import DropCursor from "./DropCursor";
|
||||||
|
import Relative from "./Relative";
|
||||||
|
import { DragObject } from "./SidebarLink";
|
||||||
|
import { useStarredContext } from "./StarredContext";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collection: Collection;
|
||||||
|
activeDocument: Document | undefined;
|
||||||
|
prefetchDocument: (id: string) => Promise<Document | void>;
|
||||||
|
belowCollection: Collection | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useLocationStateStarred() {
|
||||||
|
const location = useLocation<{
|
||||||
|
starred?: boolean;
|
||||||
|
}>();
|
||||||
|
return location.state?.starred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableCollectionLink({
|
||||||
|
collection,
|
||||||
|
activeDocument,
|
||||||
|
prefetchDocument,
|
||||||
|
belowCollection,
|
||||||
|
}: Props) {
|
||||||
|
const locationStateStarred = useLocationStateStarred();
|
||||||
|
const { ui, collections } = useStores();
|
||||||
|
const inStarredSection = useStarredContext();
|
||||||
|
const [expanded, setExpanded] = React.useState(
|
||||||
|
collection.id === ui.activeCollectionId &&
|
||||||
|
locationStateStarred === inStarredSection
|
||||||
|
);
|
||||||
|
const can = usePolicy(collection.id);
|
||||||
|
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||||
|
|
||||||
|
// Drop to reorder collection
|
||||||
|
const [
|
||||||
|
{ isCollectionDropping, isDraggingAnyCollection },
|
||||||
|
dropToReorderCollection,
|
||||||
|
] = useDrop({
|
||||||
|
accept: "collection",
|
||||||
|
drop: (item: DragObject) => {
|
||||||
|
collections.move(
|
||||||
|
item.id,
|
||||||
|
fractionalIndex(collection.index, belowCollectionIndex)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
canDrop: (item) => {
|
||||||
|
return (
|
||||||
|
collection.id !== item.id &&
|
||||||
|
(!belowCollection || item.id !== belowCollection.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
collect: (monitor: DropTargetMonitor<Collection, Collection>) => ({
|
||||||
|
isCollectionDropping: monitor.isOver(),
|
||||||
|
isDraggingAnyCollection: monitor.getItemType() === "collection",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag to reorder collection
|
||||||
|
const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({
|
||||||
|
type: "collection",
|
||||||
|
item: () => {
|
||||||
|
return {
|
||||||
|
id: collection.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isCollectionDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
canDrag: () => {
|
||||||
|
return can.move;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the current collection is active and relevant to the sidebar section we
|
||||||
|
// are in then expand it automatically
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
collection.id === ui.activeCollectionId &&
|
||||||
|
locationStateStarred === inStarredSection
|
||||||
|
) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
collection.id,
|
||||||
|
ui.activeCollectionId,
|
||||||
|
locationStateStarred,
|
||||||
|
inStarredSection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDisclosureClick = React.useCallback((ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setExpanded((e) => !e);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayChildDocuments = expanded && !isCollectionDragging;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Draggable
|
||||||
|
key={collection.id}
|
||||||
|
ref={dragToReorderCollection}
|
||||||
|
$isDragging={isCollectionDragging}
|
||||||
|
>
|
||||||
|
<CollectionLink
|
||||||
|
collection={collection}
|
||||||
|
expanded={displayChildDocuments}
|
||||||
|
activeDocument={activeDocument}
|
||||||
|
onDisclosureClick={handleDisclosureClick}
|
||||||
|
isDraggingAnyCollection={isDraggingAnyCollection}
|
||||||
|
/>
|
||||||
|
</Draggable>
|
||||||
|
<Relative>
|
||||||
|
<CollectionLinkChildren
|
||||||
|
collection={collection}
|
||||||
|
expanded={displayChildDocuments}
|
||||||
|
prefetchDocument={prefetchDocument}
|
||||||
|
/>
|
||||||
|
{isDraggingAnyCollection && (
|
||||||
|
<DropCursor
|
||||||
|
isActiveDrop={isCollectionDropping}
|
||||||
|
innerRef={dropToReorderCollection}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Relative>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Draggable = styled("div")<{ $isDragging: boolean }>`
|
||||||
|
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
||||||
|
pointer-events: ${(props) => (props.$isDragging ? "none" : "auto")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(DraggableCollectionLink);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
|
||||||
|
const EmptyCollectionPlaceholder = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Empty type="tertiary" size="small">
|
||||||
|
{t("Empty")}
|
||||||
|
</Empty>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Empty = styled(Text)`
|
||||||
|
margin-left: 46px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 34px;
|
||||||
|
font-style: italic;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default EmptyCollectionPlaceholder;
|
||||||
29
app/components/Sidebar/components/Folder.tsx
Normal file
29
app/components/Sidebar/components/Folder.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
expanded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Folder: React.FC<Props> = ({ expanded, children }) => {
|
||||||
|
const [openedOnce, setOpenedOnce] = React.useState(expanded);
|
||||||
|
|
||||||
|
// allows us to avoid rendering all children when the folder hasn't been opened
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (expanded) {
|
||||||
|
setOpenedOnce(true);
|
||||||
|
}
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
|
if (!openedOnce) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Wrapper $expanded={expanded}>{children}</Wrapper>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wrapper = styled.div<{ $expanded?: boolean }>`
|
||||||
|
display: ${(props) => (props.$expanded ? "block" : "none")};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Folder;
|
||||||
7
app/components/Sidebar/components/Relative.tsx
Normal file
7
app/components/Sidebar/components/Relative.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const Relative = styled.div`
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default Relative;
|
||||||
@@ -3,7 +3,6 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
|
||||||
import Star from "~/models/Star";
|
import Star from "~/models/Star";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
@@ -11,7 +10,9 @@ import useToasts from "~/hooks/useToasts";
|
|||||||
import DropCursor from "./DropCursor";
|
import DropCursor from "./DropCursor";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import PlaceholderCollections from "./PlaceholderCollections";
|
import PlaceholderCollections from "./PlaceholderCollections";
|
||||||
|
import Relative from "./Relative";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
|
import StarredContext from "./StarredContext";
|
||||||
import StarredLink from "./StarredLink";
|
import StarredLink from "./StarredLink";
|
||||||
|
|
||||||
const STARRED_PAGINATION_LIMIT = 10;
|
const STARRED_PAGINATION_LIMIT = 10;
|
||||||
@@ -25,7 +26,7 @@ function Starred() {
|
|||||||
const [offset, setOffset] = React.useState(0);
|
const [offset, setOffset] = React.useState(0);
|
||||||
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
|
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const { stars, documents } = useStores();
|
const { stars } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const fetchResults = React.useCallback(async () => {
|
const fetchResults = React.useCallback(async () => {
|
||||||
@@ -123,59 +124,45 @@ function Starred() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex column>
|
<StarredContext.Provider value={true}>
|
||||||
<Header onClick={handleExpandClick} expanded={expanded}>
|
<Flex column>
|
||||||
{t("Starred")}
|
<Header onClick={handleExpandClick} expanded={expanded}>
|
||||||
</Header>
|
{t("Starred")}
|
||||||
{expanded && (
|
</Header>
|
||||||
<Relative>
|
{expanded && (
|
||||||
<DropCursor
|
<Relative>
|
||||||
isActiveDrop={isOverReorder}
|
<DropCursor
|
||||||
innerRef={dropToReorder}
|
isActiveDrop={isOverReorder}
|
||||||
position="top"
|
innerRef={dropToReorder}
|
||||||
/>
|
position="top"
|
||||||
{stars.orderedData.slice(0, upperBound).map((star) => {
|
/>
|
||||||
const document = documents.get(star.documentId);
|
{stars.orderedData.slice(0, upperBound).map((star) => (
|
||||||
|
<StarredLink key={star.id} star={star} />
|
||||||
return document ? (
|
))}
|
||||||
<StarredLink
|
{show === "More" && !isFetching && (
|
||||||
key={star.id}
|
<SidebarLink
|
||||||
star={star}
|
onClick={handleShowMore}
|
||||||
documentId={document.id}
|
label={`${t("Show more")}…`}
|
||||||
collectionId={document.collectionId}
|
|
||||||
to={document.url}
|
|
||||||
title={document.title}
|
|
||||||
depth={0}
|
depth={0}
|
||||||
/>
|
/>
|
||||||
) : null;
|
)}
|
||||||
})}
|
{show === "Less" && !isFetching && (
|
||||||
{show === "More" && !isFetching && (
|
<SidebarLink
|
||||||
<SidebarLink
|
onClick={handleShowLess}
|
||||||
onClick={handleShowMore}
|
label={`${t("Show less")}…`}
|
||||||
label={`${t("Show more")}…`}
|
depth={0}
|
||||||
depth={0}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{(isFetching || fetchError) && !stars.orderedData.length && (
|
||||||
{show === "Less" && !isFetching && (
|
<Flex column>
|
||||||
<SidebarLink
|
<PlaceholderCollections />
|
||||||
onClick={handleShowLess}
|
</Flex>
|
||||||
label={`${t("Show less")}…`}
|
)}
|
||||||
depth={0}
|
</Relative>
|
||||||
/>
|
)}
|
||||||
)}
|
</Flex>
|
||||||
{(isFetching || fetchError) && !stars.orderedData.length && (
|
</StarredContext.Provider>
|
||||||
<Flex column>
|
|
||||||
<PlaceholderCollections />
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Relative>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Relative = styled.div`
|
|
||||||
position: relative;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default observer(Starred);
|
export default observer(Starred);
|
||||||
|
|||||||
7
app/components/Sidebar/components/StarredContext.ts
Normal file
7
app/components/Sidebar/components/StarredContext.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const StarredContext = React.createContext<boolean | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useStarredContext = () => React.useContext(StarredContext);
|
||||||
|
|
||||||
|
export default StarredContext;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import fractionalIndex from "fractional-index";
|
import fractionalIndex from "fractional-index";
|
||||||
|
import { Location } from "history";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { StarredIcon } from "outline-icons";
|
import { StarredIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useDrag, useDrop } from "react-dnd";
|
import { useDrag, useDrop } from "react-dnd";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import Star from "~/models/Star";
|
import Star from "~/models/Star";
|
||||||
@@ -12,46 +14,51 @@ import Fade from "~/components/Fade";
|
|||||||
import useBoolean from "~/hooks/useBoolean";
|
import useBoolean from "~/hooks/useBoolean";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import DocumentMenu from "~/menus/DocumentMenu";
|
import DocumentMenu from "~/menus/DocumentMenu";
|
||||||
|
import CollectionLink from "./CollectionLink";
|
||||||
|
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||||
|
import DocumentLink from "./DocumentLink";
|
||||||
import DropCursor from "./DropCursor";
|
import DropCursor from "./DropCursor";
|
||||||
|
import Folder from "./Folder";
|
||||||
|
import Relative from "./Relative";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
star?: Star;
|
star: Star;
|
||||||
depth: number;
|
|
||||||
title: string;
|
|
||||||
to: string;
|
|
||||||
documentId: string;
|
|
||||||
collectionId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function StarredLink({
|
function useLocationStateStarred() {
|
||||||
depth,
|
const location = useLocation<{
|
||||||
to,
|
starred?: boolean;
|
||||||
documentId,
|
}>();
|
||||||
title,
|
return location.state?.starred;
|
||||||
collectionId,
|
}
|
||||||
star,
|
|
||||||
}: Props) {
|
function StarredLink({ star }: Props) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { collections, documents } = useStores();
|
const { ui, collections, documents } = useStores();
|
||||||
const collection = collections.get(collectionId);
|
|
||||||
const document = documents.get(documentId);
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||||
const childDocuments = collection
|
const { documentId, collectionId } = star;
|
||||||
? collection.getDocumentChildren(documentId)
|
const collection = collections.get(collectionId);
|
||||||
: [];
|
const locationStateStarred = useLocationStateStarred();
|
||||||
const hasChildDocuments = childDocuments.length > 0;
|
const [expanded, setExpanded] = useState(
|
||||||
|
star.collectionId === ui.activeCollectionId && !!locationStateStarred
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (star.collectionId === ui.activeCollectionId && locationStateStarred) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [star.collectionId, ui.activeCollectionId, locationStateStarred]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!document) {
|
if (documentId) {
|
||||||
await documents.fetch(documentId);
|
await documents.fetch(documentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [collection, collectionId, collections, document, documentId, documents]);
|
}, [documentId, documents]);
|
||||||
|
|
||||||
const handleDisclosureClick = React.useCallback(
|
const handleDisclosureClick = React.useCallback(
|
||||||
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
(ev: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
@@ -69,9 +76,7 @@ function StarredLink({
|
|||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
isDragging: !!monitor.isDragging(),
|
isDragging: !!monitor.isDragging(),
|
||||||
}),
|
}),
|
||||||
canDrag: () => {
|
canDrag: () => true,
|
||||||
return depth === 0;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drop to reorder
|
// Drop to reorder
|
||||||
@@ -90,61 +95,109 @@ function StarredLink({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { emoji } = parseTitle(title);
|
const displayChildDocuments = expanded && !isDragging;
|
||||||
const label = emoji ? title.replace(emoji, "") : title;
|
|
||||||
|
|
||||||
return (
|
if (documentId) {
|
||||||
<>
|
const document = documents.get(documentId);
|
||||||
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
|
if (!document) {
|
||||||
<SidebarLink
|
return null;
|
||||||
depth={depth}
|
}
|
||||||
expanded={hasChildDocuments ? expanded : undefined}
|
|
||||||
onDisclosureClick={handleDisclosureClick}
|
const collection = collections.get(document.collectionId);
|
||||||
to={`${to}?starred`}
|
const { emoji } = parseTitle(document.title);
|
||||||
icon={
|
const label = emoji
|
||||||
depth === 0 ? (
|
? document.title.replace(emoji, "")
|
||||||
|
: document.titleWithDefault;
|
||||||
|
const childDocuments = collection
|
||||||
|
? collection.getDocumentChildren(documentId)
|
||||||
|
: [];
|
||||||
|
const hasChildDocuments = childDocuments.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Draggable key={star.id} ref={drag} $isDragging={isDragging}>
|
||||||
|
<SidebarLink
|
||||||
|
depth={0}
|
||||||
|
to={{
|
||||||
|
pathname: document.url,
|
||||||
|
state: { starred: true },
|
||||||
|
}}
|
||||||
|
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||||
|
onDisclosureClick={handleDisclosureClick}
|
||||||
|
icon={
|
||||||
emoji ? (
|
emoji ? (
|
||||||
<EmojiIcon emoji={emoji} />
|
<EmojiIcon emoji={emoji} />
|
||||||
) : (
|
) : (
|
||||||
<StarredIcon color={theme.yellow} />
|
<StarredIcon color={theme.yellow} />
|
||||||
)
|
)
|
||||||
) : undefined
|
}
|
||||||
}
|
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||||
isActive={(match, location) =>
|
!!match && location.state?.starred === true
|
||||||
!!match && location.search === "?starred"
|
}
|
||||||
}
|
label={label}
|
||||||
label={depth === 0 ? label : title}
|
exact={false}
|
||||||
exact={false}
|
showActions={menuOpen}
|
||||||
showActions={menuOpen}
|
menu={
|
||||||
menu={
|
document && !isDragging ? (
|
||||||
document ? (
|
<Fade>
|
||||||
<Fade>
|
<DocumentMenu
|
||||||
<DocumentMenu
|
document={document}
|
||||||
document={document}
|
onOpen={handleMenuOpen}
|
||||||
onOpen={handleMenuOpen}
|
onClose={handleMenuClose}
|
||||||
onClose={handleMenuClose}
|
/>
|
||||||
/>
|
</Fade>
|
||||||
</Fade>
|
) : undefined
|
||||||
) : undefined
|
}
|
||||||
}
|
|
||||||
/>
|
|
||||||
{isDraggingAny && (
|
|
||||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
{expanded &&
|
|
||||||
childDocuments.map((childDocument) => (
|
|
||||||
<ObserveredStarredLink
|
|
||||||
key={childDocument.id}
|
|
||||||
depth={depth === 0 ? 2 : depth + 1}
|
|
||||||
title={childDocument.title}
|
|
||||||
to={childDocument.url}
|
|
||||||
documentId={childDocument.id}
|
|
||||||
collectionId={collectionId}
|
|
||||||
/>
|
/>
|
||||||
))}
|
</Draggable>
|
||||||
</>
|
<Relative>
|
||||||
);
|
<Folder expanded={displayChildDocuments}>
|
||||||
|
{childDocuments.map((node, index) => (
|
||||||
|
<DocumentLink
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
collection={collection}
|
||||||
|
activeDocument={documents.active}
|
||||||
|
isDraft={node.isDraft}
|
||||||
|
depth={2}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Folder>
|
||||||
|
{isDraggingAny && (
|
||||||
|
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||||
|
)}
|
||||||
|
</Relative>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collection) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Draggable key={star?.id} ref={drag} $isDragging={isDragging}>
|
||||||
|
<CollectionLink
|
||||||
|
collection={collection}
|
||||||
|
expanded={isDragging ? undefined : displayChildDocuments}
|
||||||
|
activeDocument={documents.active}
|
||||||
|
onDisclosureClick={handleDisclosureClick}
|
||||||
|
isDraggingAnyCollection={isDraggingAny}
|
||||||
|
/>
|
||||||
|
</Draggable>
|
||||||
|
<Relative>
|
||||||
|
<CollectionLinkChildren
|
||||||
|
collection={collection}
|
||||||
|
expanded={displayChildDocuments}
|
||||||
|
/>
|
||||||
|
{isDraggingAny && (
|
||||||
|
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||||
|
)}
|
||||||
|
</Relative>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Draggable = styled.div<{ $isDragging?: boolean }>`
|
const Draggable = styled.div<{ $isDragging?: boolean }>`
|
||||||
@@ -152,6 +205,4 @@ const Draggable = styled.div<{ $isDragging?: boolean }>`
|
|||||||
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ObserveredStarredLink = observer(StarredLink);
|
export default observer(StarredLink);
|
||||||
|
|
||||||
export default ObserveredStarredLink;
|
|
||||||
|
|||||||
39
app/components/Sidebar/components/useCollectionDocuments.ts
Normal file
39
app/components/Sidebar/components/useCollectionDocuments.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
|
import Document from "~/models/Document";
|
||||||
|
|
||||||
|
export default function useCollectionDocuments(
|
||||||
|
collection: Collection | undefined,
|
||||||
|
activeDocument: Document | undefined
|
||||||
|
) {
|
||||||
|
return React.useMemo(() => {
|
||||||
|
if (!collection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
activeDocument?.isActive &&
|
||||||
|
activeDocument?.isDraft &&
|
||||||
|
activeDocument?.collectionId === collection.id &&
|
||||||
|
!activeDocument?.parentDocumentId
|
||||||
|
) {
|
||||||
|
return sortNavigationNodes(
|
||||||
|
[activeDocument.asNavigationNode, ...collection.documents],
|
||||||
|
collection.sort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collection.documents;
|
||||||
|
}, [
|
||||||
|
activeDocument?.isActive,
|
||||||
|
activeDocument?.isDraft,
|
||||||
|
activeDocument?.collectionId,
|
||||||
|
activeDocument?.parentDocumentId,
|
||||||
|
activeDocument?.asNavigationNode,
|
||||||
|
collection,
|
||||||
|
collection?.documents,
|
||||||
|
collection?.id,
|
||||||
|
collection?.sort,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,35 +1,56 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import {
|
||||||
|
starCollection,
|
||||||
|
unstarCollection,
|
||||||
|
} from "~/actions/definitions/collections";
|
||||||
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
import { starDocument, unstarDocument } from "~/actions/definitions/documents";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import NudeButton from "./NudeButton";
|
import NudeButton from "./NudeButton";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: Document;
|
collection?: Collection;
|
||||||
|
document?: Document;
|
||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Star({ size, document, ...rest }: Props) {
|
function Star({ size, document, collection, ...rest }: Props) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const context = useActionContext({
|
const context = useActionContext({
|
||||||
activeDocumentId: document.id,
|
activeDocumentId: document?.id,
|
||||||
|
activeCollectionId: collection?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!document) {
|
const target = document || collection;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NudeButton
|
<NudeButton
|
||||||
context={context}
|
context={context}
|
||||||
action={document.isStarred ? unstarDocument : starDocument}
|
hideOnActionDisabled
|
||||||
|
action={
|
||||||
|
collection
|
||||||
|
? collection.isStarred
|
||||||
|
? unstarCollection
|
||||||
|
: starCollection
|
||||||
|
: document
|
||||||
|
? document.isStarred
|
||||||
|
? unstarDocument
|
||||||
|
: starDocument
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
size={size}
|
size={size}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{document.isStarred ? (
|
{target.isStarred ? (
|
||||||
<AnimatedStar size={size} color={theme.yellow} />
|
<AnimatedStar size={size} color={theme.yellow} />
|
||||||
) : (
|
) : (
|
||||||
<AnimatedStar
|
<AnimatedStar
|
||||||
@@ -58,4 +79,4 @@ export const AnimatedStar = styled(StarredIcon)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Star;
|
export default observer(Star);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
PadlockIcon,
|
PadlockIcon,
|
||||||
AlphabeticalSortIcon,
|
AlphabeticalSortIcon,
|
||||||
ManualSortIcon,
|
ManualSortIcon,
|
||||||
|
UnstarredIcon,
|
||||||
|
StarredIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -151,11 +153,46 @@ function CollectionMenu({
|
|||||||
});
|
});
|
||||||
}, [dialogs, t, collection]);
|
}, [dialogs, t, collection]);
|
||||||
|
|
||||||
|
const handleStar = React.useCallback(
|
||||||
|
(ev: React.SyntheticEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
collection.star();
|
||||||
|
},
|
||||||
|
[collection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnstar = React.useCallback(
|
||||||
|
(ev: React.SyntheticEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
collection.unstar();
|
||||||
|
},
|
||||||
|
[collection]
|
||||||
|
);
|
||||||
|
|
||||||
const alphabeticalSort = collection.sort.field === "title";
|
const alphabeticalSort = collection.sort.field === "title";
|
||||||
const can = usePolicy(collection.id);
|
const can = usePolicy(collection.id);
|
||||||
const canUserInTeam = usePolicy(team.id);
|
const canUserInTeam = usePolicy(team.id);
|
||||||
const items: MenuItem[] = React.useMemo(
|
const items: MenuItem[] = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
title: t("Unstar"),
|
||||||
|
onClick: handleUnstar,
|
||||||
|
visible: collection.isStarred && !!can.unstar,
|
||||||
|
icon: <UnstarredIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
title: t("Star"),
|
||||||
|
onClick: handleStar,
|
||||||
|
visible: !collection.isStarred && !!can.star,
|
||||||
|
icon: <StarredIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "separator",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
title: t("New document"),
|
title: t("New document"),
|
||||||
@@ -234,6 +271,10 @@ function CollectionMenu({
|
|||||||
t,
|
t,
|
||||||
can.update,
|
can.update,
|
||||||
can.delete,
|
can.delete,
|
||||||
|
can.star,
|
||||||
|
can.unstar,
|
||||||
|
handleStar,
|
||||||
|
handleUnstar,
|
||||||
alphabeticalSort,
|
alphabeticalSort,
|
||||||
handleChangeSort,
|
handleChangeSort,
|
||||||
handleNewDocument,
|
handleNewDocument,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { trim } from "lodash";
|
import { trim } from "lodash";
|
||||||
import { action, computed, observable } from "mobx";
|
import { action, computed, observable } from "mobx";
|
||||||
|
import CollectionsStore from "~/stores/CollectionsStore";
|
||||||
import BaseModel from "~/models/BaseModel";
|
import BaseModel from "~/models/BaseModel";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
@@ -7,6 +8,8 @@ import { client } from "~/utils/ApiClient";
|
|||||||
import Field from "./decorators/Field";
|
import Field from "./decorators/Field";
|
||||||
|
|
||||||
export default class Collection extends BaseModel {
|
export default class Collection extends BaseModel {
|
||||||
|
store: CollectionsStore;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
|
||||||
@@ -91,6 +94,13 @@ export default class Collection extends BaseModel {
|
|||||||
return !!trim(this.description, "\\").trim();
|
return !!trim(this.description, "\\").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get isStarred(): boolean {
|
||||||
|
return !!this.store.rootStore.stars.orderedData.find(
|
||||||
|
(star) => star.collectionId === this.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateDocument(document: Document) {
|
updateDocument(document: Document) {
|
||||||
const travelNodes = (nodes: NavigationNode[]) =>
|
const travelNodes = (nodes: NavigationNode[]) =>
|
||||||
@@ -167,6 +177,16 @@ export default class Collection extends BaseModel {
|
|||||||
return path || [];
|
return path || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
star = async () => {
|
||||||
|
return this.store.star(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
unstar = async () => {
|
||||||
|
return this.store.unstar(this);
|
||||||
|
};
|
||||||
|
|
||||||
export = () => {
|
export = () => {
|
||||||
return client.get("/collections.export", {
|
return client.get("/collections.export", {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class Star extends BaseModel {
|
|||||||
|
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
|
||||||
|
collectionId: string;
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
|||||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||||
import PlaceholderText from "~/components/PlaceholderText";
|
import PlaceholderText from "~/components/PlaceholderText";
|
||||||
import Scene from "~/components/Scene";
|
import Scene from "~/components/Scene";
|
||||||
|
import Star, { AnimatedStar } from "~/components/Star";
|
||||||
import Tab from "~/components/Tab";
|
import Tab from "~/components/Tab";
|
||||||
import Tabs from "~/components/Tabs";
|
import Tabs from "~/components/Tabs";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
@@ -127,7 +128,7 @@ function CollectionScene() {
|
|||||||
<Empty collection={collection} />
|
<Empty collection={collection} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<HeadingWithIcon>
|
<HeadingWithIcon $isStarred={collection.isStarred}>
|
||||||
<HeadingIcon collection={collection} size={40} expanded />
|
<HeadingIcon collection={collection} size={40} expanded />
|
||||||
{collection.name}
|
{collection.name}
|
||||||
{!collection.permission && (
|
{!collection.permission && (
|
||||||
@@ -140,6 +141,7 @@ function CollectionScene() {
|
|||||||
<Badge>{t("Private")}</Badge>
|
<Badge>{t("Private")}</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
<StarButton collection={collection} size={32} />
|
||||||
</HeadingWithIcon>
|
</HeadingWithIcon>
|
||||||
<CollectionDescription collection={collection} />
|
<CollectionDescription collection={collection} />
|
||||||
|
|
||||||
@@ -247,10 +249,37 @@ function CollectionScene() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeadingWithIcon = styled(Heading)`
|
const StarButton = styled(Star)`
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
left: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 24px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: relative;
|
||||||
|
left: -4px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeadingWithIcon = styled(Heading)<{ $isStarred: boolean }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
${AnimatedStar} {
|
||||||
|
opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
${AnimatedStar} {
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
margin-left: -40px;
|
margin-left: -40px;
|
||||||
`};
|
`};
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onChangeTitle = action((value: string) => {
|
onChangeTitle = action((value: string) => {
|
||||||
|
this.title = value;
|
||||||
this.props.document.title = value;
|
this.props.document.title = value;
|
||||||
this.updateIsDirty();
|
this.updateIsDirty();
|
||||||
this.autosave();
|
this.autosave();
|
||||||
@@ -448,7 +449,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{this.props.location.pathname !== canonicalUrl && (
|
{this.props.location.pathname !== canonicalUrl && (
|
||||||
<Redirect to={canonicalUrl} />
|
<Redirect
|
||||||
|
to={{
|
||||||
|
pathname: canonicalUrl,
|
||||||
|
state: this.props.location.state,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<RegisterKeyDown trigger="m" handler={this.goToMove} />
|
<RegisterKeyDown trigger="m" handler={this.goToMove} />
|
||||||
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
|
<RegisterKeyDown trigger="e" handler={this.goToEdit} />
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import Document from "~/models/Document";
|
|||||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||||
import Star, { AnimatedStar } from "~/components/Star";
|
import Star, { AnimatedStar } from "~/components/Star";
|
||||||
import useEmojiWidth from "~/hooks/useEmojiWidth";
|
import useEmojiWidth from "~/hooks/useEmojiWidth";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
|
||||||
import { isModKey } from "~/utils/keyboard";
|
import { isModKey } from "~/utils/keyboard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -49,7 +48,6 @@ const EditableTitle = React.forwardRef(
|
|||||||
}: Props,
|
}: Props,
|
||||||
ref: React.RefObject<RefHandle>
|
ref: React.RefObject<RefHandle>
|
||||||
) => {
|
) => {
|
||||||
const can = usePolicy(document.id);
|
|
||||||
const normalizedTitle =
|
const normalizedTitle =
|
||||||
!value && readOnly ? document.titleWithDefault : value;
|
!value && readOnly ? document.titleWithDefault : value;
|
||||||
|
|
||||||
@@ -135,9 +133,7 @@ const EditableTitle = React.forwardRef(
|
|||||||
dir="auto"
|
dir="auto"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{(can.star || can.unstar) && starrable !== false && (
|
{starrable !== false && <StarButton document={document} size={32} />}
|
||||||
<StarButton document={document} size={32} />
|
|
||||||
)}
|
|
||||||
</Title>
|
</Title>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function DocumentNew() {
|
|||||||
title: "",
|
title: "",
|
||||||
text: "",
|
text: "",
|
||||||
});
|
});
|
||||||
history.replace(editDocumentUrl(document));
|
history.replace(editDocumentUrl(document), location.state);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(t("Couldn’t create the document, try again?"), {
|
showToast(t("Couldn’t create the document, try again?"), {
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
@@ -174,6 +174,19 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
star = async (collection: Collection) => {
|
||||||
|
await this.rootStore.stars.create({
|
||||||
|
collectionId: collection.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
unstar = async (collection: Collection) => {
|
||||||
|
const star = this.rootStore.stars.orderedData.find(
|
||||||
|
(star) => star.collectionId === collection.id
|
||||||
|
);
|
||||||
|
await star?.delete();
|
||||||
|
};
|
||||||
|
|
||||||
getPathForDocument(documentId: string): DocumentPath | undefined {
|
getPathForDocument(documentId: string): DocumentPath | undefined {
|
||||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type ActionContext = {
|
|||||||
isContextMenu: boolean;
|
isContextMenu: boolean;
|
||||||
isCommandBar: boolean;
|
isCommandBar: boolean;
|
||||||
isButton: boolean;
|
isButton: boolean;
|
||||||
|
inStarredSection?: boolean;
|
||||||
activeCollectionId: string | undefined;
|
activeCollectionId: string | undefined;
|
||||||
activeDocumentId: string | undefined;
|
activeDocumentId: string | undefined;
|
||||||
currentUserId: string | undefined;
|
currentUserId: string | undefined;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sequelize } from "@server/database/sequelize";
|
||||||
import { Star, Event } from "@server/models";
|
import { Star, Event } from "@server/models";
|
||||||
import { buildDocument, buildUser } from "@server/test/factories";
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
import { flushdb } from "@server/test/support";
|
import { flushdb } from "@server/test/support";
|
||||||
@@ -14,11 +15,14 @@ describe("starCreator", () => {
|
|||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const star = await starCreator({
|
const star = await sequelize.transaction(async (transaction) =>
|
||||||
documentId: document.id,
|
starCreator({
|
||||||
user,
|
documentId: document.id,
|
||||||
ip,
|
user,
|
||||||
});
|
ip,
|
||||||
|
transaction,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const event = await Event.findOne();
|
const event = await Event.findOne();
|
||||||
expect(star.documentId).toEqual(document.id);
|
expect(star.documentId).toEqual(document.id);
|
||||||
@@ -43,11 +47,14 @@ describe("starCreator", () => {
|
|||||||
index: "P",
|
index: "P",
|
||||||
});
|
});
|
||||||
|
|
||||||
const star = await starCreator({
|
const star = await sequelize.transaction(async (transaction) =>
|
||||||
documentId: document.id,
|
starCreator({
|
||||||
user,
|
documentId: document.id,
|
||||||
ip,
|
user,
|
||||||
});
|
ip,
|
||||||
|
transaction,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const events = await Event.count();
|
const events = await Event.count();
|
||||||
expect(star.documentId).toEqual(document.id);
|
expect(star.documentId).toEqual(document.id);
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import fractionalIndex from "fractional-index";
|
import fractionalIndex from "fractional-index";
|
||||||
import { Sequelize, WhereOptions } from "sequelize";
|
import { Sequelize, Transaction, WhereOptions } from "sequelize";
|
||||||
import { sequelize } from "@server/database/sequelize";
|
|
||||||
import { Star, User, Event } from "@server/models";
|
import { Star, User, Event } from "@server/models";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The user creating the star */
|
/** The user creating the star */
|
||||||
user: User;
|
user: User;
|
||||||
/** The document to star */
|
/** The document to star */
|
||||||
documentId: string;
|
documentId?: string;
|
||||||
|
/** The collection to star */
|
||||||
|
collectionId?: string;
|
||||||
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
|
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
|
||||||
index?: string;
|
index?: string;
|
||||||
/** The IP address of the user creating the star */
|
/** The IP address of the user creating the star */
|
||||||
ip: string;
|
ip: string;
|
||||||
|
transaction: Transaction;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +26,9 @@ type Props = {
|
|||||||
export default async function starCreator({
|
export default async function starCreator({
|
||||||
user,
|
user,
|
||||||
documentId,
|
documentId,
|
||||||
|
collectionId,
|
||||||
ip,
|
ip,
|
||||||
|
transaction,
|
||||||
...rest
|
...rest
|
||||||
}: Props): Promise<Star> {
|
}: Props): Promise<Star> {
|
||||||
let { index } = rest;
|
let { index } = rest;
|
||||||
@@ -43,46 +47,43 @@ export default async function starCreator({
|
|||||||
Sequelize.literal('"star"."index" collate "C"'),
|
Sequelize.literal('"star"."index" collate "C"'),
|
||||||
["updatedAt", "DESC"],
|
["updatedAt", "DESC"],
|
||||||
],
|
],
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
// create a star at the beginning of the list
|
// create a star at the beginning of the list
|
||||||
index = fractionalIndex(null, stars.length ? stars[0].index : null);
|
index = fractionalIndex(null, stars.length ? stars[0].index : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction = await sequelize.transaction();
|
const response = await Star.findOrCreate({
|
||||||
let star;
|
where: documentId
|
||||||
|
? {
|
||||||
try {
|
|
||||||
const response = await Star.findOrCreate({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
documentId,
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
index,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
star = response[0];
|
|
||||||
|
|
||||||
if (response[1]) {
|
|
||||||
await Event.create(
|
|
||||||
{
|
|
||||||
name: "stars.create",
|
|
||||||
modelId: star.id,
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
actorId: user.id,
|
|
||||||
documentId,
|
documentId,
|
||||||
ip,
|
}
|
||||||
|
: {
|
||||||
|
userId: user.id,
|
||||||
|
collectionId,
|
||||||
},
|
},
|
||||||
{ transaction }
|
defaults: {
|
||||||
);
|
index,
|
||||||
}
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
const star = response[0];
|
||||||
|
|
||||||
await transaction.commit();
|
if (response[1]) {
|
||||||
} catch (err) {
|
await Event.create(
|
||||||
await transaction.rollback();
|
{
|
||||||
throw err;
|
name: "stars.create",
|
||||||
|
modelId: star.id,
|
||||||
|
userId: user.id,
|
||||||
|
actorId: user.id,
|
||||||
|
documentId,
|
||||||
|
collectionId,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return star;
|
return star;
|
||||||
|
|||||||
34
server/migrations/20220402032204-starred-collections.js
Normal file
34
server/migrations/20220402032204-starred-collections.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("stars", "collectionId", {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: "collections",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn("stars", "documentId", {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn("stars", "documentId", {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
references: {
|
||||||
|
model: "documents",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn("stars", "userId", {
|
||||||
|
type: Sequelize.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: "users",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await queryInterface.removeColumn("stars", "collectionId");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ForeignKey,
|
ForeignKey,
|
||||||
Table,
|
Table,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
|
import Collection from "./Collection";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
import BaseModel from "./base/BaseModel";
|
import BaseModel from "./base/BaseModel";
|
||||||
@@ -26,11 +27,18 @@ class Star extends BaseModel {
|
|||||||
userId: string;
|
userId: string;
|
||||||
|
|
||||||
@BelongsTo(() => Document, "documentId")
|
@BelongsTo(() => Document, "documentId")
|
||||||
document: Document;
|
document: Document | null;
|
||||||
|
|
||||||
@ForeignKey(() => Document)
|
@ForeignKey(() => Document)
|
||||||
@Column(DataType.UUID)
|
@Column(DataType.UUID)
|
||||||
documentId: string;
|
documentId: string | null;
|
||||||
|
|
||||||
|
@BelongsTo(() => Collection, "collectionId")
|
||||||
|
collection: Collection | null;
|
||||||
|
|
||||||
|
@ForeignKey(() => Collection)
|
||||||
|
@Column(DataType.UUID)
|
||||||
|
collectionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Star;
|
export default Star;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ allow(User, "move", Collection, (user, collection) => {
|
|||||||
throw AdminRequiredError();
|
throw AdminRequiredError();
|
||||||
});
|
});
|
||||||
|
|
||||||
allow(User, "read", Collection, (user, collection) => {
|
allow(User, ["read", "star", "unstar"], Collection, (user, collection) => {
|
||||||
if (!collection || user.teamId !== collection.teamId) {
|
if (!collection || user.teamId !== collection.teamId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default function present(star: Star) {
|
|||||||
return {
|
return {
|
||||||
id: star.id,
|
id: star.id,
|
||||||
documentId: star.documentId,
|
documentId: star.documentId,
|
||||||
|
collectionId: star.collectionId,
|
||||||
index: star.index,
|
index: star.index,
|
||||||
createdAt: star.createdAt,
|
createdAt: star.createdAt,
|
||||||
updatedAt: star.updatedAt,
|
updatedAt: star.updatedAt,
|
||||||
|
|||||||
@@ -53,15 +53,6 @@ Object {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`#documents.starred should require authentication 1`] = `
|
|
||||||
Object {
|
|
||||||
"error": "authentication_required",
|
|
||||||
"message": "Authentication required",
|
|
||||||
"ok": false,
|
|
||||||
"status": 401,
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`#documents.unstar should require authentication 1`] = `
|
exports[`#documents.unstar should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"error": "authentication_required",
|
||||||
|
|||||||
@@ -1455,45 +1455,6 @@ describe("#documents.viewed", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("#documents.starred", () => {
|
|
||||||
it("should return empty result if no stars", async () => {
|
|
||||||
const { user } = await seed();
|
|
||||||
const res = await server.post("/api/documents.starred", {
|
|
||||||
body: {
|
|
||||||
token: user.getJwtToken(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
expect(res.status).toEqual(200);
|
|
||||||
expect(body.data.length).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return starred documents", async () => {
|
|
||||||
const { user, document } = await seed();
|
|
||||||
await Star.create({
|
|
||||||
documentId: document.id,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
const res = await server.post("/api/documents.starred", {
|
|
||||||
body: {
|
|
||||||
token: user.getJwtToken(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
expect(res.status).toEqual(200);
|
|
||||||
expect(body.data.length).toEqual(1);
|
|
||||||
expect(body.data[0].id).toEqual(document.id);
|
|
||||||
expect(body.policies[0].abilities.update).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should require authentication", async () => {
|
|
||||||
const res = await server.post("/api/documents.starred");
|
|
||||||
const body = await res.json();
|
|
||||||
expect(res.status).toEqual(401);
|
|
||||||
expect(body).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("#documents.move", () => {
|
describe("#documents.move", () => {
|
||||||
it("should move the document", async () => {
|
it("should move the document", async () => {
|
||||||
const { user, document } = await seed();
|
const { user, document } = await seed();
|
||||||
|
|||||||
@@ -313,62 +313,6 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deprecated – use stars.list instead
|
|
||||||
router.post("documents.starred", auth(), pagination(), async (ctx) => {
|
|
||||||
let { direction } = ctx.body;
|
|
||||||
const { sort = "updatedAt" } = ctx.body;
|
|
||||||
|
|
||||||
assertSort(sort, Document);
|
|
||||||
if (direction !== "ASC") {
|
|
||||||
direction = "DESC";
|
|
||||||
}
|
|
||||||
const { user } = ctx.state;
|
|
||||||
const collectionIds = await user.collectionIds();
|
|
||||||
const stars = await Star.findAll({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
order: [[sort, direction]],
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Document,
|
|
||||||
where: {
|
|
||||||
collectionId: collectionIds,
|
|
||||||
},
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Collection.scope({
|
|
||||||
method: ["withMembership", user.id],
|
|
||||||
}),
|
|
||||||
as: "collection",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Star,
|
|
||||||
as: "starred",
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
offset: ctx.state.pagination.offset,
|
|
||||||
limit: ctx.state.pagination.limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documents = stars.map((star) => star.document);
|
|
||||||
const data = await Promise.all(
|
|
||||||
documents.map((document) => presentDocument(document))
|
|
||||||
);
|
|
||||||
const policies = presentPolicies(user, documents);
|
|
||||||
|
|
||||||
ctx.body = {
|
|
||||||
pagination: ctx.state.pagination,
|
|
||||||
data,
|
|
||||||
policies,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("documents.drafts", auth(), pagination(), async (ctx) => {
|
router.post("documents.drafts", auth(), pagination(), async (ctx) => {
|
||||||
let { direction } = ctx.body;
|
let { direction } = ctx.body;
|
||||||
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body;
|
const { collectionId, dateFilter, sort = "updatedAt" } = ctx.body;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { Sequelize } from "sequelize";
|
|||||||
import starCreator from "@server/commands/starCreator";
|
import starCreator from "@server/commands/starCreator";
|
||||||
import starDestroyer from "@server/commands/starDestroyer";
|
import starDestroyer from "@server/commands/starDestroyer";
|
||||||
import starUpdater from "@server/commands/starUpdater";
|
import starUpdater from "@server/commands/starUpdater";
|
||||||
|
import { sequelize } from "@server/database/sequelize";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { Document, Star } from "@server/models";
|
import { Document, Star, Collection } from "@server/models";
|
||||||
import { authorize } from "@server/policies";
|
import { authorize } from "@server/policies";
|
||||||
import {
|
import {
|
||||||
presentStar,
|
presentStar,
|
||||||
@@ -18,27 +19,43 @@ import pagination from "./middlewares/pagination";
|
|||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post("stars.create", auth(), async (ctx) => {
|
router.post("stars.create", auth(), async (ctx) => {
|
||||||
const { documentId } = ctx.body;
|
const { documentId, collectionId } = ctx.body;
|
||||||
const { index } = ctx.body;
|
const { index } = ctx.body;
|
||||||
assertUuid(documentId, "documentId is required");
|
|
||||||
|
|
||||||
const { user } = ctx.state;
|
const { user } = ctx.state;
|
||||||
const document = await Document.findByPk(documentId, {
|
|
||||||
userId: user.id,
|
assertUuid(
|
||||||
});
|
documentId || collectionId,
|
||||||
authorize(user, "star", document);
|
"documentId or collectionId is required"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (documentId) {
|
||||||
|
const document = await Document.findByPk(documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
authorize(user, "star", document);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
const collection = await Collection.scope({
|
||||||
|
method: ["withMembership", user.id],
|
||||||
|
}).findByPk(collectionId);
|
||||||
|
authorize(user, "star", collection);
|
||||||
|
}
|
||||||
|
|
||||||
if (index) {
|
if (index) {
|
||||||
assertIndexCharacters(index);
|
assertIndexCharacters(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
const star = await starCreator({
|
const star = await sequelize.transaction(async (transaction) =>
|
||||||
user,
|
starCreator({
|
||||||
documentId,
|
user,
|
||||||
ip: ctx.request.ip,
|
documentId,
|
||||||
index,
|
collectionId,
|
||||||
});
|
ip: ctx.request.ip,
|
||||||
|
index,
|
||||||
|
transaction,
|
||||||
|
})
|
||||||
|
);
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentStar(star),
|
data: presentStar(star),
|
||||||
policies: presentPolicies(user, [star]),
|
policies: presentPolicies(user, [star]),
|
||||||
@@ -72,12 +89,17 @@ router.post("stars.list", auth(), pagination(), async (ctx) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
const documentIds = stars
|
||||||
where: {
|
.map((star) => star.documentId)
|
||||||
id: stars.map((star) => star.documentId),
|
.filter(Boolean) as string[];
|
||||||
collectionId: collectionIds,
|
const documents = documentIds.length
|
||||||
},
|
? await Document.defaultScopeWithUser(user.id).findAll({
|
||||||
});
|
where: {
|
||||||
|
id: documentIds,
|
||||||
|
collectionId: collectionIds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const policies = presentPolicies(user, [...documents, ...stars]);
|
const policies = presentPolicies(user, [...documents, ...stars]);
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export async function starIndexing(
|
|||||||
const documents = await Document.findAll({
|
const documents = await Document.findAll({
|
||||||
attributes: ["id", "updatedAt"],
|
attributes: ["id", "updatedAt"],
|
||||||
where: {
|
where: {
|
||||||
id: stars.map((star) => star.documentId),
|
id: stars.map((star) => star.documentId).filter(Boolean) as string[],
|
||||||
},
|
},
|
||||||
order: [["updatedAt", "DESC"]],
|
order: [["updatedAt", "DESC"]],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
"New collection": "New collection",
|
"New collection": "New collection",
|
||||||
"Create a collection": "Create a collection",
|
"Create a collection": "Create a collection",
|
||||||
"Edit collection": "Edit collection",
|
"Edit collection": "Edit collection",
|
||||||
|
"Star": "Star",
|
||||||
|
"Unstar": "Unstar",
|
||||||
"Delete IndexedDB cache": "Delete IndexedDB cache",
|
"Delete IndexedDB cache": "Delete IndexedDB cache",
|
||||||
"IndexedDB cache deleted": "IndexedDB cache deleted",
|
"IndexedDB cache deleted": "IndexedDB cache deleted",
|
||||||
"Development": "Development",
|
"Development": "Development",
|
||||||
"Open document": "Open document",
|
"Open document": "Open document",
|
||||||
"New document": "New document",
|
"New document": "New document",
|
||||||
"Star": "Star",
|
|
||||||
"Unstar": "Unstar",
|
|
||||||
"Download": "Download",
|
"Download": "Download",
|
||||||
"Download document": "Download document",
|
"Download document": "Download document",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
@@ -138,6 +138,7 @@
|
|||||||
"Documents": "Documents",
|
"Documents": "Documents",
|
||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
"Document archived": "Document archived",
|
"Document archived": "Document archived",
|
||||||
|
"Empty": "Empty",
|
||||||
"Move document": "Move document",
|
"Move document": "Move document",
|
||||||
"Collections": "Collections",
|
"Collections": "Collections",
|
||||||
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
||||||
|
|||||||
Reference in New Issue
Block a user