diff --git a/app/components/Layout.js b/app/components/Layout.js index 0615e4da..c43912bd 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -6,11 +6,18 @@ import * as React from "react"; import { Helmet } from "react-helmet"; import { withTranslation, type TFunction } from "react-i18next"; import keydown from "react-keydown"; -import { Switch, Route, Redirect } from "react-router-dom"; +import { + Switch, + Route, + Redirect, + withRouter, + type RouterHistory, +} from "react-router-dom"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import AuthStore from "stores/AuthStore"; import DocumentsStore from "stores/DocumentsStore"; +import PoliciesStore from "stores/PoliciesStore"; import UiStore from "stores/UiStore"; import ErrorSuspended from "scenes/ErrorSuspended"; import KeyboardShortcuts from "scenes/KeyboardShortcuts"; @@ -29,6 +36,7 @@ import { homeUrl, searchUrl, matchDocumentSlug as slug, + newDocumentUrl, } from "utils/routeHelpers"; type Props = { @@ -38,6 +46,8 @@ type Props = { title?: ?React.Node, auth: AuthStore, ui: UiStore, + history: RouterHistory, + policies: PoliciesStore, notifications?: React.Node, i18n: Object, t: TFunction, @@ -81,6 +91,17 @@ class Layout extends React.Component { this.redirectTo = homeUrl(); } + @keydown("n") + goToNewDocument() { + const { activeCollectionId } = this.props.ui; + if (!activeCollectionId) return; + + const can = this.props.policies.abilities(activeCollectionId); + if (!can.update) return; + + this.props.history.push(newDocumentUrl(activeCollectionId)); + } + render() { const { auth, t, ui } = this.props; const { user, team } = auth; @@ -198,5 +219,5 @@ const Content = styled(Flex)` `; export default withTranslation()( - inject("auth", "ui", "documents")(Layout) + inject("auth", "ui", "documents", "policies")(withRouter(Layout)) ); diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js index afa40ab3..e5db8213 100644 --- a/app/components/Sidebar/components/CollectionLink.js +++ b/app/components/Sidebar/components/CollectionLink.js @@ -1,9 +1,9 @@ // @flow +import fractionalIndex from "fractional-index"; import { observer } from "mobx-react"; import * as React from "react"; -import { useDrop } from "react-dnd"; +import { useDrop, useDrag } from "react-dnd"; import styled from "styled-components"; -import UiStore from "stores/UiStore"; import Collection from "models/Collection"; import Document from "models/Document"; import CollectionIcon from "components/CollectionIcon"; @@ -18,10 +18,12 @@ import CollectionSortMenu from "menus/CollectionSortMenu"; type Props = {| collection: Collection, - ui: UiStore, canUpdate: boolean, activeDocument: ?Document, prefetchDocument: (id: string) => Promise, + belowCollection: Collection | void, + isDraggingAnyCollection: boolean, + onChangeDragging: (dragging: boolean) => void, |}; function CollectionLink({ @@ -29,7 +31,9 @@ function CollectionLink({ activeDocument, prefetchDocument, canUpdate, - ui, + belowCollection, + isDraggingAnyCollection, + onChangeDragging, }: Props) { const [menuOpen, setMenuOpen] = React.useState(false); @@ -40,10 +44,23 @@ function CollectionLink({ [collection] ); - const { documents, policies } = useStores(); - const expanded = collection.id === ui.activeCollectionId; + const { ui, documents, policies, collections } = useStores(); + + const [expanded, setExpanded] = React.useState( + collection.id === ui.activeCollectionId + ); + + React.useEffect(() => { + if (isDraggingAnyCollection) { + setExpanded(false); + } else { + setExpanded(collection.id === ui.activeCollectionId); + } + }, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]); + const manualSort = collection.sort.field === "index"; const can = policies.abilities(collection.id); + const belowCollectionIndex = belowCollection ? belowCollection.index : null; // Drop to re-parent const [{ isOver, canDrop }, drop] = useDrop({ @@ -74,49 +91,101 @@ function CollectionLink({ }), }); + // Drop to reorder Collection + const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({ + accept: "collection", + drop: async (item, monitor) => { + collections.move( + item.id, + fractionalIndex(collection.index, belowCollectionIndex) + ); + }, + canDrop: (item, monitor) => { + return ( + collection.id !== item.id && + (!belowCollection || item.id !== belowCollection.id) + ); + }, + collect: (monitor) => ({ + isCollectionDropping: monitor.isOver(), + }), + }); + + // Drag to reorder Collection + const [{ isCollectionDragging }, dragToReorderCollection] = useDrag({ + type: "collection", + item: () => { + onChangeDragging(true); + return { + id: collection.id, + }; + }, + collect: (monitor) => ({ + isCollectionDragging: monitor.isDragging(), + }), + canDrag: (monitor) => { + return can.move; + }, + end: (monitor) => { + onChangeDragging(false); + }, + }); + return ( <>
- - - } - iconColor={collection.color} - expanded={expanded} - showActions={menuOpen || expanded} - isActiveDrop={isOver && canDrop} - label={ - - } - exact={false} - menu={ - <> - {can.update && ( - + + + } + iconColor={collection.color} + expanded={expanded} + showActions={menuOpen || expanded} + isActiveDrop={isOver && canDrop} + label={ + + } + exact={false} + menu={ + <> + {can.update && ( + setMenuOpen(true)} + onClose={() => setMenuOpen(false)} + /> + )} + setMenuOpen(true)} onClose={() => setMenuOpen(false)} /> - )} - setMenuOpen(true)} - onClose={() => setMenuOpen(false)} - /> - - } - /> - + + } + /> + + {expanded && manualSort && ( )} + {isDraggingAnyCollection && ( + + )}
{expanded && @@ -136,6 +205,11 @@ function CollectionLink({ ); } +const Draggable = styled("div")` + opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)}; + pointer-events: ${(props) => (props.$isMoving ? "none" : "auto")}; +`; + const SidebarLinkWithPadding = styled(SidebarLink)` padding-right: 60px; `; diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js index 759d7958..8567c7ea 100644 --- a/app/components/Sidebar/components/Collections.js +++ b/app/components/Sidebar/components/Collections.js @@ -1,98 +1,98 @@ // @flow -import { observer, inject } from "mobx-react"; +import fractionalIndex from "fractional-index"; +import { observer } from "mobx-react"; import { PlusIcon } from "outline-icons"; import * as React from "react"; -import { withTranslation, type TFunction } from "react-i18next"; -import keydown from "react-keydown"; -import { withRouter, type RouterHistory } from "react-router-dom"; - -import CollectionsStore from "stores/CollectionsStore"; -import DocumentsStore from "stores/DocumentsStore"; -import PoliciesStore from "stores/PoliciesStore"; -import UiStore from "stores/UiStore"; +import { useDrop } from "react-dnd"; +import { useTranslation } from "react-i18next"; import Fade from "components/Fade"; import Flex from "components/Flex"; +import useStores from "../../../hooks/useStores"; import CollectionLink from "./CollectionLink"; import CollectionsLoading from "./CollectionsLoading"; +import DropCursor from "./DropCursor"; import Header from "./Header"; import SidebarLink from "./SidebarLink"; -import { newDocumentUrl } from "utils/routeHelpers"; - type Props = { - history: RouterHistory, - policies: PoliciesStore, - collections: CollectionsStore, - documents: DocumentsStore, onCreateCollection: () => void, - ui: UiStore, - t: TFunction, }; -@observer -class Collections extends React.Component { - isPreloaded: boolean = !!this.props.collections.orderedData.length; - - componentDidMount() { - const { collections } = this.props; +function Collections({ onCreateCollection }: Props) { + const { ui, policies, documents, collections } = useStores(); + const isPreloaded: boolean = !!collections.orderedData.length; + const { t } = useTranslation(); + const orderedCollections = collections.orderedData; + const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState( + false + ); + React.useEffect(() => { if (!collections.isLoaded) { collections.fetchPage({ limit: 100 }); } - } + }); - @keydown("n") - goToNewDocument() { - const { activeCollectionId } = this.props.ui; - if (!activeCollectionId) return; + const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({ + accept: "collection", + drop: async (item, monitor) => { + collections.move( + item.id, + fractionalIndex(null, orderedCollections[0].index) + ); + }, + canDrop: (item, monitor) => { + return item.id !== orderedCollections[0].id; + }, + collect: (monitor) => ({ + isCollectionDropping: monitor.isOver(), + }), + }); - const can = this.props.policies.abilities(activeCollectionId); - if (!can.update) return; - - this.props.history.push(newDocumentUrl(activeCollectionId)); - } - - render() { - const { collections, ui, policies, documents, t } = this.props; - - const content = ( - <> - {collections.orderedData.map((collection) => ( - - ))} - } - label={`${t("New collection")}…`} - exact + const content = ( + <> + + {orderedCollections.map((collection, index) => ( + - - ); + ))} + } + label={`${t("New collection")}…`} + exact + /> + + ); + if (!collections.isLoaded) { return (
{t("Collections")}
- {collections.isLoaded ? ( - this.isPreloaded ? ( - content - ) : ( - {content} - ) - ) : ( - - )} +
); } + + return ( + +
{t("Collections")}
+ {isPreloaded ? content : {content}} +
+ ); } -export default withTranslation()( - inject("collections", "ui", "documents", "policies")(withRouter(Collections)) -); +export default observer(Collections); diff --git a/app/components/Sidebar/components/DropCursor.js b/app/components/Sidebar/components/DropCursor.js index 2eb584cf..d25ac49d 100644 --- a/app/components/Sidebar/components/DropCursor.js +++ b/app/components/Sidebar/components/DropCursor.js @@ -7,12 +7,14 @@ function DropCursor({ isActiveDrop, innerRef, theme, + from, }: { isActiveDrop: boolean, innerRef: React.Ref, theme: Theme, + from: string, }) { - return ; + return ; } // transparent hover zone with a thin visible band vertically centered @@ -25,7 +27,7 @@ const Cursor = styled("div")` width: 100%; height: 14px; - bottom: -7px; + ${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")} background: transparent; ::after { diff --git a/app/components/SocketProvider.js b/app/components/SocketProvider.js index 67541c78..3bc4a7ed 100644 --- a/app/components/SocketProvider.js +++ b/app/components/SocketProvider.js @@ -272,6 +272,13 @@ class SocketProvider extends React.Component { } }); + this.socket.on("collections.update_index", (event) => { + const collection = collections.get(event.collectionId); + if (collection) { + collection.updateIndex(event.index); + } + }); + // received a message from the API server that we should request // to join a specific room. Forward that to the ws server. this.socket.on("join", (event) => { diff --git a/app/models/Collection.js b/app/models/Collection.js index a8fc4666..254333d7 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -17,9 +17,10 @@ export default class Collection extends BaseModel { color: string; private: boolean; sharing: boolean; + index: string; documents: NavigationNode[]; - createdAt: ?string; - updatedAt: ?string; + createdAt: string; + updatedAt: string; deletedAt: ?string; sort: { field: string, direction: "asc" | "desc" }; url: string; @@ -67,6 +68,11 @@ export default class Collection extends BaseModel { travelDocuments(this.documents); } + @action + updateIndex(index: string) { + this.index = index; + } + getDocumentChildren(documentId: string): NavigationNode[] { let result = []; const traveler = (nodes) => { @@ -117,6 +123,7 @@ export default class Collection extends BaseModel { "icon", "private", "sort", + "index", ]); }; diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index 6f9c786a..9934215b 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -1,7 +1,7 @@ // @flow -import { concat, filter, last } from "lodash"; +import invariant from "invariant"; +import { concat, last } from "lodash"; import { computed, action } from "mobx"; -import naturalSort from "shared/utils/naturalSort"; import Collection from "models/Collection"; import BaseStore from "./BaseStore"; import RootStore from "./RootStore"; @@ -33,10 +33,18 @@ export default class CollectionsStore extends BaseStore { @computed get orderedData(): Collection[] { - return filter( - naturalSort(Array.from(this.data.values()), "name"), - (d) => !d.deletedAt + let collections = Array.from(this.data.values()); + + collections = collections.filter((collection) => + collection.deletedAt ? false : true ); + + return collections.sort((a, b) => { + if (a.index === b.index) { + return a.updatedAt > b.updatedAt ? -1 : 1; + } + return a.index < b.index ? -1 : 1; + }); } @computed @@ -95,6 +103,21 @@ export default class CollectionsStore extends BaseStore { }); }; + @action + move = async (collectionId: string, index: string) => { + const res = await client.post("/collections.move", { + id: collectionId, + index, + }); + invariant(res && res.success, "Collection could not be moved"); + + const collection = this.get(collectionId); + + if (collection) { + collection.updateIndex(res.data.index); + } + }; + async update(params: Object): Promise { const result = await super.update(params); diff --git a/package.json b/package.json index 345c47ed..86a31f2d 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "file-loader": "^1.1.6", "flow-typed": "^2.6.2", "focus-visible": "^5.1.0", + "fractional-index": "^1.0.0", "fs-extra": "^4.0.2", "http-errors": "1.4.0", "i18next": "^19.8.3", diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap index b3aaf516..011369c3 100644 --- a/server/api/__snapshots__/collections.test.js.snap +++ b/server/api/__snapshots__/collections.test.js.snap @@ -97,6 +97,15 @@ Object { } `; +exports[`#collections.move should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + exports[`#collections.remove_group should require group in team 1`] = ` Object { "error": "authorization_error", diff --git a/server/api/collections.js b/server/api/collections.js index fadae36d..a516f34c 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -1,5 +1,6 @@ // @flow import fs from "fs"; +import fractionalIndex from "fractional-index"; import Router from "koa-router"; import { ValidationError } from "../errors"; import { exportCollections } from "../exporter"; @@ -23,7 +24,10 @@ import { presentGroup, presentCollectionGroupMembership, } from "../presenters"; -import { Op } from "../sequelize"; +import { Op, sequelize } from "../sequelize"; + +import collectionIndexing from "../utils/collectionIndexing"; +import removeIndexCollision from "../utils/removeIndexCollision"; import { archiveCollection, archiveCollections } from "../utils/zip"; import pagination from "./middlewares/pagination"; @@ -39,6 +43,8 @@ router.post("collections.create", auth(), async (ctx) => { icon, sort = Collection.DEFAULT_SORT, } = ctx.body; + + let { index } = ctx.body; const isPrivate = ctx.body.private; ctx.assertPresent(name, "name is required"); @@ -49,6 +55,30 @@ router.post("collections.create", auth(), async (ctx) => { const user = ctx.state.user; authorize(user, "create", Collection); + const collections = await Collection.findAll({ + where: { teamId: user.teamId, deletedAt: null }, + attributes: ["id", "index", "updatedAt"], + limit: 1, + order: [ + // using LC_COLLATE:"C" because we need byte order to drive the sorting + sequelize.literal('"collection"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + }); + + if (index) { + const allowedASCII = new RegExp(/^[\x21-\x7E]+$/); + if (!allowedASCII.test(index)) { + throw new ValidationError( + "Index characters must be between x21 to x7E ASCII" + ); + } + } else { + index = fractionalIndex(null, collections[0].index); + } + + index = await removeIndexCollision(user.teamId, index); + let collection = await Collection.create({ name, description, @@ -59,6 +89,7 @@ router.post("collections.create", auth(), async (ctx) => { private: isPrivate, sharing, sort, + index, }); await Event.create({ @@ -571,6 +602,17 @@ router.post("collections.list", auth(), pagination(), async (ctx) => { limit: ctx.state.pagination.limit, }); + const nullIndexCollection = collections.findIndex( + (collection) => collection.index === null + ); + + if (nullIndexCollection !== -1) { + const indexedCollections = await collectionIndexing(ctx.state.user.teamId); + collections.forEach((collection) => { + collection.index = indexedCollections[collection.id]; + }); + } + ctx.body = { pagination: ctx.state.pagination, data: collections.map(presentCollection), @@ -608,4 +650,34 @@ router.post("collections.delete", auth(), async (ctx) => { }; }); +router.post("collections.move", auth(), async (ctx) => { + const id = ctx.body.id; + let index = ctx.body.index; + + ctx.assertPresent(index, "index is required"); + ctx.assertUuid(id, "id must be a uuid"); + + const user = ctx.state.user; + const collection = await Collection.findByPk(id); + + authorize(user, "move", collection); + + index = await removeIndexCollision(user.teamId, index); + + await collection.update({ index }); + + await Event.create({ + name: "collections.move", + collectionId: collection.id, + teamId: collection.teamId, + actorId: user.id, + data: { index }, + ip: ctx.request.ip, + }); + + ctx.body = { + success: true, + data: { index }, + }; +}); export default router; diff --git a/server/api/collections.test.js b/server/api/collections.test.js index ce4cd927..a68be9bd 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -130,6 +130,127 @@ describe("#collections.import", () => { }); }); +describe("#collections.move", () => { + it("should require authentication", async () => { + const res = await server.post("/api/collections.move"); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should require authorization", async () => { + const user = await buildUser(); + const { collection } = await seed(); + + const res = await server.post("/api/collections.move", { + body: { token: user.getJwtToken(), id: collection.id, index: "P" }, + }); + + expect(res.status).toEqual(403); + }); + + it("should return success", async () => { + const { admin, collection } = await seed(); + const res = await server.post("/api/collections.move", { + body: { token: admin.getJwtToken(), id: collection.id, index: "P" }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toBe(true); + }); + + it("if index collision occurs, should updated index of other collection", async () => { + const { user, admin, collection } = await seed(); + const createdCollectionResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "Test", + sharing: false, + index: "Q", + }, + } + ); + + await createdCollectionResponse.json(); + const movedCollectionRes = await server.post("/api/collections.move", { + body: { token: admin.getJwtToken(), id: collection.id, index: "Q" }, + }); + + const movedCollection = await movedCollectionRes.json(); + + expect(movedCollectionRes.status).toEqual(200); + expect(movedCollection.success).toBe(true); + expect(movedCollection.data.index).toEqual("h"); + expect(movedCollection.data.index > "Q").toBeTruthy(); + }); + + it("if index collision with an extra collection, should updated index of other collection", async () => { + const { user, admin } = await seed(); + const createdCollectionAResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "A", + sharing: false, + index: "a", + }, + } + ); + + const createdCollectionBResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "B", + sharing: false, + index: "b", + }, + } + ); + + const createdCollectionCResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "C", + sharing: false, + index: "c", + }, + } + ); + + await createdCollectionAResponse.json(); + await createdCollectionBResponse.json(); + const createdCollectionC = await createdCollectionCResponse.json(); + + const movedCollectionCResponse = await server.post( + "/api/collections.move", + { + body: { + token: admin.getJwtToken(), + id: createdCollectionC.data.id, + index: "a", + }, + } + ); + + const movedCollectionC = await movedCollectionCResponse.json(); + + expect(movedCollectionCResponse.status).toEqual(200); + expect(movedCollectionC.success).toBe(true); + expect(movedCollectionC.data.index).toEqual("aP"); + expect(movedCollectionC.data.index > "a").toBeTruthy(); + expect(movedCollectionC.data.index < "b").toBeTruthy(); + }); +}); + describe("#collections.export", () => { it("should now allow export of private collection not a member", async () => { const { user } = await seed(); @@ -922,6 +1043,89 @@ describe("#collections.create", () => { expect(body.policies[0].abilities.read).toBeTruthy(); expect(body.policies[0].abilities.export).toBeTruthy(); }); + + it("if index collision, should updated index of other collection", async () => { + const { user } = await seed(); + const createdCollectionAResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "A", + sharing: false, + index: "a", + }, + } + ); + await createdCollectionAResponse.json(); + + const createCollectionResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "C", + sharing: false, + index: "a", + }, + } + ); + + const createdCollection = await createCollectionResponse.json(); + + expect(createCollectionResponse.status).toEqual(200); + expect(createdCollection.data.index).toEqual("p"); + expect(createdCollection.data.index > "a").toBeTruthy(); + }); + + it("if index collision with an extra collection, should updated index of other collection", async () => { + const { user } = await seed(); + const createdCollectionAResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "A", + sharing: false, + index: "a", + }, + } + ); + + const createdCollectionBResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "B", + sharing: false, + index: "b", + }, + } + ); + + await createdCollectionAResponse.json(); + await createdCollectionBResponse.json(); + + const createCollectionResponse = await server.post( + "/api/collections.create", + { + body: { + token: user.getJwtToken(), + name: "C", + sharing: false, + index: "a", + }, + } + ); + + const createdCollection = await createCollectionResponse.json(); + + expect(createCollectionResponse.status).toEqual(200); + expect(createdCollection.data.index).toEqual("aP"); + expect(createdCollection.data.index > "a").toBeTruthy(); + expect(createdCollection.data.index < "b").toBeTruthy(); + }); }); describe("#collections.update", () => { diff --git a/server/events.js b/server/events.js index a3d72d9e..b797478d 100644 --- a/server/events.js +++ b/server/events.js @@ -118,6 +118,7 @@ export type CollectionEvent = collectionId: string, teamId: string, actorId: string, + data: { name: string }, ip: string, } | { @@ -135,6 +136,14 @@ export type CollectionEvent = actorId: string, data: { name: string, groupId: string }, ip: string, + } + | { + name: "collections.move", + collectionId: string, + teamId: string, + actorId: string, + data: { index: string }, + ip: string, }; export type GroupEvent = diff --git a/server/migrations/20210218111237-add-collection-index.js b/server/migrations/20210218111237-add-collection-index.js new file mode 100644 index 00000000..4ce3246f --- /dev/null +++ b/server/migrations/20210218111237-add-collection-index.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("collections", "index", { + type: Sequelize.TEXT, + defaultValue: null, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn("collections", "index"); + }, +}; diff --git a/server/models/Collection.js b/server/models/Collection.js index e2e41f8c..1f6614b5 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -21,6 +21,10 @@ const Collection = sequelize.define( description: DataTypes.STRING, icon: DataTypes.STRING, color: DataTypes.STRING, + index: { + type: DataTypes.STRING, + defaultValue: null, + }, private: DataTypes.BOOLEAN, maintainerApprovalRequired: DataTypes.BOOLEAN, documentStructure: DataTypes.JSONB, diff --git a/server/models/Event.js b/server/models/Event.js index cefc857a..899e6cdf 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -57,6 +57,7 @@ Event.add = (event) => { Event.ACTIVITY_EVENTS = [ "collections.create", "collections.delete", + "collections.move", "documents.publish", "documents.archive", "documents.unarchive", @@ -73,6 +74,7 @@ Event.AUDIT_EVENTS = [ "api_keys.delete", "collections.create", "collections.update", + "collections.move", "collections.add_user", "collections.remove_user", "collections.add_group", diff --git a/server/policies/collection.js b/server/policies/collection.js index 5e3bdd8e..f9cc4e1d 100644 --- a/server/policies/collection.js +++ b/server/policies/collection.js @@ -14,6 +14,13 @@ allow(User, "import", Collection, (actor) => { throw new AdminRequiredError(); }); +allow(User, "move", Collection, (user, collection) => { + if (!collection || user.teamId !== collection.teamId) return false; + if (collection.deletedAt) return false; + if (user.isAdmin) return true; + throw new AdminRequiredError(); +}); + allow(User, ["read", "export"], Collection, (user, collection) => { if (!collection || user.teamId !== collection.teamId) return false; diff --git a/server/presenters/collection.js b/server/presenters/collection.js index b877e9c5..5fbbf628 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -28,6 +28,7 @@ export default function present(collection: Collection) { description: collection.description, sort: collection.sort, icon: collection.icon, + index: collection.index, color: collection.color || "#4E5C6E", private: collection.private, sharing: collection.sharing, diff --git a/server/services/websockets.js b/server/services/websockets.js index d5d3334f..deb34609 100644 --- a/server/services/websockets.js +++ b/server/services/websockets.js @@ -170,6 +170,7 @@ export default class Websockets { }, ], }); + return socketio .to( collection.private @@ -197,6 +198,16 @@ export default class Websockets { ], }); } + + case "collections.move": { + return socketio + .to(`collection-${event.collectionId}`) + .emit("collections.update_index", { + collectionId: event.collectionId, + index: event.data.index, + }); + } + case "collections.add_user": { // the user being added isn't yet in the websocket channel for the collection // so they need to be notified separately diff --git a/server/utils/collectionIndexing.js b/server/utils/collectionIndexing.js new file mode 100644 index 00000000..d27bf662 --- /dev/null +++ b/server/utils/collectionIndexing.js @@ -0,0 +1,39 @@ +// @flow +import fractionalIndex from "fractional-index"; +import naturalSort from "../../shared/utils/naturalSort"; +import { Collection } from "../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 = 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; +} diff --git a/server/utils/removeIndexCollision.js b/server/utils/removeIndexCollision.js new file mode 100644 index 00000000..48ecb711 --- /dev/null +++ b/server/utils/removeIndexCollision.js @@ -0,0 +1,45 @@ +// @flow +import fractionalIndex from "fractional-index"; +import { Collection } from "../models"; +import { sequelize, Op } from "../sequelize"; + +/** + * + * @param teamId The team id whose collections has to be fetched + * @param index the index for which collision has to be checked + * @returns An index, if there is collision returns a new index otherwise the same index + */ +export default async function removeIndexCollision( + teamId: string, + index: string +) { + const collection = await Collection.findOne({ + where: { teamId, deletedAt: null, index }, + }); + + if (!collection) { + return index; + } + + const nextCollection = await Collection.findAll({ + where: { + teamId, + deletedAt: null, + index: { + [Op.gt]: index, + }, + }, + attributes: ["id", "index"], + limit: 1, + order: [ + sequelize.literal('"collection"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + }); + + const nextCollectionIndex = nextCollection.length + ? nextCollection[0].index + : null; + + return fractionalIndex(index, nextCollectionIndex); +} diff --git a/yarn.lock b/yarn.lock index 9687c69d..bd0a1ed4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5741,6 +5741,11 @@ formidable@^1.1.1: resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== +fractional-index@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fractional-index/-/fractional-index-1.0.0.tgz#98d528e26176a70930ef397e3f912de259b6cda9" + integrity sha512-AsCqhK0KuX37mZC8BtP9jSTfor6GxIivLYhbYJS1e6gW//kph+d9oF+BM/Y6NMcCHfGCxhuj+ueyXLLIc+ri1A== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"