mirror of
https://github.com/outline/outline.git
synced 2025-03-28 14:34:35 +00:00
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
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
from="collections"
|
||||
position="top"
|
||||
/>
|
||||
{orderedCollections.map((collection: Collection, index: number) => (
|
||||
<CollectionLink
|
||||
|
@ -4,27 +4,26 @@ import styled from "styled-components";
|
||||
function DropCursor({
|
||||
isActiveDrop,
|
||||
innerRef,
|
||||
from,
|
||||
position,
|
||||
}: {
|
||||
isActiveDrop: boolean;
|
||||
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
|
||||
const Cursor = styled.div<{ isOver?: boolean; from?: string }>`
|
||||
const Cursor = styled.div<{ isOver?: boolean; position?: "top" }>`
|
||||
opacity: ${(props) => (props.isOver ? 1 : 0)};
|
||||
transition: opacity 150ms;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
|
||||
background: transparent;
|
||||
${(props) => (props.position === "top" ? "top: 25px;" : "bottom: -7px;")}
|
||||
|
||||
::after {
|
||||
background: ${(props) => props.theme.slateDark};
|
||||
|
@ -1,12 +1,15 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Star from "~/models/Star";
|
||||
import Flex from "~/components/Flex";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DropCursor from "./DropCursor";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Section from "./Section";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
@ -23,14 +26,13 @@ function Starred() {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
|
||||
const { showToast } = useToasts();
|
||||
const { documents } = useStores();
|
||||
const { stars, documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { fetchStarred, starred } = documents;
|
||||
|
||||
const fetchResults = React.useCallback(async () => {
|
||||
try {
|
||||
setIsFetching(true);
|
||||
await fetchStarred({
|
||||
await stars.fetchPage({
|
||||
limit: STARRED_PAGINATION_LIMIT,
|
||||
offset,
|
||||
});
|
||||
@ -42,9 +44,9 @@ function Starred() {
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [fetchStarred, offset, showToast, t]);
|
||||
}, [stars, offset, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
let stateInLocal;
|
||||
|
||||
try {
|
||||
@ -60,19 +62,19 @@ function Starred() {
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(starred.length);
|
||||
React.useEffect(() => {
|
||||
setOffset(stars.orderedData.length);
|
||||
|
||||
if (starred.length <= STARRED_PAGINATION_LIMIT) {
|
||||
if (stars.orderedData.length <= STARRED_PAGINATION_LIMIT) {
|
||||
setShow("Nothing");
|
||||
} else if (starred.length >= upperBound) {
|
||||
} else if (stars.orderedData.length >= upperBound) {
|
||||
setShow("More");
|
||||
} else if (starred.length < upperBound) {
|
||||
} else if (stars.orderedData.length < upperBound) {
|
||||
setShow("Less");
|
||||
}
|
||||
}, [starred, upperBound]);
|
||||
}, [stars.orderedData, upperBound]);
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (offset === 0) {
|
||||
fetchResults();
|
||||
}
|
||||
@ -106,20 +108,34 @@ function Starred() {
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const content = starred.slice(0, upperBound).map((document) => {
|
||||
return (
|
||||
// Drop to reorder document
|
||||
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
|
||||
key={document.id}
|
||||
key={star.id}
|
||||
star={star}
|
||||
documentId={document.id}
|
||||
collectionId={document.collectionId}
|
||||
to={document.url}
|
||||
title={document.title}
|
||||
depth={2}
|
||||
/>
|
||||
);
|
||||
) : null;
|
||||
});
|
||||
|
||||
if (!starred.length) {
|
||||
if (!stars.orderedData.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -133,6 +149,11 @@ function Starred() {
|
||||
/>
|
||||
{expanded && (
|
||||
<>
|
||||
<DropCursor
|
||||
isActiveDrop={isOverReorder}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
{content}
|
||||
{show === "More" && !isFetching && (
|
||||
<SidebarLink
|
||||
@ -148,7 +169,7 @@ function Starred() {
|
||||
depth={2}
|
||||
/>
|
||||
)}
|
||||
{(isFetching || fetchError) && !starred.length && (
|
||||
{(isFetching || fetchError) && !stars.orderedData.length && (
|
||||
<Flex column>
|
||||
<PlaceholderCollections />
|
||||
</Flex>
|
||||
|
@ -1,18 +1,23 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import Star from "~/models/Star";
|
||||
import Fade from "~/components/Fade";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import Disclosure from "./Disclosure";
|
||||
import DropCursor from "./DropCursor";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
type Props = {
|
||||
star?: Star;
|
||||
depth: number;
|
||||
title: string;
|
||||
to: string;
|
||||
@ -20,7 +25,14 @@ type Props = {
|
||||
collectionId: string;
|
||||
};
|
||||
|
||||
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
||||
function StarredLink({
|
||||
depth,
|
||||
title,
|
||||
to,
|
||||
documentId,
|
||||
collectionId,
|
||||
star,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { collections, documents, policies } = useStores();
|
||||
const collection = collections.get(collectionId);
|
||||
@ -74,9 +86,37 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
||||
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 (
|
||||
<>
|
||||
<Relative>
|
||||
<Draggable key={documentId} ref={drag} $isDragging={isDragging}>
|
||||
<SidebarLink
|
||||
depth={depth}
|
||||
to={`${to}?starred`}
|
||||
@ -114,7 +154,10 @@ function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Relative>
|
||||
{isDraggingAny && (
|
||||
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
|
||||
)}
|
||||
</Draggable>
|
||||
{expanded &&
|
||||
childDocuments.map((childDocument) => (
|
||||
<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;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.5 : 1)};
|
||||
`;
|
||||
|
||||
const ObserveredStarredLink = observer(StarredLink);
|
||||
|
@ -72,6 +72,7 @@ class SocketProvider extends React.Component<Props> {
|
||||
collections,
|
||||
groups,
|
||||
pins,
|
||||
stars,
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
@ -273,12 +274,16 @@ class SocketProvider extends React.Component<Props> {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("documents.star", (event: any) => {
|
||||
documents.starredIds.set(event.documentId, true);
|
||||
this.socket.on("stars.create", (event: any) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("documents.unstar", (event: any) => {
|
||||
documents.starredIds.set(event.documentId, false);
|
||||
this.socket.on("stars.update", (event: any) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.delete", (event: any) => {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("documents.permanent_delete", (event: any) => {
|
||||
|
@ -147,7 +147,9 @@ export default class Document extends BaseModel {
|
||||
|
||||
@computed
|
||||
get isStarred(): boolean {
|
||||
return !!this.store.starredIds.get(this.id);
|
||||
return !!this.store.rootStore.stars.orderedData.find(
|
||||
(star) => star.documentId === this.id
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
@ -258,7 +260,7 @@ export default class Document extends BaseModel {
|
||||
};
|
||||
|
||||
@action
|
||||
star = () => {
|
||||
star = async () => {
|
||||
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
|
||||
searchCache: Map<string, SearchResult[]> = new Map();
|
||||
|
||||
@observable
|
||||
starredIds: Map<string, boolean> = new Map();
|
||||
|
||||
@observable
|
||||
backlinks: Map<string, string[]> = new Map();
|
||||
|
||||
@ -172,14 +169,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
return this.searchCache.get(query) || [];
|
||||
}
|
||||
|
||||
get starred(): Document[] {
|
||||
return orderBy(
|
||||
this.all.filter((d) => d.isStarred),
|
||||
"updatedAt",
|
||||
"desc"
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get archived(): Document[] {
|
||||
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
|
||||
get templatesAlphabetical(): Document[] {
|
||||
return naturalSort(this.templates, "title");
|
||||
@ -623,19 +607,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
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
|
||||
removeCollectionDocuments(collectionId: string) {
|
||||
const documents = this.inCollection(collectionId);
|
||||
@ -739,27 +710,16 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
star = async (document: Document) => {
|
||||
this.starredIds.set(document.id, true);
|
||||
|
||||
try {
|
||||
return await client.post("/documents.star", {
|
||||
id: document.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.starredIds.set(document.id, false);
|
||||
}
|
||||
await this.rootStore.stars.create({
|
||||
documentId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
unstar = async (document: Document) => {
|
||||
this.starredIds.set(document.id, false);
|
||||
|
||||
try {
|
||||
return await client.post("/documents.unstar", {
|
||||
id: document.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.starredIds.set(document.id, true);
|
||||
}
|
||||
const star = this.rootStore.stars.orderedData.find(
|
||||
(star) => star.documentId === document.id
|
||||
);
|
||||
await star?.delete();
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | null | undefined => {
|
||||
|
@ -17,6 +17,7 @@ import PoliciesStore from "./PoliciesStore";
|
||||
import RevisionsStore from "./RevisionsStore";
|
||||
import SearchesStore from "./SearchesStore";
|
||||
import SharesStore from "./SharesStore";
|
||||
import StarsStore from "./StarsStore";
|
||||
import ToastsStore from "./ToastsStore";
|
||||
import UiStore from "./UiStore";
|
||||
import UsersStore from "./UsersStore";
|
||||
@ -42,6 +43,7 @@ export default class RootStore {
|
||||
searches: SearchesStore;
|
||||
shares: SharesStore;
|
||||
ui: UiStore;
|
||||
stars: StarsStore;
|
||||
users: UsersStore;
|
||||
views: ViewsStore;
|
||||
toasts: ToastsStore;
|
||||
@ -67,6 +69,7 @@ export default class RootStore {
|
||||
this.revisions = new RevisionsStore(this);
|
||||
this.searches = new SearchesStore(this);
|
||||
this.shares = new SharesStore(this);
|
||||
this.stars = new StarsStore(this);
|
||||
this.ui = new UiStore();
|
||||
this.users = new UsersStore(this);
|
||||
this.views = new ViewsStore(this);
|
||||
@ -92,6 +95,7 @@ export default class RootStore {
|
||||
this.revisions.clear();
|
||||
this.searches.clear();
|
||||
this.shares.clear();
|
||||
this.stars.clear();
|
||||
this.fileOperations.clear();
|
||||
// this.ui omitted to keep ui settings between sessions
|
||||
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" })
|
||||
@Fix
|
||||
class Star extends BaseModel {
|
||||
@Column
|
||||
index: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
|
@ -17,6 +17,7 @@ import "./notificationSetting";
|
||||
import "./pins";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./star";
|
||||
import "./user";
|
||||
import "./team";
|
||||
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 presentShare from "./share";
|
||||
import presentSlackAttachment from "./slackAttachment";
|
||||
import presentStar from "./star";
|
||||
import presentTeam from "./team";
|
||||
import presentUser from "./user";
|
||||
import presentView from "./view";
|
||||
@ -32,6 +33,7 @@ export {
|
||||
presentCollection,
|
||||
presentShare,
|
||||
presentSearchQuery,
|
||||
presentStar,
|
||||
presentTeam,
|
||||
presentGroup,
|
||||
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,
|
||||
GroupUser,
|
||||
Pin,
|
||||
Star,
|
||||
} from "@server/models";
|
||||
import { presentPin } from "@server/presenters";
|
||||
import { presentPin, presentStar } from "@server/presenters";
|
||||
import { Event } from "../../types";
|
||||
|
||||
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.update": {
|
||||
const group = await Group.findByPk(event.modelId, {
|
||||
|
@ -25,7 +25,7 @@ import {
|
||||
presentCollectionGroupMembership,
|
||||
presentFileOperation,
|
||||
} from "@server/presenters";
|
||||
import collectionIndexing from "@server/utils/collectionIndexing";
|
||||
import { collectionIndexing } from "@server/utils/indexing";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import {
|
||||
assertUuid,
|
||||
@ -623,12 +623,12 @@ router.post("collections.list", auth(), pagination(), async (ctx) => {
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
const nullIndexCollection = collections.findIndex(
|
||||
const nullIndex = collections.findIndex(
|
||||
(collection) => collection.index === null
|
||||
);
|
||||
|
||||
if (nullIndexCollection !== -1) {
|
||||
const indexedCollections = await collectionIndexing(ctx.state.user.teamId);
|
||||
if (nullIndex !== -1) {
|
||||
const indexedCollections = await collectionIndexing(user.teamId);
|
||||
collections.forEach((collection) => {
|
||||
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) => {
|
||||
let { direction } = 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) => {
|
||||
const { id } = ctx.body;
|
||||
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) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
|
@ -22,6 +22,7 @@ import pins from "./pins";
|
||||
import revisions from "./revisions";
|
||||
import searches from "./searches";
|
||||
import shares from "./shares";
|
||||
import stars from "./stars";
|
||||
import team from "./team";
|
||||
import users from "./users";
|
||||
import utils from "./utils";
|
||||
@ -58,6 +59,7 @@ router.use("/", hooks.routes());
|
||||
router.use("/", apiKeys.routes());
|
||||
router.use("/", searches.routes());
|
||||
router.use("/", shares.routes());
|
||||
router.use("/", stars.routes());
|
||||
router.use("/", team.routes());
|
||||
router.use("/", integrations.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,
|
||||
Event,
|
||||
Document,
|
||||
Star,
|
||||
Collection,
|
||||
Group,
|
||||
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> = {}) {
|
||||
count++;
|
||||
return Team.create(
|
||||
|
@ -241,15 +241,27 @@ export type PinEvent = {
|
||||
name: "pins.create" | "pins.update" | "pins.delete";
|
||||
teamId: string;
|
||||
modelId: string;
|
||||
documentId: string;
|
||||
collectionId?: string;
|
||||
actorId: 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 =
|
||||
| UserEvent
|
||||
| DocumentEvent
|
||||
| PinEvent
|
||||
| StarEvent
|
||||
| CollectionEvent
|
||||
| CollectionImportEvent
|
||||
| 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