Allow viewing diff before revision is written (#5399)

This commit is contained in:
Tom Moor
2023-05-29 22:49:13 -04:00
committed by GitHub
parent 555691c79b
commit 45641103ba
17 changed files with 313 additions and 179 deletions

View File

@@ -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<Props> = ({
showLastViewed,
showParentDocuments,
document,
revision,
children,
replace,
to,
@@ -64,7 +67,16 @@ const DocumentMeta: React.FC<Props> = ({
const userName = updatedBy.name;
let content;
if (deletedAt) {
if (revision) {
content = (
<span>
{revision.createdBy?.id === user.id
? t("You updated")
: t("{{ userName }} updated", { userName })}{" "}
<Time dateTime={revision.createdAt} addSuffix />
</span>
);
} else if (deletedAt) {
content = (
<span>
{lastUpdatedByCurrentUser

View File

@@ -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 = <EditIcon size={16} />;
meta = t("{{userName}} edited", opts);
meta = latest ? (
<>
{t("Current version")} &middot; {event.actor.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(document, event.modelId || ""),
state: { retainScrollPosition: true },
};
break;
case "documents.live_editing":
icon = <LightningIcon size={16} />;
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) => {
</Subtitle>
}
actions={
isRevision && isActive && event.modelId ? (
isRevision && isActive && event.modelId && !latest ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}

View File

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

View File

@@ -451,8 +451,8 @@ class DocumentScene extends React.Component<Props> {
<Header
document={document}
documentHasHeadings={hasHeadings}
revision={revision}
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly && !team?.seamlessEditing}
isSaving={this.isSaving}

View File

@@ -7,6 +7,7 @@ import { Link, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useStores from "~/hooks/useStores";
@@ -15,12 +16,19 @@ import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
type Props = {
/* The document to display meta data for */
document: Document;
revision?: Revision;
isDraft: boolean;
to?: LocationDescriptor;
rtl?: boolean;
};
function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
function TitleDocumentMeta({
to,
isDraft,
document,
revision,
...rest
}: Props) {
const { auth, views, comments, ui } = useStores();
const { t } = useTranslation();
const { team } = auth;
@@ -36,7 +44,7 @@ function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
const commentsCount = comments.inDocument(document.id).length;
return (
<Meta document={document} to={to} replace {...rest}>
<Meta document={document} revision={revision} to={to} replace {...rest}>
{team?.getPreference(TeamPreference.Commenting) && (
<>
&nbsp;&nbsp;

View File

@@ -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({
</Button>
</Action>
)}
{isRevision && (
{revision && revision.createdAt !== document.updatedAt && (
<Action>
<Tooltip
tooltip={t("Restore version")}

View File

@@ -3,6 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Event from "~/models/Event";
import Empty from "~/components/Empty";
import PaginatedEventList from "~/components/PaginatedEventList";
@@ -41,8 +42,8 @@ function History() {
eventsInDocument.unshift(
new Event(
{
id: "live",
name: "documents.live_editing",
id: RevisionHelper.latestId(document.id),
name: "revisions.create",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,

View File

@@ -20,18 +20,17 @@ type Props = Omit<EditorProps, "extensions"> & {
* Displays revision HTML pre-rendered on the server.
*/
function RevisionViewer(props: Props) {
const { document, shareId, children, revision } = props;
const { document, children, revision } = props;
return (
<Flex auto column>
<h1 dir={revision.dir}>{revision.title}</h1>
{!shareId && (
<DocumentMeta
document={document}
to={documentPath(document)}
rtl={revision.rtl}
/>
)}
<DocumentMeta
document={document}
revision={revision}
to={documentPath(document)}
rtl={revision.rtl}
/>
<EditorContainer
dangerouslySetInnerHTML={{ __html: revision.html }}
dir={revision.dir}

View File

@@ -47,6 +47,16 @@ export default class RevisionsStore extends BaseStore<Revision> {
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

View File

@@ -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<Revision>
options?: SaveOptions<Revision>
) {
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

View File

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

View File

@@ -0,0 +1 @@
export { default } from "./revisions";

View File

@@ -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<T.RevisionsInfoReq>) => {
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<T.RevisionsDiffReq>) => {
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<T.RevisionsListReq>) => {
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;

View File

@@ -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<typeof RevisionsInfoSchema>;
export const RevisionsDiffSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
compareToId: z.string().uuid().optional(),
}),
});
export type RevisionsDiffReq = z.infer<typeof RevisionsDiffSchema>;
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<typeof RevisionsListSchema>;

View File

@@ -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",

View File

@@ -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}` : "";
}
}