From 45641103bad6c8305a71f286c2d7b25596f98bd3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 29 May 2023 22:49:13 -0400 Subject: [PATCH] Allow viewing diff before revision is written (#5399) --- app/components/DocumentMeta.tsx | 14 +- app/components/EventListItem.tsx | 22 +-- app/scenes/Document/components/DataLoader.tsx | 23 ++- app/scenes/Document/components/Document.tsx | 2 +- .../Document/components/DocumentMeta.tsx | 12 +- app/scenes/Document/components/Header.tsx | 8 +- app/scenes/Document/components/History.tsx | 5 +- .../Document/components/RevisionViewer.tsx | 15 +- app/stores/RevisionsStore.ts | 10 ++ server/models/Revision.ts | 34 ++-- server/routes/api/revisions.ts | 129 --------------- server/routes/api/revisions/index.ts | 1 + .../api/{ => revisions}/revisions.test.ts | 0 server/routes/api/revisions/revisions.ts | 154 ++++++++++++++++++ server/routes/api/revisions/schema.ts | 46 ++++++ shared/i18n/locales/en_US/translation.json | 6 +- shared/utils/RevisionHelper.ts | 11 ++ 17 files changed, 313 insertions(+), 179 deletions(-) delete mode 100644 server/routes/api/revisions.ts create mode 100644 server/routes/api/revisions/index.ts rename server/routes/api/{ => revisions}/revisions.test.ts (100%) create mode 100644 server/routes/api/revisions/revisions.ts create mode 100644 server/routes/api/revisions/schema.ts create mode 100644 shared/utils/RevisionHelper.ts diff --git a/app/components/DocumentMeta.tsx b/app/components/DocumentMeta.tsx index 787d629d4..98c8e6f2a 100644 --- a/app/components/DocumentMeta.tsx +++ b/app/components/DocumentMeta.tsx @@ -6,6 +6,7 @@ import { Link } from "react-router-dom"; import styled from "styled-components"; import { s, ellipsis } from "@shared/styles"; import Document from "~/models/Document"; +import Revision from "~/models/Revision"; import DocumentBreadcrumb from "~/components/DocumentBreadcrumb"; import DocumentTasks from "~/components/DocumentTasks"; import Flex from "~/components/Flex"; @@ -19,6 +20,7 @@ type Props = { showLastViewed?: boolean; showParentDocuments?: boolean; document: Document; + revision?: Revision; replace?: boolean; to?: LocationDescriptor; }; @@ -29,6 +31,7 @@ const DocumentMeta: React.FC = ({ showLastViewed, showParentDocuments, document, + revision, children, replace, to, @@ -64,7 +67,16 @@ const DocumentMeta: React.FC = ({ const userName = updatedBy.name; let content; - if (deletedAt) { + if (revision) { + content = ( + + {revision.createdBy?.id === user.id + ? t("You updated") + : t("{{ userName }} updated", { userName })}{" "} + + ); + } else if (deletedAt) { content = ( {lastUpdatedByCurrentUser diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx index f748cb0dc..77b85c938 100644 --- a/app/components/EventListItem.tsx +++ b/app/components/EventListItem.tsx @@ -7,7 +7,6 @@ import { PublishIcon, MoveIcon, UnpublishIcon, - LightningIcon, } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -61,18 +60,15 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => { switch (event.name) { case "revisions.create": icon = ; - meta = t("{{userName}} edited", opts); + meta = latest ? ( + <> + {t("Current version")} · {event.actor.name} + + ) : ( + t("{{userName}} edited", opts) + ); to = { - pathname: documentHistoryPath(document, event.modelId || ""), - state: { retainScrollPosition: true }, - }; - break; - - case "documents.live_editing": - icon = ; - meta = t("Latest"); - to = { - pathname: documentHistoryPath(document), + pathname: documentHistoryPath(document, event.modelId || "latest"), state: { retainScrollPosition: true }, }; break; @@ -153,7 +149,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => { } actions={ - isRevision && isActive && event.modelId ? ( + isRevision && isActive && event.modelId && !latest ? ( ) : undefined } diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index ccbd9a953..ef370be29 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -2,6 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useLocation, RouteComponentProps, StaticContext } from "react-router"; import { NavigationNode, TeamPreference } from "@shared/types"; +import { RevisionHelper } from "@shared/utils/RevisionHelper"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; import Error404 from "~/scenes/Error404"; @@ -59,7 +60,14 @@ function DataLoader({ match, children }: Props) { documents.getByUrl(match.params.documentSlug) ?? documents.get(match.params.documentSlug); - const revision = revisionId ? revisions.get(revisionId) : undefined; + const revision = revisionId + ? revisions.get( + revisionId === "latest" + ? RevisionHelper.latestId(document?.id) + : revisionId + ) + : undefined; + const sharedTree = document ? documents.getSharedTree(document.id) : undefined; @@ -94,6 +102,19 @@ function DataLoader({ match, children }: Props) { fetchRevision(); }, [revisions, revisionId]); + React.useEffect(() => { + async function fetchRevision() { + if (document && revisionId === "latest") { + try { + await revisions.fetchLatest(document.id); + } catch (err) { + setError(err); + } + } + } + fetchRevision(); + }, [document, revisionId, revisions]); + React.useEffect(() => { async function fetchSubscription() { if (document?.id && !revisionId) { diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 6ee2d51f5..90f14d632 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -451,8 +451,8 @@ class DocumentScene extends React.Component {
+ {team?.getPreference(TeamPreference.Commenting) && ( <>  •  diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 61d4cdd92..05231e299 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -14,6 +14,7 @@ import styled from "styled-components"; import { NavigationNode } from "@shared/types"; import { Theme } from "~/stores/UiStore"; import Document from "~/models/Document"; +import Revision from "~/models/Revision"; import { Action, Separator } from "~/components/Actions"; import Badge from "~/components/Badge"; import Button from "~/components/Button"; @@ -40,11 +41,11 @@ import ShareButton from "./ShareButton"; type Props = { document: Document; documentHasHeadings: boolean; + revision: Revision | undefined; sharedTree: NavigationNode | undefined; shareId: string | null | undefined; isDraft: boolean; isEditing: boolean; - isRevision: boolean; isSaving: boolean; isPublishing: boolean; publishingIsDisabled: boolean; @@ -65,11 +66,11 @@ type Props = { function DocumentHeader({ document, documentHasHeadings, + revision, shareId, isEditing, isDraft, isPublishing, - isRevision, isSaving, savingIsDisabled, publishingIsDisabled, @@ -83,6 +84,7 @@ function DocumentHeader({ const { resolvedTheme } = ui; const { team } = auth; const isMobile = useMobile(); + const isRevision = !!revision; // We cache this value for as long as the component is mounted so that if you // apply a template there is still the option to replace it until the user @@ -287,7 +289,7 @@ function DocumentHeader({ )} - {isRevision && ( + {revision && revision.createdAt !== document.updatedAt && ( & { * Displays revision HTML pre-rendered on the server. */ function RevisionViewer(props: Props) { - const { document, shareId, children, revision } = props; + const { document, children, revision } = props; return (

{revision.title}

- {!shareId && ( - - )} + { return revisions; } + /** + * Fetches the latest revision for the given document. + * + * @returns A promise that resolves to the latest revision for the given document + */ + fetchLatest = async (documentId: string) => { + const res = await client.post(`/revisions.info`, { documentId }); + return this.add(res.data); + }; + @action fetchPage = async ( options: PaginationParams | undefined diff --git a/server/models/Revision.ts b/server/models/Revision.ts index 71aa62a02..d0bfad44f 100644 --- a/server/models/Revision.ts +++ b/server/models/Revision.ts @@ -1,4 +1,4 @@ -import { FindOptions, Op } from "sequelize"; +import { Op, SaveOptions } from "sequelize"; import { DataType, BelongsTo, @@ -74,24 +74,26 @@ class Revision extends IdModel { }); } + static buildFromDocument(document: Document) { + return this.build({ + title: document.title, + text: document.text, + userId: document.lastModifiedById, + editorVersion: document.editorVersion, + version: document.version, + documentId: document.id, + // revision time is set to the last time document was touched as this + // handler can be debounced in the case of an update + createdAt: document.updatedAt, + }); + } + static createFromDocument( document: Document, - options?: FindOptions + options?: SaveOptions ) { - return this.create( - { - title: document.title, - text: document.text, - userId: document.lastModifiedById, - editorVersion: document.editorVersion, - version: document.version, - documentId: document.id, - // revision time is set to the last time document was touched as this - // handler can be debounced in the case of an update - createdAt: document.updatedAt, - }, - options - ); + const revision = this.buildFromDocument(document); + return revision.save(options); } // instance methods diff --git a/server/routes/api/revisions.ts b/server/routes/api/revisions.ts deleted file mode 100644 index 6ddd6dab7..000000000 --- a/server/routes/api/revisions.ts +++ /dev/null @@ -1,129 +0,0 @@ -import Router from "koa-router"; -import { Op } from "sequelize"; -import { ValidationError } from "@server/errors"; -import auth from "@server/middlewares/authentication"; -import { Document, Revision } from "@server/models"; -import DocumentHelper from "@server/models/helpers/DocumentHelper"; -import { authorize } from "@server/policies"; -import { presentRevision } from "@server/presenters"; -import { APIContext } from "@server/types"; -import slugify from "@server/utils/slugify"; -import { assertPresent, assertSort, assertUuid } from "@server/validation"; -import pagination from "./middlewares/pagination"; - -const router = new Router(); - -router.post("revisions.info", auth(), async (ctx: APIContext) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - const { user } = ctx.state.auth; - const revision = await Revision.findByPk(id, { - rejectOnEmpty: true, - }); - - const document = await Document.findByPk(revision.documentId, { - userId: user.id, - }); - authorize(user, "read", document); - - const before = await revision.previous(); - - ctx.body = { - data: await presentRevision( - revision, - await DocumentHelper.diff(before, revision, { - includeTitle: false, - includeStyles: false, - }) - ), - }; -}); - -router.post("revisions.diff", auth(), async (ctx: APIContext) => { - const { id, compareToId } = ctx.request.body; - assertUuid(id, "id is required"); - - const { user } = ctx.state.auth; - const revision = await Revision.findByPk(id, { - rejectOnEmpty: true, - }); - const document = await Document.findByPk(revision.documentId, { - userId: user.id, - }); - authorize(user, "read", document); - - let before; - if (compareToId) { - assertUuid(compareToId, "compareToId must be a UUID"); - before = await Revision.findOne({ - where: { - id: compareToId, - documentId: revision.documentId, - createdAt: { - [Op.lt]: revision.createdAt, - }, - }, - }); - if (!before) { - throw ValidationError( - "Revision could not be found, compareToId must be a revision of the same document before the provided revision" - ); - } - } else { - before = await revision.previous(); - } - - const accept = ctx.request.headers["accept"]; - const content = await DocumentHelper.diff(before, revision); - - if (accept?.includes("text/html")) { - ctx.set("Content-Type", "text/html"); - ctx.set( - "Content-Disposition", - `attachment; filename="${slugify(document.titleWithDefault)}-${ - revision.id - }.html"` - ); - ctx.body = content; - return; - } - - ctx.body = { - data: content, - }; -}); - -router.post("revisions.list", auth(), pagination(), async (ctx: APIContext) => { - let { direction } = ctx.request.body; - const { documentId, sort = "updatedAt" } = ctx.request.body; - if (direction !== "ASC") { - direction = "DESC"; - } - assertSort(sort, Revision); - assertPresent(documentId, "documentId is required"); - - const { user } = ctx.state.auth; - const document = await Document.findByPk(documentId, { - userId: user.id, - }); - authorize(user, "read", document); - - const revisions = await Revision.findAll({ - where: { - documentId: document.id, - }, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - revisions.map((revision) => presentRevision(revision)) - ); - - ctx.body = { - pagination: ctx.state.pagination, - data, - }; -}); - -export default router; diff --git a/server/routes/api/revisions/index.ts b/server/routes/api/revisions/index.ts new file mode 100644 index 000000000..9fa90b620 --- /dev/null +++ b/server/routes/api/revisions/index.ts @@ -0,0 +1 @@ +export { default } from "./revisions"; diff --git a/server/routes/api/revisions.test.ts b/server/routes/api/revisions/revisions.test.ts similarity index 100% rename from server/routes/api/revisions.test.ts rename to server/routes/api/revisions/revisions.test.ts diff --git a/server/routes/api/revisions/revisions.ts b/server/routes/api/revisions/revisions.ts new file mode 100644 index 000000000..ed02a0f32 --- /dev/null +++ b/server/routes/api/revisions/revisions.ts @@ -0,0 +1,154 @@ +import Router from "koa-router"; +import { Op } from "sequelize"; +import { RevisionHelper } from "@shared/utils/RevisionHelper"; +import { ValidationError } from "@server/errors"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { Document, Revision } from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; +import { authorize } from "@server/policies"; +import { presentRevision } from "@server/presenters"; +import { APIContext } from "@server/types"; +import slugify from "@server/utils/slugify"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "revisions.info", + auth(), + validate(T.RevisionsInfoSchema), + async (ctx: APIContext) => { + const { id, documentId } = ctx.input.body; + const { user } = ctx.state.auth; + let before: Revision | null, after: Revision; + + if (id) { + const revision = await Revision.findByPk(id, { + rejectOnEmpty: true, + }); + + const document = await Document.findByPk(revision.documentId, { + userId: user.id, + }); + authorize(user, "read", document); + after = revision; + before = await revision.previous(); + } else if (documentId) { + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "read", document); + after = Revision.buildFromDocument(document); + after.id = RevisionHelper.latestId(document.id); + after.user = document.updatedBy; + + before = await Revision.findLatest(documentId); + } else { + throw ValidationError("Either id or documentId must be provided"); + } + + ctx.body = { + data: await presentRevision( + after, + await DocumentHelper.diff(before, after, { + includeTitle: false, + includeStyles: false, + }) + ), + }; + } +); + +router.post( + "revisions.diff", + auth(), + validate(T.RevisionsDiffSchema), + async (ctx: APIContext) => { + const { id, compareToId } = ctx.input.body; + const { user } = ctx.state.auth; + + const revision = await Revision.findByPk(id, { + rejectOnEmpty: true, + }); + const document = await Document.findByPk(revision.documentId, { + userId: user.id, + }); + authorize(user, "read", document); + + let before; + if (compareToId) { + before = await Revision.findOne({ + where: { + id: compareToId, + documentId: revision.documentId, + createdAt: { + [Op.lt]: revision.createdAt, + }, + }, + }); + if (!before) { + throw ValidationError( + "Revision could not be found, compareToId must be a revision of the same document before the provided revision" + ); + } + } else { + before = await revision.previous(); + } + + const accept = ctx.request.headers["accept"]; + const content = await DocumentHelper.diff(before, revision); + + if (accept?.includes("text/html")) { + ctx.set("Content-Type", "text/html"); + ctx.set( + "Content-Disposition", + `attachment; filename="${slugify(document.titleWithDefault)}-${ + revision.id + }.html"` + ); + ctx.body = content; + return; + } + + ctx.body = { + data: content, + }; + } +); + +router.post( + "revisions.list", + auth(), + pagination(), + validate(T.RevisionsListSchema), + async (ctx: APIContext) => { + const { direction, documentId, sort } = ctx.input.body; + const { user } = ctx.state.auth; + + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "read", document); + + const revisions = await Revision.findAll({ + where: { + documentId: document.id, + }, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + const data = await Promise.all( + revisions.map((revision) => presentRevision(revision)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; + } +); + +export default router; diff --git a/server/routes/api/revisions/schema.ts b/server/routes/api/revisions/schema.ts new file mode 100644 index 000000000..5d3df7fe3 --- /dev/null +++ b/server/routes/api/revisions/schema.ts @@ -0,0 +1,46 @@ +import { isEmpty } from "lodash"; +import { z } from "zod"; +import { Revision } from "@server/models"; +import BaseSchema from "@server/routes/api/BaseSchema"; + +export const RevisionsInfoSchema = BaseSchema.extend({ + body: z + .object({ + id: z.string().uuid().optional(), + documentId: z.string().uuid().optional(), + }) + .refine((req) => !(isEmpty(req.id) && isEmpty(req.documentId)), { + message: "id or documentId is required", + }), +}); + +export type RevisionsInfoReq = z.infer; + +export const RevisionsDiffSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + compareToId: z.string().uuid().optional(), + }), +}); + +export type RevisionsDiffReq = z.infer; + +export const RevisionsListSchema = z.object({ + body: z.object({ + direction: z + .string() + .optional() + .transform((val) => (val !== "ASC" ? "DESC" : val)), + + sort: z + .string() + .refine((val) => Object.keys(Revision.getAttributes()).includes(val), { + message: "Invalid sort parameter", + }) + .default("createdAt"), + + documentId: z.string().uuid(), + }), +}); + +export type RevisionsListReq = z.infer; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a4cb7cec1..54babc3b8 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -127,6 +127,8 @@ "Draft": "Draft", "Template": "Template", "New doc": "New doc", + "You updated": "You updated", + "{{ userName }} updated": "{{ userName }} updated", "You deleted": "You deleted", "{{ userName }} deleted": "{{ userName }} deleted", "You archived": "You archived", @@ -137,8 +139,6 @@ "{{ userName }} published": "{{ userName }} published", "You saved": "You saved", "{{ userName }} saved": "{{ userName }} saved", - "You updated": "You updated", - "{{ userName }} updated": "{{ userName }} updated", "Never viewed": "Never viewed", "Viewed": "Viewed", "in": "in", @@ -164,8 +164,8 @@ "our engineers have been notified": "our engineers have been notified", "Report a Bug": "Report a Bug", "Show Detail": "Show Detail", + "Current version": "Current version", "{{userName}} edited": "{{userName}} edited", - "Latest": "Latest", "{{userName}} archived": "{{userName}} archived", "{{userName}} restored": "{{userName}} restored", "{{userName}} deleted": "{{userName}} deleted", diff --git a/shared/utils/RevisionHelper.ts b/shared/utils/RevisionHelper.ts new file mode 100644 index 000000000..8481f61f2 --- /dev/null +++ b/shared/utils/RevisionHelper.ts @@ -0,0 +1,11 @@ +export class RevisionHelper { + /** + * Get a static id for the latest revision of a document. + * + * @param documentId The document to generate an ID for. + * @returns The ID of the latest revision of the document. + */ + static latestId(documentId?: string) { + return documentId ? `latest-${documentId}` : ""; + } +}