feat: Add reordering to starred documents (#2953)
* draft * reordering * JIT Index stars on first load * test * Remove unused code on client * small unrefactor
This commit is contained in:
@@ -73,7 +73,7 @@ function Collections() {
|
|||||||
<DropCursor
|
<DropCursor
|
||||||
isActiveDrop={isCollectionDropping}
|
isActiveDrop={isCollectionDropping}
|
||||||
innerRef={dropToReorderCollection}
|
innerRef={dropToReorderCollection}
|
||||||
from="collections"
|
position="top"
|
||||||
/>
|
/>
|
||||||
{orderedCollections.map((collection: Collection, index: number) => (
|
{orderedCollections.map((collection: Collection, index: number) => (
|
||||||
<CollectionLink
|
<CollectionLink
|
||||||
|
|||||||
@@ -4,27 +4,26 @@ import styled from "styled-components";
|
|||||||
function DropCursor({
|
function DropCursor({
|
||||||
isActiveDrop,
|
isActiveDrop,
|
||||||
innerRef,
|
innerRef,
|
||||||
from,
|
position,
|
||||||
}: {
|
}: {
|
||||||
isActiveDrop: boolean;
|
isActiveDrop: boolean;
|
||||||
innerRef: React.Ref<HTMLDivElement>;
|
innerRef: React.Ref<HTMLDivElement>;
|
||||||
from?: string;
|
position?: "top";
|
||||||
}) {
|
}) {
|
||||||
return <Cursor isOver={isActiveDrop} ref={innerRef} from={from} />;
|
return <Cursor isOver={isActiveDrop} ref={innerRef} position={position} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// transparent hover zone with a thin visible band vertically centered
|
// transparent hover zone with a thin visible band vertically centered
|
||||||
const Cursor = styled.div<{ isOver?: boolean; from?: string }>`
|
const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
|
||||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||||
transition: opacity 150ms;
|
transition: opacity 150ms;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
${(props) => (props.position === "top" ? "top: 25px;" : "bottom: -7px;")}
|
||||||
|
|
||||||
::after {
|
::after {
|
||||||
background: ${(props) => props.theme.slateDark};
|
background: ${(props) => props.theme.slateDark};
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import fractionalIndex from "fractional-index";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { CollapsedIcon } from "outline-icons";
|
import { CollapsedIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect } from "react";
|
import { useDrop } from "react-dnd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
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";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
|
import DropCursor from "./DropCursor";
|
||||||
import PlaceholderCollections from "./PlaceholderCollections";
|
import PlaceholderCollections from "./PlaceholderCollections";
|
||||||
import Section from "./Section";
|
import Section from "./Section";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
@@ -23,14 +26,13 @@ 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 { documents } = useStores();
|
const { stars, documents } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fetchStarred, starred } = documents;
|
|
||||||
|
|
||||||
const fetchResults = React.useCallback(async () => {
|
const fetchResults = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
await fetchStarred({
|
await stars.fetchPage({
|
||||||
limit: STARRED_PAGINATION_LIMIT,
|
limit: STARRED_PAGINATION_LIMIT,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
@@ -42,9 +44,9 @@ function Starred() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
}
|
}
|
||||||
}, [fetchStarred, offset, showToast, t]);
|
}, [stars, offset, showToast, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
let stateInLocal;
|
let stateInLocal;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -60,19 +62,19 @@ function Starred() {
|
|||||||
}
|
}
|
||||||
}, [expanded]);
|
}, [expanded]);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
setOffset(starred.length);
|
setOffset(stars.orderedData.length);
|
||||||
|
|
||||||
if (starred.length <= STARRED_PAGINATION_LIMIT) {
|
if (stars.orderedData.length <= STARRED_PAGINATION_LIMIT) {
|
||||||
setShow("Nothing");
|
setShow("Nothing");
|
||||||
} else if (starred.length >= upperBound) {
|
} else if (stars.orderedData.length >= upperBound) {
|
||||||
setShow("More");
|
setShow("More");
|
||||||
} else if (starred.length < upperBound) {
|
} else if (stars.orderedData.length < upperBound) {
|
||||||
setShow("Less");
|
setShow("Less");
|
||||||
}
|
}
|
||||||
}, [starred, upperBound]);
|
}, [stars.orderedData, upperBound]);
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
fetchResults();
|
fetchResults();
|
||||||
}
|
}
|
||||||
@@ -106,20 +108,34 @@ function Starred() {
|
|||||||
[expanded]
|
[expanded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = starred.slice(0, upperBound).map((document) => {
|
// Drop to reorder document
|
||||||
return (
|
const [{ isOverReorder }, dropToReorder] = useDrop({
|
||||||
|
accept: "star",
|
||||||
|
drop: async (item: Star) => {
|
||||||
|
item?.save({ index: fractionalIndex(null, stars.orderedData[0].index) });
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOverReorder: !!monitor.isOver(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = stars.orderedData.slice(0, upperBound).map((star) => {
|
||||||
|
const document = documents.get(star.documentId);
|
||||||
|
|
||||||
|
return document ? (
|
||||||
<StarredLink
|
<StarredLink
|
||||||
key={document.id}
|
key={star.id}
|
||||||
|
star={star}
|
||||||
documentId={document.id}
|
documentId={document.id}
|
||||||
collectionId={document.collectionId}
|
collectionId={document.collectionId}
|
||||||
to={document.url}
|
to={document.url}
|
||||||
title={document.title}
|
title={document.title}
|
||||||
depth={2}
|
depth={2}
|
||||||
/>
|
/>
|
||||||
);
|
) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!starred.length) {
|
if (!stars.orderedData.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +149,11 @@ function Starred() {
|
|||||||
/>
|
/>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<>
|
||||||
|
<DropCursor
|
||||||
|
isActiveDrop={isOverReorder}
|
||||||
|
innerRef={dropToReorder}
|
||||||
|
position="top"
|
||||||
|
/>
|
||||||
{content}
|
{content}
|
||||||
{show === "More" && !isFetching && (
|
{show === "More" && !isFetching && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
@@ -148,7 +169,7 @@ function Starred() {
|
|||||||
depth={2}
|
depth={2}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(isFetching || fetchError) && !starred.length && (
|
{(isFetching || fetchError) && !stars.orderedData.length && (
|
||||||
<Flex column>
|
<Flex column>
|
||||||
<PlaceholderCollections />
|
<PlaceholderCollections />
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
|
import fractionalIndex from "fractional-index";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||||
|
import Star from "~/models/Star";
|
||||||
import Fade from "~/components/Fade";
|
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 Disclosure from "./Disclosure";
|
import Disclosure from "./Disclosure";
|
||||||
|
import DropCursor from "./DropCursor";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
star?: Star;
|
||||||
depth: number;
|
depth: number;
|
||||||
title: string;
|
title: string;
|
||||||
to: string;
|
to: string;
|
||||||
@@ -20,7 +25,14 @@ type Props = {
|
|||||||
collectionId: string;
|
collectionId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
function StarredLink({
|
||||||
|
depth,
|
||||||
|
title,
|
||||||
|
to,
|
||||||
|
documentId,
|
||||||
|
collectionId,
|
||||||
|
star,
|
||||||
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { collections, documents, policies } = useStores();
|
const { collections, documents, policies } = useStores();
|
||||||
const collection = collections.get(collectionId);
|
const collection = collections.get(collectionId);
|
||||||
@@ -74,9 +86,37 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
|||||||
setIsEditing(isEditing);
|
setIsEditing(isEditing);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Draggable
|
||||||
|
const [{ isDragging }, drag] = useDrag({
|
||||||
|
type: "star",
|
||||||
|
item: () => star,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: !!monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
canDrag: () => {
|
||||||
|
return depth === 2;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drop to reorder
|
||||||
|
const [{ isOverReorder, isDraggingAny }, dropToReorder] = useDrop({
|
||||||
|
accept: "star",
|
||||||
|
drop: (item: Star) => {
|
||||||
|
const next = star?.next();
|
||||||
|
|
||||||
|
item?.save({
|
||||||
|
index: fractionalIndex(star?.index || null, next?.index || null),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOverReorder: !!monitor.isOver(),
|
||||||
|
isDraggingAny: !!monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Relative>
|
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
depth={depth}
|
depth={depth}
|
||||||
to={`${to}?starred`}
|
to={`${to}?starred`}
|
||||||
@@ -114,7 +154,10 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
|||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Relative>
|
{isDraggingAny && (
|
||||||
|
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
{expanded &&
|
{expanded &&
|
||||||
childDocuments.map((childDocument) => (
|
childDocuments.map((childDocument) => (
|
||||||
<ObserveredStarredLink
|
<ObserveredStarredLink
|
||||||
@@ -130,8 +173,9 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Relative = styled.div`
|
const Draggable = styled.div<{ $isDragging?: boolean }>`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ObserveredStarredLink = observer(StarredLink);
|
const ObserveredStarredLink = observer(StarredLink);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
collections,
|
collections,
|
||||||
groups,
|
groups,
|
||||||
pins,
|
pins,
|
||||||
|
stars,
|
||||||
memberships,
|
memberships,
|
||||||
policies,
|
policies,
|
||||||
presence,
|
presence,
|
||||||
@@ -273,12 +274,16 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
pins.remove(event.modelId);
|
pins.remove(event.modelId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("documents.star", (event: any) => {
|
this.socket.on("stars.create", (event: any) => {
|
||||||
documents.starredIds.set(event.documentId, true);
|
stars.add(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("documents.unstar", (event: any) => {
|
this.socket.on("stars.update", (event: any) => {
|
||||||
documents.starredIds.set(event.documentId, false);
|
stars.add(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("stars.delete", (event: any) => {
|
||||||
|
stars.remove(event.modelId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("documents.permanent_delete", (event: any) => {
|
this.socket.on("documents.permanent_delete", (event: any) => {
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ export default class Document extends BaseModel {
|
|||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isStarred(): boolean {
|
get isStarred(): boolean {
|
||||||
return !!this.store.starredIds.get(this.id);
|
return !!this.store.rootStore.stars.orderedData.find(
|
||||||
|
(star) => star.documentId === this.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
@@ -258,7 +260,7 @@ export default class Document extends BaseModel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
star = () => {
|
star = async () => {
|
||||||
return this.store.star(this);
|
return this.store.star(this);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
28
app/models/Star.ts
Normal file
28
app/models/Star.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { observable } from "mobx";
|
||||||
|
import BaseModel from "./BaseModel";
|
||||||
|
import Field from "./decorators/Field";
|
||||||
|
|
||||||
|
class Star extends BaseModel {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
index: string;
|
||||||
|
|
||||||
|
documentId: string;
|
||||||
|
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
|
||||||
|
next(): Star | undefined {
|
||||||
|
const index = this.store.orderedData.indexOf(this);
|
||||||
|
return this.store.orderedData[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): Star | undefined {
|
||||||
|
const index = this.store.orderedData.indexOf(this);
|
||||||
|
return this.store.orderedData[index + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Star;
|
||||||
@@ -43,9 +43,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
@observable
|
@observable
|
||||||
searchCache: Map<string, SearchResult[]> = new Map();
|
searchCache: Map<string, SearchResult[]> = new Map();
|
||||||
|
|
||||||
@observable
|
|
||||||
starredIds: Map<string, boolean> = new Map();
|
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
backlinks: Map<string, string[]> = new Map();
|
backlinks: Map<string, string[]> = new Map();
|
||||||
|
|
||||||
@@ -172,14 +169,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
return this.searchCache.get(query) || [];
|
return this.searchCache.get(query) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
get starred(): Document[] {
|
|
||||||
return orderBy(
|
|
||||||
this.all.filter((d) => d.isStarred),
|
|
||||||
"updatedAt",
|
|
||||||
"desc"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get archived(): Document[] {
|
get archived(): Document[] {
|
||||||
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
||||||
@@ -194,11 +183,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
|
||||||
get starredAlphabetical(): Document[] {
|
|
||||||
return naturalSort(this.starred, "title");
|
|
||||||
}
|
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get templatesAlphabetical(): Document[] {
|
get templatesAlphabetical(): Document[] {
|
||||||
return naturalSort(this.templates, "title");
|
return naturalSort(this.templates, "title");
|
||||||
@@ -623,19 +607,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
return this.add(res.data);
|
return this.add(res.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
_add = this.add;
|
|
||||||
|
|
||||||
@action
|
|
||||||
add = (item: Record<string, any>): Document => {
|
|
||||||
const document = this._add(item);
|
|
||||||
|
|
||||||
if (item.starred !== undefined) {
|
|
||||||
this.starredIds.set(document.id, item.starred);
|
|
||||||
}
|
|
||||||
|
|
||||||
return document;
|
|
||||||
};
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
removeCollectionDocuments(collectionId: string) {
|
removeCollectionDocuments(collectionId: string) {
|
||||||
const documents = this.inCollection(collectionId);
|
const documents = this.inCollection(collectionId);
|
||||||
@@ -739,27 +710,16 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
star = async (document: Document) => {
|
star = async (document: Document) => {
|
||||||
this.starredIds.set(document.id, true);
|
await this.rootStore.stars.create({
|
||||||
|
documentId: document.id,
|
||||||
try {
|
|
||||||
return await client.post("/documents.star", {
|
|
||||||
id: document.id,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
this.starredIds.set(document.id, false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
unstar = async (document: Document) => {
|
unstar = async (document: Document) => {
|
||||||
this.starredIds.set(document.id, false);
|
const star = this.rootStore.stars.orderedData.find(
|
||||||
|
(star) => star.documentId === document.id
|
||||||
try {
|
);
|
||||||
return await client.post("/documents.unstar", {
|
await star?.delete();
|
||||||
id: document.id,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.starredIds.set(document.id, true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getByUrl = (url = ""): Document | null | undefined => {
|
getByUrl = (url = ""): Document | null | undefined => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import PoliciesStore from "./PoliciesStore";
|
|||||||
import RevisionsStore from "./RevisionsStore";
|
import RevisionsStore from "./RevisionsStore";
|
||||||
import SearchesStore from "./SearchesStore";
|
import SearchesStore from "./SearchesStore";
|
||||||
import SharesStore from "./SharesStore";
|
import SharesStore from "./SharesStore";
|
||||||
|
import StarsStore from "./StarsStore";
|
||||||
import ToastsStore from "./ToastsStore";
|
import ToastsStore from "./ToastsStore";
|
||||||
import UiStore from "./UiStore";
|
import UiStore from "./UiStore";
|
||||||
import UsersStore from "./UsersStore";
|
import UsersStore from "./UsersStore";
|
||||||
@@ -42,6 +43,7 @@ export default class RootStore {
|
|||||||
searches: SearchesStore;
|
searches: SearchesStore;
|
||||||
shares: SharesStore;
|
shares: SharesStore;
|
||||||
ui: UiStore;
|
ui: UiStore;
|
||||||
|
stars: StarsStore;
|
||||||
users: UsersStore;
|
users: UsersStore;
|
||||||
views: ViewsStore;
|
views: ViewsStore;
|
||||||
toasts: ToastsStore;
|
toasts: ToastsStore;
|
||||||
@@ -67,6 +69,7 @@ export default class RootStore {
|
|||||||
this.revisions = new RevisionsStore(this);
|
this.revisions = new RevisionsStore(this);
|
||||||
this.searches = new SearchesStore(this);
|
this.searches = new SearchesStore(this);
|
||||||
this.shares = new SharesStore(this);
|
this.shares = new SharesStore(this);
|
||||||
|
this.stars = new StarsStore(this);
|
||||||
this.ui = new UiStore();
|
this.ui = new UiStore();
|
||||||
this.users = new UsersStore(this);
|
this.users = new UsersStore(this);
|
||||||
this.views = new ViewsStore(this);
|
this.views = new ViewsStore(this);
|
||||||
@@ -92,6 +95,7 @@ export default class RootStore {
|
|||||||
this.revisions.clear();
|
this.revisions.clear();
|
||||||
this.searches.clear();
|
this.searches.clear();
|
||||||
this.shares.clear();
|
this.shares.clear();
|
||||||
|
this.stars.clear();
|
||||||
this.fileOperations.clear();
|
this.fileOperations.clear();
|
||||||
// this.ui omitted to keep ui settings between sessions
|
// this.ui omitted to keep ui settings between sessions
|
||||||
this.users.clear();
|
this.users.clear();
|
||||||
|
|||||||
44
app/stores/StarsStore.ts
Normal file
44
app/stores/StarsStore.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import invariant from "invariant";
|
||||||
|
import { action, runInAction, computed } from "mobx";
|
||||||
|
import Star from "~/models/Star";
|
||||||
|
import { PaginationParams } from "~/types";
|
||||||
|
import { client } from "~/utils/ApiClient";
|
||||||
|
import BaseStore from "./BaseStore";
|
||||||
|
import RootStore from "./RootStore";
|
||||||
|
|
||||||
|
export default class StarsStore extends BaseStore<Star> {
|
||||||
|
constructor(rootStore: RootStore) {
|
||||||
|
super(rootStore, Star);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
fetchPage = async (params?: PaginationParams | undefined): Promise<void> => {
|
||||||
|
this.isFetching = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.post(`/stars.list`, params);
|
||||||
|
invariant(res && res.data, "Data not available");
|
||||||
|
runInAction(`StarsStore#fetchPage`, () => {
|
||||||
|
res.data.documents.forEach(this.rootStore.documents.add);
|
||||||
|
res.data.stars.forEach(this.add);
|
||||||
|
this.addPolicies(res.policies);
|
||||||
|
this.isLoaded = true;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.isFetching = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@computed
|
||||||
|
get orderedData(): Star[] {
|
||||||
|
const stars = Array.from(this.data.values());
|
||||||
|
|
||||||
|
return stars.sort((a, b) => {
|
||||||
|
if (a.index === b.index) {
|
||||||
|
return a.updatedAt > b.updatedAt ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.index < b.index ? -1 : 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
server/commands/starCreator.test.ts
Normal file
58
server/commands/starCreator.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Star, Event } from "@server/models";
|
||||||
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
import starCreator from "./starCreator";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
describe("starCreator", () => {
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
it("should create star", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const star = await starCreator({
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = await Event.findOne();
|
||||||
|
expect(star.documentId).toEqual(document.id);
|
||||||
|
expect(star.userId).toEqual(user.id);
|
||||||
|
expect(star.index).toEqual("P");
|
||||||
|
expect(event!.name).toEqual("stars.create");
|
||||||
|
expect(event!.modelId).toEqual(star.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not record event if star is existing", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Star.create({
|
||||||
|
teamId: document.teamId,
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
index: "P",
|
||||||
|
});
|
||||||
|
|
||||||
|
const star = await starCreator({
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = await Event.count();
|
||||||
|
expect(star.documentId).toEqual(document.id);
|
||||||
|
expect(star.userId).toEqual(user.id);
|
||||||
|
expect(star.index).toEqual("P");
|
||||||
|
expect(events).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
server/commands/starCreator.ts
Normal file
89
server/commands/starCreator.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import fractionalIndex from "fractional-index";
|
||||||
|
import { Sequelize, WhereOptions } from "sequelize";
|
||||||
|
import { sequelize } from "@server/database/sequelize";
|
||||||
|
import { Star, User, Event } from "@server/models";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The user creating the star */
|
||||||
|
user: User;
|
||||||
|
/** The document to star */
|
||||||
|
documentId: string;
|
||||||
|
/** The sorted index for the star in the sidebar If no index is provided then it will be at the end */
|
||||||
|
index?: string;
|
||||||
|
/** The IP address of the user creating the star */
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This command creates a "starred" document via the star relation. Stars are
|
||||||
|
* only visible to the user that created them.
|
||||||
|
*
|
||||||
|
* @param Props The properties of the star to create
|
||||||
|
* @returns Star The star that was created
|
||||||
|
*/
|
||||||
|
export default async function starCreator({
|
||||||
|
user,
|
||||||
|
documentId,
|
||||||
|
ip,
|
||||||
|
...rest
|
||||||
|
}: Props): Promise<Star> {
|
||||||
|
let { index } = rest;
|
||||||
|
const where: WhereOptions<Star> = {
|
||||||
|
userId: user.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!index) {
|
||||||
|
const stars = await Star.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ["id", "index", "updatedAt"],
|
||||||
|
limit: 1,
|
||||||
|
order: [
|
||||||
|
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||||
|
// find only the first star so we can create an index before it
|
||||||
|
Sequelize.literal('"star"."index" collate "C"'),
|
||||||
|
["updatedAt", "DESC"],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// create a star at the beginning of the list
|
||||||
|
index = fractionalIndex(null, stars.length ? stars[0].index : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
let star;
|
||||||
|
|
||||||
|
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,
|
||||||
|
actorId: user.id,
|
||||||
|
documentId,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return star;
|
||||||
|
}
|
||||||
39
server/commands/starDestroyer.test.ts
Normal file
39
server/commands/starDestroyer.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Star, Event } from "@server/models";
|
||||||
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
import starDestroyer from "./starDestroyer";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("starDestroyer", () => {
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
it("should destroy existing star", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const star = await Star.create({
|
||||||
|
teamId: document.teamId,
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
index: "P",
|
||||||
|
});
|
||||||
|
|
||||||
|
await starDestroyer({
|
||||||
|
star,
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await Star.count();
|
||||||
|
expect(count).toEqual(0);
|
||||||
|
|
||||||
|
const event = await Event.findOne();
|
||||||
|
expect(event!.name).toEqual("stars.delete");
|
||||||
|
expect(event!.modelId).toEqual(star.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
server/commands/starDestroyer.ts
Normal file
54
server/commands/starDestroyer.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Transaction } from "sequelize";
|
||||||
|
import { sequelize } from "@server/database/sequelize";
|
||||||
|
import { Event, Star, User } from "@server/models";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The user destroying the star */
|
||||||
|
user: User;
|
||||||
|
/** The star to destroy */
|
||||||
|
star: Star;
|
||||||
|
/** The IP address of the user creating the star */
|
||||||
|
ip: string;
|
||||||
|
/** Optional existing transaction */
|
||||||
|
transaction?: Transaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This command destroys a document star. This just removes the star itself and
|
||||||
|
* does not touch the document
|
||||||
|
*
|
||||||
|
* @param Props The properties of the star to destroy
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
export default async function starDestroyer({
|
||||||
|
user,
|
||||||
|
star,
|
||||||
|
ip,
|
||||||
|
transaction: t,
|
||||||
|
}: Props): Promise<Star> {
|
||||||
|
const transaction = t || (await sequelize.transaction());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await star.destroy({ transaction });
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "stars.delete",
|
||||||
|
modelId: star.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
userId: star.userId,
|
||||||
|
documentId: star.documentId,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return star;
|
||||||
|
}
|
||||||
40
server/commands/starUpdater.test.ts
Normal file
40
server/commands/starUpdater.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Star, Event } from "@server/models";
|
||||||
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
import starUpdater from "./starUpdater";
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("starUpdater", () => {
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
it("should update (move) existing star", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let star = await Star.create({
|
||||||
|
teamId: document.teamId,
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdById: user.id,
|
||||||
|
index: "P",
|
||||||
|
});
|
||||||
|
|
||||||
|
star = await starUpdater({
|
||||||
|
star,
|
||||||
|
index: "h",
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = await Event.findOne();
|
||||||
|
expect(star.documentId).toEqual(document.id);
|
||||||
|
expect(star.userId).toEqual(user.id);
|
||||||
|
expect(star.index).toEqual("h");
|
||||||
|
expect(event!.name).toEqual("stars.update");
|
||||||
|
expect(event!.modelId).toEqual(star.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
server/commands/starUpdater.ts
Normal file
53
server/commands/starUpdater.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { sequelize } from "@server/database/sequelize";
|
||||||
|
import { Event, Star, User } from "@server/models";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The user updating the star */
|
||||||
|
user: User;
|
||||||
|
/** The existing star */
|
||||||
|
star: Star;
|
||||||
|
/** The index to star the document at */
|
||||||
|
index: string;
|
||||||
|
/** The IP address of the user creating the star */
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This command updates a "starred" document. A star can only be moved to a new
|
||||||
|
* index (reordered) once created.
|
||||||
|
*
|
||||||
|
* @param Props The properties of the star to update
|
||||||
|
* @returns Star The updated star
|
||||||
|
*/
|
||||||
|
export default async function starUpdater({
|
||||||
|
user,
|
||||||
|
star,
|
||||||
|
index,
|
||||||
|
ip,
|
||||||
|
}: Props): Promise<Star> {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
star.index = index;
|
||||||
|
await star.save({ transaction });
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "stars.update",
|
||||||
|
modelId: star.id,
|
||||||
|
userId: star.userId,
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
documentId: star.documentId,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return star;
|
||||||
|
}
|
||||||
13
server/migrations/20220117012250-add-starred-sorting.js
Normal file
13
server/migrations/20220117012250-add-starred-sorting.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn("stars", "index", {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
down: async (queryInterface) => {
|
||||||
|
await queryInterface.removeColumn("stars", "index");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,11 @@ import Fix from "./decorators/Fix";
|
|||||||
@Table({ tableName: "stars", modelName: "star" })
|
@Table({ tableName: "stars", modelName: "star" })
|
||||||
@Fix
|
@Fix
|
||||||
class Star extends BaseModel {
|
class Star extends BaseModel {
|
||||||
|
@Column
|
||||||
|
index: string | null;
|
||||||
|
|
||||||
|
// associations
|
||||||
|
|
||||||
@BelongsTo(() => User, "userId")
|
@BelongsTo(() => User, "userId")
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import "./notificationSetting";
|
|||||||
import "./pins";
|
import "./pins";
|
||||||
import "./searchQuery";
|
import "./searchQuery";
|
||||||
import "./share";
|
import "./share";
|
||||||
|
import "./star";
|
||||||
import "./user";
|
import "./user";
|
||||||
import "./team";
|
import "./team";
|
||||||
import "./group";
|
import "./group";
|
||||||
|
|||||||
9
server/policies/star.ts
Normal file
9
server/policies/star.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { User, Star } from "@server/models";
|
||||||
|
import { allow } from "./cancan";
|
||||||
|
|
||||||
|
allow(
|
||||||
|
User,
|
||||||
|
["update", "delete"],
|
||||||
|
Star,
|
||||||
|
(user, star) => user.id === star?.userId
|
||||||
|
);
|
||||||
@@ -16,6 +16,7 @@ import presentRevision from "./revision";
|
|||||||
import presentSearchQuery from "./searchQuery";
|
import presentSearchQuery from "./searchQuery";
|
||||||
import presentShare from "./share";
|
import presentShare from "./share";
|
||||||
import presentSlackAttachment from "./slackAttachment";
|
import presentSlackAttachment from "./slackAttachment";
|
||||||
|
import presentStar from "./star";
|
||||||
import presentTeam from "./team";
|
import presentTeam from "./team";
|
||||||
import presentUser from "./user";
|
import presentUser from "./user";
|
||||||
import presentView from "./view";
|
import presentView from "./view";
|
||||||
@@ -32,6 +33,7 @@ export {
|
|||||||
presentCollection,
|
presentCollection,
|
||||||
presentShare,
|
presentShare,
|
||||||
presentSearchQuery,
|
presentSearchQuery,
|
||||||
|
presentStar,
|
||||||
presentTeam,
|
presentTeam,
|
||||||
presentGroup,
|
presentGroup,
|
||||||
presentIntegration,
|
presentIntegration,
|
||||||
|
|||||||
11
server/presenters/star.ts
Normal file
11
server/presenters/star.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Star } from "@server/models";
|
||||||
|
|
||||||
|
export default function present(star: Star) {
|
||||||
|
return {
|
||||||
|
id: star.id,
|
||||||
|
documentId: star.documentId,
|
||||||
|
index: star.index,
|
||||||
|
createdAt: star.createdAt,
|
||||||
|
updatedAt: star.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
CollectionGroup,
|
CollectionGroup,
|
||||||
GroupUser,
|
GroupUser,
|
||||||
Pin,
|
Pin,
|
||||||
|
Star,
|
||||||
} from "@server/models";
|
} from "@server/models";
|
||||||
import { presentPin } from "@server/presenters";
|
import { presentPin, presentStar } from "@server/presenters";
|
||||||
import { Event } from "../../types";
|
import { Event } from "../../types";
|
||||||
|
|
||||||
export default class WebsocketsProcessor {
|
export default class WebsocketsProcessor {
|
||||||
@@ -386,6 +387,23 @@ export default class WebsocketsProcessor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "stars.create":
|
||||||
|
case "stars.update": {
|
||||||
|
const star = await Star.findByPk(event.modelId);
|
||||||
|
if (!star) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return socketio
|
||||||
|
.to(`user-${event.userId}`)
|
||||||
|
.emit(event.name, presentStar(star));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "stars.delete": {
|
||||||
|
return socketio.to(`user-${event.userId}`).emit(event.name, {
|
||||||
|
modelId: event.modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case "groups.create":
|
case "groups.create":
|
||||||
case "groups.update": {
|
case "groups.update": {
|
||||||
const group = await Group.findByPk(event.modelId, {
|
const group = await Group.findByPk(event.modelId, {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
presentCollectionGroupMembership,
|
presentCollectionGroupMembership,
|
||||||
presentFileOperation,
|
presentFileOperation,
|
||||||
} from "@server/presenters";
|
} from "@server/presenters";
|
||||||
import collectionIndexing from "@server/utils/collectionIndexing";
|
import { collectionIndexing } from "@server/utils/indexing";
|
||||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||||
import {
|
import {
|
||||||
assertUuid,
|
assertUuid,
|
||||||
@@ -623,12 +623,12 @@ router.post("collections.list", auth(), pagination(), async (ctx) => {
|
|||||||
offset: ctx.state.pagination.offset,
|
offset: ctx.state.pagination.offset,
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
const nullIndexCollection = collections.findIndex(
|
const nullIndex = collections.findIndex(
|
||||||
(collection) => collection.index === null
|
(collection) => collection.index === null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nullIndexCollection !== -1) {
|
if (nullIndex !== -1) {
|
||||||
const indexedCollections = await collectionIndexing(ctx.state.user.teamId);
|
const indexedCollections = await collectionIndexing(user.teamId);
|
||||||
collections.forEach((collection) => {
|
collections.forEach((collection) => {
|
||||||
collection.index = indexedCollections[collection.id];
|
collection.index = indexedCollections[collection.id];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deprecated – use stars.list instead
|
||||||
router.post("documents.starred", auth(), pagination(), async (ctx) => {
|
router.post("documents.starred", auth(), pagination(), async (ctx) => {
|
||||||
let { direction } = ctx.body;
|
let { direction } = ctx.body;
|
||||||
const { sort = "updatedAt" } = ctx.body;
|
const { sort = "updatedAt" } = ctx.body;
|
||||||
@@ -864,6 +865,7 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deprecated – use stars.create instead
|
||||||
router.post("documents.star", auth(), async (ctx) => {
|
router.post("documents.star", auth(), async (ctx) => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
assertPresent(id, "id is required");
|
assertPresent(id, "id is required");
|
||||||
@@ -898,6 +900,7 @@ router.post("documents.star", auth(), async (ctx) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Deprecated – use stars.delete instead
|
||||||
router.post("documents.unstar", auth(), async (ctx) => {
|
router.post("documents.unstar", auth(), async (ctx) => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
assertPresent(id, "id is required");
|
assertPresent(id, "id is required");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import pins from "./pins";
|
|||||||
import revisions from "./revisions";
|
import revisions from "./revisions";
|
||||||
import searches from "./searches";
|
import searches from "./searches";
|
||||||
import shares from "./shares";
|
import shares from "./shares";
|
||||||
|
import stars from "./stars";
|
||||||
import team from "./team";
|
import team from "./team";
|
||||||
import users from "./users";
|
import users from "./users";
|
||||||
import utils from "./utils";
|
import utils from "./utils";
|
||||||
@@ -58,6 +59,7 @@ router.use("/", hooks.routes());
|
|||||||
router.use("/", apiKeys.routes());
|
router.use("/", apiKeys.routes());
|
||||||
router.use("/", searches.routes());
|
router.use("/", searches.routes());
|
||||||
router.use("/", shares.routes());
|
router.use("/", shares.routes());
|
||||||
|
router.use("/", stars.routes());
|
||||||
router.use("/", team.routes());
|
router.use("/", team.routes());
|
||||||
router.use("/", integrations.routes());
|
router.use("/", integrations.routes());
|
||||||
router.use("/", notificationSettings.routes());
|
router.use("/", notificationSettings.routes());
|
||||||
|
|||||||
86
server/routes/api/stars.test.ts
Normal file
86
server/routes/api/stars.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import TestServer from "fetch-test-server";
|
||||||
|
import webService from "@server/services/web";
|
||||||
|
import { buildUser, buildStar, buildDocument } from "@server/test/factories";
|
||||||
|
import { flushdb } from "@server/test/support";
|
||||||
|
|
||||||
|
const app = webService();
|
||||||
|
const server = new TestServer(app.callback());
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe("#stars.create", () => {
|
||||||
|
it("should create a star", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/stars.create", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.documentId).toEqual(document.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require authentication", async () => {
|
||||||
|
const res = await server.post("/api/stars.create");
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#stars.list", () => {
|
||||||
|
it("should list users stars", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
|
||||||
|
await buildStar();
|
||||||
|
|
||||||
|
const star = await buildStar({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/stars.list", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.stars.length).toEqual(1);
|
||||||
|
expect(body.data.stars[0].id).toEqual(star.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require authentication", async () => {
|
||||||
|
const res = await server.post("/api/stars.list");
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#stars.delete", () => {
|
||||||
|
it("should delete users star", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const star = await buildStar({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/stars.delete", {
|
||||||
|
body: {
|
||||||
|
id: star.id,
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require authentication", async () => {
|
||||||
|
const res = await server.post("/api/stars.delete");
|
||||||
|
expect(res.status).toEqual(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
134
server/routes/api/stars.ts
Normal file
134
server/routes/api/stars.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Router from "koa-router";
|
||||||
|
import { Sequelize } from "sequelize";
|
||||||
|
import starCreator from "@server/commands/starCreator";
|
||||||
|
import starDestroyer from "@server/commands/starDestroyer";
|
||||||
|
import starUpdater from "@server/commands/starUpdater";
|
||||||
|
import auth from "@server/middlewares/authentication";
|
||||||
|
import { Document, Star } from "@server/models";
|
||||||
|
import { authorize } from "@server/policies";
|
||||||
|
import {
|
||||||
|
presentStar,
|
||||||
|
presentDocument,
|
||||||
|
presentPolicies,
|
||||||
|
} from "@server/presenters";
|
||||||
|
import { starIndexing } from "@server/utils/indexing";
|
||||||
|
import { assertUuid, assertIndexCharacters } from "@server/validation";
|
||||||
|
import pagination from "./middlewares/pagination";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post("stars.create", auth(), async (ctx) => {
|
||||||
|
const { documentId } = ctx.body;
|
||||||
|
const { index } = ctx.body;
|
||||||
|
assertUuid(documentId, "documentId is required");
|
||||||
|
|
||||||
|
const { user } = ctx.state;
|
||||||
|
const document = await Document.findByPk(documentId, {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
authorize(user, "star", document);
|
||||||
|
|
||||||
|
if (index) {
|
||||||
|
assertIndexCharacters(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const star = await starCreator({
|
||||||
|
user,
|
||||||
|
documentId,
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentStar(star),
|
||||||
|
policies: presentPolicies(user, [star]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("stars.list", auth(), pagination(), async (ctx) => {
|
||||||
|
const { user } = ctx.state;
|
||||||
|
|
||||||
|
const [stars, collectionIds] = await Promise.all([
|
||||||
|
Star.findAll({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
order: [
|
||||||
|
Sequelize.literal('"star"."index" collate "C"'),
|
||||||
|
["updatedAt", "DESC"],
|
||||||
|
],
|
||||||
|
offset: ctx.state.pagination.offset,
|
||||||
|
limit: ctx.state.pagination.limit,
|
||||||
|
}),
|
||||||
|
user.collectionIds(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nullIndex = stars.findIndex((star) => star.index === null);
|
||||||
|
|
||||||
|
if (nullIndex !== -1) {
|
||||||
|
const indexedStars = await starIndexing(user.id);
|
||||||
|
stars.forEach((star) => {
|
||||||
|
star.index = indexedStars[star.id];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
||||||
|
where: {
|
||||||
|
id: stars.map((star) => star.documentId),
|
||||||
|
collectionId: collectionIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const policies = presentPolicies(user, [...documents, ...stars]);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
pagination: ctx.state.pagination,
|
||||||
|
data: {
|
||||||
|
stars: stars.map(presentStar),
|
||||||
|
documents: await Promise.all(
|
||||||
|
documents.map((document: Document) => presentDocument(document))
|
||||||
|
),
|
||||||
|
},
|
||||||
|
policies,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("stars.update", auth(), async (ctx) => {
|
||||||
|
const { id, index } = ctx.body;
|
||||||
|
assertUuid(id, "id is required");
|
||||||
|
|
||||||
|
assertIndexCharacters(index);
|
||||||
|
|
||||||
|
const { user } = ctx.state;
|
||||||
|
let star = await Star.findByPk(id);
|
||||||
|
authorize(user, "update", star);
|
||||||
|
|
||||||
|
star = await starUpdater({
|
||||||
|
user,
|
||||||
|
star,
|
||||||
|
ip: ctx.request.ip,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
data: presentStar(star),
|
||||||
|
policies: presentPolicies(user, [star]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("stars.delete", auth(), async (ctx) => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
assertUuid(id, "id is required");
|
||||||
|
|
||||||
|
const { user } = ctx.state;
|
||||||
|
const star = await Star.findByPk(id);
|
||||||
|
authorize(user, "delete", star);
|
||||||
|
|
||||||
|
await starDestroyer({ user, star, ip: ctx.request.ip });
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Event,
|
Event,
|
||||||
Document,
|
Document,
|
||||||
|
Star,
|
||||||
Collection,
|
Collection,
|
||||||
Group,
|
Group,
|
||||||
GroupUser,
|
GroupUser,
|
||||||
@@ -44,6 +45,30 @@ export async function buildShare(overrides: Partial<Share> = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildStar(overrides: Partial<Star> = {}) {
|
||||||
|
let user;
|
||||||
|
|
||||||
|
if (overrides.userId) {
|
||||||
|
user = await User.findByPk(overrides.userId);
|
||||||
|
} else {
|
||||||
|
user = await buildUser();
|
||||||
|
overrides.userId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overrides.documentId) {
|
||||||
|
const document = await buildDocument({
|
||||||
|
createdById: overrides.userId,
|
||||||
|
teamId: user?.teamId,
|
||||||
|
});
|
||||||
|
overrides.documentId = document.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Star.create({
|
||||||
|
index: "h",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTeam(overrides: Record<string, any> = {}) {
|
export function buildTeam(overrides: Record<string, any> = {}) {
|
||||||
count++;
|
count++;
|
||||||
return Team.create(
|
return Team.create(
|
||||||
|
|||||||
@@ -241,15 +241,27 @@ export type PinEvent = {
|
|||||||
name: "pins.create" | "pins.update" | "pins.delete";
|
name: "pins.create" | "pins.update" | "pins.delete";
|
||||||
teamId: string;
|
teamId: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
documentId: string;
|
||||||
collectionId?: string;
|
collectionId?: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StarEvent = {
|
||||||
|
name: "stars.create" | "stars.update" | "stars.delete";
|
||||||
|
teamId: string;
|
||||||
|
modelId: string;
|
||||||
|
documentId: string;
|
||||||
|
userId: string;
|
||||||
|
actorId: string;
|
||||||
|
ip: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
| UserEvent
|
| UserEvent
|
||||||
| DocumentEvent
|
| DocumentEvent
|
||||||
| PinEvent
|
| PinEvent
|
||||||
|
| StarEvent
|
||||||
| CollectionEvent
|
| CollectionEvent
|
||||||
| CollectionImportEvent
|
| CollectionImportEvent
|
||||||
| CollectionExportAllEvent
|
| CollectionExportAllEvent
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import fractionalIndex from "fractional-index";
|
|
||||||
import naturalSort from "@shared/utils/naturalSort";
|
|
||||||
import { Collection } from "@server/models";
|
|
||||||
|
|
||||||
export default async function collectionIndexing(teamId: string) {
|
|
||||||
const collections = await Collection.findAll({
|
|
||||||
where: {
|
|
||||||
teamId,
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
//no point in maintaining index of deleted collections.
|
|
||||||
attributes: ["id", "index", "name"],
|
|
||||||
});
|
|
||||||
|
|
||||||
let sortableCollections: [Collection, string | null][] = collections.map(
|
|
||||||
(collection) => {
|
|
||||||
return [collection, collection.index];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
sortableCollections = naturalSort(
|
|
||||||
sortableCollections,
|
|
||||||
(collection) => collection[0].name
|
|
||||||
);
|
|
||||||
|
|
||||||
//for each collection with null index, use previous collection index to create new index
|
|
||||||
let previousCollectionIndex = null;
|
|
||||||
|
|
||||||
for (const collection of sortableCollections) {
|
|
||||||
if (collection[1] === null) {
|
|
||||||
const index = fractionalIndex(previousCollectionIndex, collection[1]);
|
|
||||||
collection[0].index = index;
|
|
||||||
await collection[0].save();
|
|
||||||
}
|
|
||||||
|
|
||||||
previousCollectionIndex = collection[0].index;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexedCollections = {};
|
|
||||||
sortableCollections.forEach((collection) => {
|
|
||||||
indexedCollections[collection[0].id] = collection[0].index;
|
|
||||||
});
|
|
||||||
return indexedCollections;
|
|
||||||
}
|
|
||||||
82
server/utils/indexing.ts
Normal file
82
server/utils/indexing.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import fractionalIndex from "fractional-index";
|
||||||
|
import naturalSort from "@shared/utils/naturalSort";
|
||||||
|
import { Collection, Document, Star } from "@server/models";
|
||||||
|
|
||||||
|
export async function collectionIndexing(
|
||||||
|
teamId: string
|
||||||
|
): Promise<{ [id: string]: string }> {
|
||||||
|
const collections = await Collection.findAll({
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
// no point in maintaining index of deleted collections.
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
attributes: ["id", "index", "name"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortable = naturalSort(collections, (collection) => collection.name);
|
||||||
|
|
||||||
|
// for each collection with null index, use previous collection index to create new index
|
||||||
|
let previousIndex = null;
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const collection of sortable) {
|
||||||
|
if (collection.index === null) {
|
||||||
|
collection.index = fractionalIndex(previousIndex, null);
|
||||||
|
promises.push(collection.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
previousIndex = collection.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const indexedCollections = {};
|
||||||
|
sortable.forEach((collection) => {
|
||||||
|
indexedCollections[collection.id] = collection.index;
|
||||||
|
});
|
||||||
|
return indexedCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function starIndexing(
|
||||||
|
userId: string
|
||||||
|
): Promise<{ [id: string]: string }> {
|
||||||
|
const stars = await Star.findAll({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const documents = await Document.findAll({
|
||||||
|
attributes: ["id", "updatedAt"],
|
||||||
|
where: {
|
||||||
|
id: stars.map((star) => star.documentId),
|
||||||
|
},
|
||||||
|
order: [["updatedAt", "DESC"]],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortable = stars.sort(function (a, b) {
|
||||||
|
return (
|
||||||
|
documents.findIndex((d) => d.id === a.documentId) -
|
||||||
|
documents.findIndex((d) => d.id === b.documentId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let previousIndex = null;
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const star of sortable) {
|
||||||
|
if (star.index === null) {
|
||||||
|
star.index = fractionalIndex(previousIndex, null);
|
||||||
|
promises.push(star.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
previousIndex = star.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
const indexedStars = {};
|
||||||
|
sortable.forEach((star) => {
|
||||||
|
indexedStars[star.id] = star.index;
|
||||||
|
});
|
||||||
|
return indexedStars;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user