Enable dragging a document into drafts (#8411)

* Enable dragging a document into drafts

* unpublish by detaching from collection

* websocket events
This commit is contained in:
Hemachandar
2025-02-16 08:15:05 +05:30
committed by GitHub
parent 0b13698998
commit bef4292146
11 changed files with 182 additions and 39 deletions

View File

@@ -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 && (
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25
? "25+"
: documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
/>
)}
{can.createDocument && <DraftsLink />}
</Section>
</Overflow>
<Scrollable flex shadow>
@@ -158,8 +140,4 @@ const Overflow = styled.div`
flex-shrink: 0;
`;
const Drafts = styled(Text)`
margin: 0 4px;
`;
export default observer(AppSidebar);

View File

@@ -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 (
<div ref={dropRef}>
<SidebarLink
to={draftsPath()}
icon={<DraftsIcon />}
label={
<Flex align="center" justify="space-between">
{t("Drafts")}
{documents.totalDrafts > 0 ? (
<Drafts size="xsmall" type="tertiary">
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
</Drafts>
) : null}
</Flex>
}
isActiveDrop={isOver && canDrop}
/>
</div>
);
});
const Drafts = styled(Text)`
margin: 0 4px;
`;

View File

@@ -586,3 +586,45 @@ export function useDropToArchive() {
}),
});
}
export function useDropToUnpublish() {
const { t } = useTranslation();
const { policies, documents } = useStores();
return useDrop<
DragObject,
Promise<void>,
{ 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(),
}),
});
}

View File

@@ -225,6 +225,32 @@ class WebsocketProvider extends React.Component<Props> {
})
);
this.socket.on(
"documents.unpublish",
action(
(event: {
document: PartialExcept<Document, "id">;
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<Document, "id">) => {

View File

@@ -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 = () => {

View File

@@ -776,17 +776,30 @@ export default class DocumentsStore extends Store<Document> {
};
@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);
}
});
};

View File

@@ -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();
};

View File

@@ -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 }),

View File

@@ -1454,7 +1454,7 @@ router.post(
auth(),
validate(T.DocumentsUnpublishSchema),
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
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 = {

View File

@@ -300,6 +300,9 @@ export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
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(),
}),

View File

@@ -182,7 +182,6 @@ export type DocumentEvent = BaseEvent<Document> &
name:
| "documents.create"
| "documents.publish"
| "documents.unpublish"
| "documents.delete"
| "documents.permanent_delete"
| "documents.archive"
@@ -194,6 +193,11 @@ export type DocumentEvent = BaseEvent<Document> &
source?: "import";
};
}
| {
name: "documents.unpublish";
documentId: string;
collectionId: string;
}
| {
name: "documents.unarchive";
documentId: string;