@@ -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;