From bef4292146142a28ba0d62f7f87c261fb245650f Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Sun, 16 Feb 2025 08:15:05 +0530 Subject: [PATCH] Enable dragging a document into drafts (#8411) * Enable dragging a document into drafts * unpublish by detaching from collection * websocket events --- app/components/Sidebar/App.tsx | 30 ++----------- .../Sidebar/components/DraftsLink.tsx | 41 ++++++++++++++++++ .../Sidebar/hooks/useDragAndDrop.tsx | 42 +++++++++++++++++++ app/components/WebsocketProvider.tsx | 26 ++++++++++++ app/models/Document.ts | 6 ++- app/stores/DocumentsStore.ts | 19 +++++++-- server/models/Document.ts | 13 +++++- .../queues/processors/WebsocketsProcessor.ts | 23 +++++++++- server/routes/api/documents/documents.ts | 12 +++--- server/routes/api/documents/schema.ts | 3 ++ server/types.ts | 6 ++- 11 files changed, 182 insertions(+), 39 deletions(-) create mode 100644 app/components/Sidebar/components/DraftsLink.tsx diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index a517c42742..1f89d2210c 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -1,26 +1,25 @@ import { observer } from "mobx-react"; -import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons"; +import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons"; import * as React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { metaDisplay } from "@shared/utils/keyboard"; -import Flex from "~/components/Flex"; import Scrollable from "~/components/Scrollable"; -import Text from "~/components/Text"; import { inviteUser } from "~/actions/definitions/users"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import OrganizationMenu from "~/menus/OrganizationMenu"; -import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers"; +import { homePath, searchPath } from "~/utils/routeHelpers"; import TeamLogo from "../TeamLogo"; import Tooltip from "../Tooltip"; import Sidebar from "./Sidebar"; import ArchiveLink from "./components/ArchiveLink"; import Collections from "./components/Collections"; +import { DraftsLink } from "./components/DraftsLink"; import DragPlaceholder from "./components/DragPlaceholder"; import HistoryNavigation from "./components/HistoryNavigation"; import Section from "./components/Section"; @@ -107,24 +106,7 @@ function AppSidebar() { label={t("Search")} exact={false} /> - {can.createDocument && ( - } - label={ - - {t("Drafts")} - {documents.totalDrafts > 0 ? ( - - {documents.totalDrafts > 25 - ? "25+" - : documents.totalDrafts} - - ) : null} - - } - /> - )} + {can.createDocument && } @@ -158,8 +140,4 @@ const Overflow = styled.div` flex-shrink: 0; `; -const Drafts = styled(Text)` - margin: 0 4px; -`; - export default observer(AppSidebar); diff --git a/app/components/Sidebar/components/DraftsLink.tsx b/app/components/Sidebar/components/DraftsLink.tsx new file mode 100644 index 0000000000..0b67263f68 --- /dev/null +++ b/app/components/Sidebar/components/DraftsLink.tsx @@ -0,0 +1,41 @@ +import { observer } from "mobx-react"; +import { DraftsIcon } from "outline-icons"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Flex from "~/components/Flex"; +import Text from "~/components/Text"; +import useStores from "~/hooks/useStores"; +import { draftsPath } from "~/utils/routeHelpers"; +import { useDropToUnpublish } from "../hooks/useDragAndDrop"; +import SidebarLink from "./SidebarLink"; + +export const DraftsLink = observer(() => { + const { t } = useTranslation(); + const { documents } = useStores(); + const [{ isOver, canDrop }, dropRef] = useDropToUnpublish(); + + return ( +
+ } + label={ + + {t("Drafts")} + {documents.totalDrafts > 0 ? ( + + {documents.totalDrafts > 25 ? "25+" : documents.totalDrafts} + + ) : null} + + } + isActiveDrop={isOver && canDrop} + /> +
+ ); +}); + +const Drafts = styled(Text)` + margin: 0 4px; +`; diff --git a/app/components/Sidebar/hooks/useDragAndDrop.tsx b/app/components/Sidebar/hooks/useDragAndDrop.tsx index dcd51db121..4d1867c7ca 100644 --- a/app/components/Sidebar/hooks/useDragAndDrop.tsx +++ b/app/components/Sidebar/hooks/useDragAndDrop.tsx @@ -586,3 +586,45 @@ export function useDropToArchive() { }), }); } + +export function useDropToUnpublish() { + const { t } = useTranslation(); + const { policies, documents } = useStores(); + + return useDrop< + DragObject, + Promise, + { isOver: boolean; canDrop: boolean } + >({ + accept: "document", + drop: async (item) => { + const document = documents.get(item.id); + if (!document) { + return; + } + + try { + await document.unpublish({ detach: true }); + toast.success( + t("Unpublished {{ documentName }}", { + documentName: document.noun, + }) + ); + } catch (err) { + toast.error(err.message); + } + }, + canDrop: (item) => { + const policy = policies.abilities(item.id); + if (!policy) { + return true; // optimistic, let the server check for the necessary permission. + } + + return policy.unpublish; + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); +} diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index 51907aec1a..2a34ad8342 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -225,6 +225,32 @@ class WebsocketProvider extends React.Component { }) ); + this.socket.on( + "documents.unpublish", + action( + (event: { + document: PartialExcept; + collectionId: string; + }) => { + const document = event.document; + + // When document is detached as part of unpublishing, only the owner should be able to view it. + if ( + !document.collectionId && + document.createdBy?.id !== currentUserId + ) { + documents.remove(document.id); + } else { + documents.add(document); + } + policies.remove(document.id); + + const collection = collections.get(event.collectionId); + collection?.removeDocument(document.id); + } + ) + ); + this.socket.on( "documents.archive", action((event: PartialExcept) => { diff --git a/app/models/Document.ts b/app/models/Document.ts index a77d1578c1..ed05f872c0 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -448,7 +448,11 @@ export default class Document extends ArchivableModel implements Searchable { restore = (options?: { revisionId?: string; collectionId?: string }) => this.store.restore(this, options); - unpublish = () => this.store.unpublish(this); + unpublish = ( + options: { detach?: boolean } = { + detach: false, + } + ) => this.store.unpublish(this, options); @action enableEmbeds = () => { diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index e0699e1bc0..a87a38f31c 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -776,17 +776,30 @@ export default class DocumentsStore extends Store { }; @action - unpublish = async (document: Document) => { + unpublish = async ( + document: Document, + options: { detach?: boolean } = { + detach: false, + } + ) => { const res = await client.post("/documents.unpublish", { id: document.id, + ...options, }); runInAction("Document#unpublish", () => { invariant(res?.data, "Data should be available"); + // unpublishing could sometimes detach the document from the collection. + // so, get the collection id before data is updated. + const collectionId = document.collectionId; + document.updateData(res.data); this.addPolicies(res.policies); - const collection = this.getCollectionForDocument(document); - void collection?.fetchDocuments({ force: true }); + + if (collectionId) { + const collection = this.rootStore.collections.get(collectionId); + collection?.removeDocument(document.id); + } }); }; diff --git a/server/models/Document.ts b/server/models/Document.ts index 144711b879..4eef7eb6d0 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -981,7 +981,13 @@ class Document extends ArchivableModel< return false; }; - unpublish = async (user: User) => { + /** + * + * @param user User who is performing the action + * @param options.detach Whether to detach the document from the containing collection + * @returns Updated document + */ + unpublish = async (user: User, options: { detach: boolean }) => { // If the document is already a draft then calling unpublish should act like save if (!this.publishedAt) { return this.save(); @@ -1010,6 +1016,11 @@ class Document extends ArchivableModel< this.createdBy = user; this.updatedBy = user; this.publishedAt = null; + + if (options.detach) { + this.collectionId = null; + } + return this.save(); }; diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index a3d261674b..75c49f37e9 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -42,7 +42,6 @@ export default class WebsocketsProcessor { switch (event.name) { case "documents.create": case "documents.publish": - case "documents.unpublish": case "documents.restore": { const document = await Document.findByPk(event.documentId, { paranoid: false, @@ -73,6 +72,28 @@ export default class WebsocketsProcessor { }); } + case "documents.unpublish": { + const document = await Document.findByPk(event.documentId, { + paranoid: false, + }); + + if (!document) { + return; + } + + const documentToPresent = await presentDocument(undefined, document); + + const channels = await this.getDocumentEventChannels(event, document); + + // We need to add the collection channel to let the members update the doc structure. + channels.push(`collection-${event.collectionId}`); + + return socketio.to(channels).emit(event.name, { + document: documentToPresent, + collectionId: event.collectionId, + }); + } + case "documents.unarchive": { const [document, srcCollection] = await Promise.all([ Document.findByPk(event.documentId, { paranoid: false }), diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 70dcfa42d6..a6a015c134 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1454,7 +1454,7 @@ router.post( auth(), validate(T.DocumentsUnpublishSchema), async (ctx: APIContext) => { - const { id } = ctx.input.body; + const { id, detach } = ctx.input.body; const { user } = ctx.state.auth; const document = await Document.findByPk(id, { @@ -1473,14 +1473,14 @@ router.post( ); } - await document.unpublish(user); + // detaching would unset collectionId from document, so save a ref to the affected collectionId. + const collectionId = document.collectionId; + + await document.unpublish(user, { detach }); await Event.createFromContext(ctx, { name: "documents.unpublish", documentId: document.id, - collectionId: document.collectionId, - data: { - title: document.title, - }, + collectionId, }); ctx.body = { diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index e089544a0f..be38350268 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -300,6 +300,9 @@ export type DocumentsDeleteReq = z.infer; export const DocumentsUnpublishSchema = BaseSchema.extend({ body: BaseIdSchema.extend({ + /** Whether to detach the document from the collection */ + detach: z.boolean().default(false), + /** @deprecated Version of the API to be used, remove in a few releases */ apiVersion: z.number().optional(), }), diff --git a/server/types.ts b/server/types.ts index 3b715a2bf2..e6d0868aa0 100644 --- a/server/types.ts +++ b/server/types.ts @@ -182,7 +182,6 @@ export type DocumentEvent = BaseEvent & name: | "documents.create" | "documents.publish" - | "documents.unpublish" | "documents.delete" | "documents.permanent_delete" | "documents.archive" @@ -194,6 +193,11 @@ export type DocumentEvent = BaseEvent & source?: "import"; }; } + | { + name: "documents.unpublish"; + documentId: string; + collectionId: string; + } | { name: "documents.unarchive"; documentId: string;