mirror of
https://github.com/outline/outline.git
synced 2025-12-30 07:19:52 -06:00
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:
@@ -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);
|
||||
|
||||
41
app/components/Sidebar/components/DraftsLink.tsx
Normal file
41
app/components/Sidebar/components/DraftsLink.tsx
Normal 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;
|
||||
`;
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">) => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user