mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 9a1c8f07d1 | |||
| 241cb11493 | |||
| 8195791bb2 | |||
| b037ae5dc1 | |||
| aeba8ce4eb | |||
| 3565e68725 | |||
| 429c5fba85 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| 3df82c500b |
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
@@ -101,7 +101,11 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered.map((item, index) => {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
return filterTemplateItems(items).map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
+48
-42
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
@@ -110,6 +110,52 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const can = policies.abilities(collection.id);
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
filterTemplateItems([
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]),
|
||||
[can, collection, handleNewDocument, handleImportDocument, t]
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -134,47 +180,7 @@ function CollectionMenu({
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
|
||||
+19
-17
@@ -149,25 +149,27 @@ function CollectionScene() {
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<Separator />
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
|
||||
@@ -33,7 +33,7 @@ type Props = {|
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@observable activeLinkEvent: ?MouseEvent;
|
||||
ref = React.createRef<Textarea>();
|
||||
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
|
||||
|
||||
focusAtStart = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
@@ -143,9 +143,8 @@ class DocumentEditor extends React.Component<Props> {
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
rtl={
|
||||
this.ref.current?.textarea
|
||||
? window.getComputedStyle(this.ref.current.textarea)
|
||||
.direction === "rtl"
|
||||
this.ref.current
|
||||
? window.getComputedStyle(this.ref.current).direction === "rtl"
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -50,10 +50,13 @@ class Drafts extends React.Component<Props> {
|
||||
}) => {
|
||||
this.props.history.replace({
|
||||
pathname: this.props.location.pathname,
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
search: queryString.stringify(
|
||||
{
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -140,10 +140,13 @@ class Search extends React.Component<Props> {
|
||||
}) => {
|
||||
this.props.history.replace({
|
||||
pathname: this.props.location.pathname,
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
search: queryString.stringify(
|
||||
{
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+7
-4
@@ -13,6 +13,7 @@
|
||||
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/",
|
||||
"lint": "eslint app server shared",
|
||||
"deploy": "git push heroku master",
|
||||
"postinstall": "yarn yarn-deduplicate yarn.lock",
|
||||
"heroku-postbuild": "yarn build && yarn db:migrate",
|
||||
"sequelize:migrate": "sequelize db:migrate",
|
||||
"db:create-migration": "sequelize migration:create",
|
||||
@@ -109,6 +110,7 @@
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-htmldiff": "^0.9.3",
|
||||
"nodemailer": "^6.4.16",
|
||||
"outline-icons": "^1.27.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
@@ -118,7 +120,7 @@
|
||||
"pg": "^8.5.1",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"polished": "3.6.5",
|
||||
"query-string": "^4.3.4",
|
||||
"query-string": "^7.0.1",
|
||||
"quoted-printable": "^1.0.1",
|
||||
"randomstring": "1.1.5",
|
||||
"raw-loader": "^0.5.1",
|
||||
@@ -200,12 +202,13 @@
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^3.0.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^6.1.0"
|
||||
"workbox-webpack-plugin": "^6.1.0",
|
||||
"yarn-deduplicate": "^3.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-view": "1.18.1",
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.56.0"
|
||||
}
|
||||
"version": "0.57.0"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
let {
|
||||
sort = "createdAt",
|
||||
actorId,
|
||||
documentId,
|
||||
collectionId,
|
||||
direction,
|
||||
name,
|
||||
@@ -31,10 +32,12 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
|
||||
if (actorId) {
|
||||
ctx.assertUuid(actorId, "actorId must be a UUID");
|
||||
where = {
|
||||
...where,
|
||||
actorId,
|
||||
};
|
||||
where = { ...where, actorId };
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
ctx.assertUuid(documentId, "documentId must be a UUID");
|
||||
where = { ...where, documentId };
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
import { buildEvent } from "../test/factories";
|
||||
import { buildEvent, buildUser } from "../test/factories";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@@ -101,6 +101,54 @@ describe("#events.list", () => {
|
||||
expect(body.data[0].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by documentId", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should not return events for documentId without authorization", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const actor = await buildUser();
|
||||
|
||||
await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: actor.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should allow filtering by event name", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
|
||||
@@ -41,11 +41,13 @@ export const CollectionNotificationEmail = ({
|
||||
<Body>
|
||||
<Heading>{collection.name}</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the collection "{collection.name}".
|
||||
{actor.name} {eventName} the collection “{collection.name}”.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${process.env.URL}${collection.url}`}>
|
||||
<Button
|
||||
href={`${process.env.URL}${collection.url}?ref=notification-email`}
|
||||
>
|
||||
Open Collection
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../shared/styles/theme";
|
||||
import { User, Document, Team, Collection } from "../models";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
@@ -15,6 +17,7 @@ export type Props = {
|
||||
document: Document,
|
||||
collection: Collection,
|
||||
eventName: string,
|
||||
summary: string,
|
||||
unsubscribeUrl: string,
|
||||
};
|
||||
|
||||
@@ -38,26 +41,34 @@ export const DocumentNotificationEmail = ({
|
||||
document,
|
||||
collection,
|
||||
eventName = "published",
|
||||
summary,
|
||||
unsubscribeUrl,
|
||||
}: Props) => {
|
||||
const link = `${team.url}${document.url}?ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
"{document.title}" {eventName}
|
||||
“{document.title}” {eventName}
|
||||
</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the document "{document.title}", in the{" "}
|
||||
{collection.name} collection.
|
||||
</p>
|
||||
<hr />
|
||||
<EmptySpace height={10} />
|
||||
<p>{document.getSummary()}</p>
|
||||
<EmptySpace height={10} />
|
||||
{summary && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff href={link}>
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={`${team.url}${document.url}`}>Open Document</Button>
|
||||
<Button href={link}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
@@ -65,3 +76,211 @@ export const DocumentNotificationEmail = ({
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export const css = `
|
||||
font-family: ${theme.fontFamily};
|
||||
font-weight: ${theme.fontWeight};
|
||||
font-size: 1em;
|
||||
line-height: 1.7em;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
img.image-right-50 {
|
||||
float: right;
|
||||
width: 50%;
|
||||
margin-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
img.image-left-50 {
|
||||
float: left;
|
||||
width: 50%;
|
||||
margin-right: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${theme.noticeInfoBackground};
|
||||
color: ${theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.notice-tip {
|
||||
background: ${theme.noticeTipBackground};
|
||||
color: ${theme.noticeTipText};
|
||||
}
|
||||
|
||||
.notice-warning {
|
||||
background: ${theme.noticeWarningBackground};
|
||||
color: ${theme.noticeWarningText};
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${theme.link};
|
||||
}
|
||||
|
||||
ins {
|
||||
background-color: #128a2929;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
del {
|
||||
background-color: ${theme.slateLight};
|
||||
color: ${theme.slate};
|
||||
text-decoration: strikethrough;
|
||||
}
|
||||
|
||||
hr {
|
||||
position: relative;
|
||||
height: 1em;
|
||||
border: 0;
|
||||
}
|
||||
hr:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-top: 1px solid ${theme.horizontalRule};
|
||||
top: 0.5em;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
hr.page-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
hr.page-break:before {
|
||||
border-top: 1px dashed ${theme.horizontalRule};
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
padding: 3px 4px;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
mark {
|
||||
border-radius: 1px;
|
||||
color: ${theme.textHighlightForeground};
|
||||
background: ${theme.textHighlight};
|
||||
a {
|
||||
color: ${theme.textHighlightForeground};
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.checkbox-list-item {
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
font-size: 0;
|
||||
display: block;
|
||||
float: left;
|
||||
white-space: nowrap;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 2px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid ${theme.textSecondary};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.75em 1em;
|
||||
line-height: 1.4em;
|
||||
position: relative;
|
||||
background: ${theme.codeBackground};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
-webkit-font-smoothing: initial;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 13px;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
box-sizing: border-box;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
tr {
|
||||
position: relative;
|
||||
border-bottom: 1px solid ${theme.tableDivider};
|
||||
}
|
||||
td,
|
||||
th {
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
border: 1px solid ${theme.tableDivider};
|
||||
position: relative;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const InviteEmail = ({
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
|
||||
<Button href={`${teamUrl}/home?ref=welcome-email`}>
|
||||
View my dashboard
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
href?: string,
|
||||
|};
|
||||
|
||||
export default ({ children, ...rest }: Props) => {
|
||||
const style = {
|
||||
borderRadius: "4px",
|
||||
background: theme.secondaryBackground,
|
||||
padding: ".5em 1em",
|
||||
color: theme.text,
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<a width="100%" style={style} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { Table, TBody, TR, TD } from "oy-vey";
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
export default (props: Props) => (
|
||||
<Table width="550" padding="40">
|
||||
|
||||
@@ -100,6 +100,8 @@ export type RevisionEvent = {
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
modelId: string,
|
||||
};
|
||||
|
||||
export type CollectionImportEvent = {
|
||||
|
||||
+3
-1
@@ -13,6 +13,7 @@ import {
|
||||
type Props as DocumentNotificationEmailT,
|
||||
DocumentNotificationEmail,
|
||||
documentNotificationEmailText,
|
||||
css as documentNotificationEmailCSS,
|
||||
} from "./emails/DocumentNotificationEmail";
|
||||
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
|
||||
import {
|
||||
@@ -146,8 +147,9 @@ export class Mailer {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
title: `“${opts.document.title}” ${opts.eventName}`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a document`,
|
||||
html: <DocumentNotificationEmail {...opts} />,
|
||||
headCSS: documentNotificationEmailCSS,
|
||||
text: documentNotificationEmailText(opts),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -395,7 +395,11 @@ Collection.prototype.isChildDocument = function (
|
||||
let result = false;
|
||||
|
||||
const loopChildren = (documents, input) => {
|
||||
return documents.map((document) => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
|
||||
documents.forEach((document) => {
|
||||
let parents = [...input];
|
||||
if (document.id === documentId) {
|
||||
result = parents.includes(parentDocumentId);
|
||||
@@ -403,7 +407,6 @@ Collection.prototype.isChildDocument = function (
|
||||
parents.push(document.id);
|
||||
loopChildren(document.children, parents);
|
||||
}
|
||||
return document;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
// @flow
|
||||
import debug from "debug";
|
||||
import type { DocumentEvent, CollectionEvent, Event } from "../events";
|
||||
import type {
|
||||
DocumentEvent,
|
||||
RevisionEvent,
|
||||
CollectionEvent,
|
||||
Event,
|
||||
} from "../events";
|
||||
import mailer from "../mailer";
|
||||
import {
|
||||
View,
|
||||
Document,
|
||||
Team,
|
||||
Collection,
|
||||
Revision,
|
||||
User,
|
||||
NotificationSetting,
|
||||
Attachment,
|
||||
} from "../models";
|
||||
import { Op } from "../sequelize";
|
||||
import markdownDiff from "../utils/markdownDiff";
|
||||
|
||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||
import { getSignedImageUrl } from "../utils/s3";
|
||||
|
||||
const log = debug("services");
|
||||
|
||||
async function replaceImageAttachments(text: string) {
|
||||
const attachmentIds = parseAttachmentIds(text);
|
||||
|
||||
await Promise.all(
|
||||
attachmentIds.map(async (id) => {
|
||||
const attachment = await Attachment.findByPk(id);
|
||||
if (attachment) {
|
||||
const accessUrl = await getSignedImageUrl(attachment.key, 86400 * 4);
|
||||
text = text.replace(attachment.redirectUrl, accessUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export default class Notifications {
|
||||
async on(event: Event) {
|
||||
switch (event.name) {
|
||||
case "documents.publish":
|
||||
case "documents.update.debounced":
|
||||
return this.documentUpdated(event);
|
||||
return this.documentPublished(event);
|
||||
case "revisions.create":
|
||||
return this.revisionCreated(event);
|
||||
case "collections.create":
|
||||
return this.collectionCreated(event);
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
async documentUpdated(event: DocumentEvent) {
|
||||
async documentPublished(event: DocumentEvent) {
|
||||
// never send notifications when batch importing documents
|
||||
if (event.data && event.data.source === "import") return;
|
||||
|
||||
@@ -45,10 +73,7 @@ export default class Notifications {
|
||||
[Op.ne]: document.lastModifiedById,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event:
|
||||
event.name === "documents.publish"
|
||||
? "documents.publish"
|
||||
: "documents.update",
|
||||
event: "documents.publish",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -59,25 +84,14 @@ export default class Notifications {
|
||||
],
|
||||
});
|
||||
|
||||
const eventName =
|
||||
event.name === "documents.publish" ? "published" : "updated";
|
||||
const eventName = "published";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// For document updates we only want to send notifications if
|
||||
// the document has been edited by the user with this notification setting
|
||||
// This could be replaced with ability to "follow" in the future
|
||||
if (
|
||||
eventName === "updated" &&
|
||||
!document.collaboratorIds.includes(setting.userId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the user has access to the collection this document is in. Just
|
||||
// because they were a collaborator once doesn't mean they still are.
|
||||
const collectionIds = await setting.user.collectionIds();
|
||||
if (!collectionIds.includes(document.collectionId)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this user has viewed the document since the last update was made
|
||||
@@ -96,7 +110,7 @@ export default class Notifications {
|
||||
log(
|
||||
`suppressing notification to ${setting.userId} because update viewed`
|
||||
);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
mailer.documentNotification({
|
||||
@@ -105,12 +119,119 @@ export default class Notifications {
|
||||
document,
|
||||
team,
|
||||
collection,
|
||||
summary: document.getSummary(),
|
||||
actor: document.updatedBy,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async revisionCreated(event: RevisionEvent) {
|
||||
const revision = await Revision.findByPk(event.modelId, {
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
include: [
|
||||
{
|
||||
model: Collection,
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!revision) return;
|
||||
|
||||
const { document } = revision;
|
||||
const { collection } = document;
|
||||
if (!collection || !document) return;
|
||||
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
if (!team) return;
|
||||
|
||||
const notificationSettings = await NotificationSetting.findAll({
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: revision.userId,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eventName = "updated";
|
||||
|
||||
for (const setting of notificationSettings) {
|
||||
// For document updates we only want to send notifications if
|
||||
// the document has been edited by the user with this notification setting
|
||||
// This could be replaced with ability to "follow" in the future
|
||||
if (!document.collaboratorIds.includes(setting.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the user has access to the collection this document is in. Just
|
||||
// because they were a collaborator once doesn't mean they still are.
|
||||
const collectionIds = await setting.user.collectionIds();
|
||||
if (!collectionIds.includes(document.collectionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this user has viewed the document since the last update was made
|
||||
// then we can avoid sending them a useless notification, yay.
|
||||
const view = await View.findOne({
|
||||
where: {
|
||||
userId: setting.userId,
|
||||
documentId: event.documentId,
|
||||
updatedAt: {
|
||||
[Op.gt]: document.updatedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (view) {
|
||||
log(
|
||||
`suppressing notification to ${setting.userId} because update viewed`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = await Revision.findOne({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
createdAt: {
|
||||
[Op.lt]: revision.createdAt,
|
||||
},
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
let summary = markdownDiff(previous ? previous.text : "", revision.text);
|
||||
|
||||
console.log(summary);
|
||||
summary = await replaceImageAttachments(summary);
|
||||
console.log(summary);
|
||||
|
||||
mailer.documentNotification({
|
||||
to: setting.user.email,
|
||||
eventName,
|
||||
document,
|
||||
team,
|
||||
collection,
|
||||
summary,
|
||||
actor: revision.user,
|
||||
unsubscribeUrl: setting.unsubscribeUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async collectionCreated(event: CollectionEvent) {
|
||||
const collection = await Collection.findByPk(event.collectionId, {
|
||||
include: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import mailer from "../mailer";
|
||||
import { View, NotificationSetting } from "../models";
|
||||
import { View, NotificationSetting, Revision } from "../models";
|
||||
import { buildDocument, buildCollection, buildUser } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import NotificationsService from "./notifications";
|
||||
@@ -89,9 +89,10 @@ describe("documents.publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("documents.update.debounced", () => {
|
||||
describe("revisions.create", () => {
|
||||
test("should send a notification to other collaborator", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({ teamId: document.teamId });
|
||||
document.collaboratorIds = [collaborator.id];
|
||||
await document.save();
|
||||
@@ -103,8 +104,9 @@ describe("documents.update.debounced", () => {
|
||||
});
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
@@ -115,6 +117,7 @@ describe("documents.update.debounced", () => {
|
||||
|
||||
test("should not send a notification if viewed since update", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
const collaborator = await buildUser({ teamId: document.teamId });
|
||||
document.collaboratorIds = [collaborator.id];
|
||||
await document.save();
|
||||
@@ -128,9 +131,10 @@ describe("documents.update.debounced", () => {
|
||||
await View.touch(document.id, collaborator.id, true);
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
});
|
||||
@@ -138,12 +142,13 @@ describe("documents.update.debounced", () => {
|
||||
expect(mailer.documentNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send a notification to last editor", async () => {
|
||||
test("should not send a notification to the last user that modified", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
lastModifiedById: user.id,
|
||||
});
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
await NotificationSetting.create({
|
||||
userId: user.id,
|
||||
@@ -152,8 +157,9 @@ describe("documents.update.debounced", () => {
|
||||
});
|
||||
|
||||
await Notifications.on({
|
||||
name: "documents.update.debounced",
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
modelId: revision.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: document.createdById,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import type { DocumentEvent, RevisionEvent } from "../events";
|
||||
import { Revision, Document } from "../models";
|
||||
import { Revision, Document, Event } from "../models";
|
||||
|
||||
export default class Revisions {
|
||||
async on(event: DocumentEvent | RevisionEvent) {
|
||||
@@ -22,7 +22,15 @@ export default class Revisions {
|
||||
return;
|
||||
}
|
||||
|
||||
await Revision.createFromDocument(document);
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
Event.add({
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: revision.id,
|
||||
teamId: document.teamId,
|
||||
actorId: revision.userId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
This is a test paragraph
|
||||
|
||||
This is a second test paragraph. This is a second sentence.
|
||||
|
||||
This is a another test paragraph. This is a another sentence.
|
||||
|
||||
- list item 1
|
||||
- list item 2
|
||||
|
||||
```
|
||||
this is a codeblock
|
||||
```
|
||||
|
||||
:::info
|
||||
This is an info block
|
||||
:::
|
||||
|
||||
!!This is a placeholder!!
|
||||
|
||||
==this is a highlight==
|
||||
|
||||
- [ ] checklist item 1
|
||||
- [ ] checklist item 2
|
||||
- [x] checklist item 3
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
This is a test paragraph
|
||||
|
||||
This is a second test paragraph. This is a second sentence.
|
||||
|
||||
This is a another test paragraph. This is a another sentence.
|
||||
|
||||
- list item 1
|
||||
|
||||
```
|
||||
this is a codeblock
|
||||
```
|
||||
|
||||
This is a new paragraph.
|
||||
|
||||
:::info
|
||||
This is an info block
|
||||
:::
|
||||
|
||||
!!This is a placeholder!!
|
||||
|
||||
==this is a highlight==
|
||||
|
||||
- [x] checklist item 1
|
||||
- [x] checklist item 2
|
||||
- [ ] checklist item 3
|
||||
- [ ] checklist item 4
|
||||
- [x] checklist item 5
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
|
||||
same on both sides
|
||||
@@ -2,9 +2,11 @@
|
||||
require("dotenv").config({ silent: true });
|
||||
|
||||
// test environment variables
|
||||
process.env.URL = "http://localhost:3000";
|
||||
process.env.DATABASE_URL = process.env.DATABASE_URL_TEST;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.GOOGLE_CLIENT_ID = "123";
|
||||
process.env.AZURE_CLIENT_ID = "";
|
||||
process.env.SLACK_KEY = "123";
|
||||
process.env.DEPLOYMENT = "";
|
||||
process.env.ALLOWED_DOMAINS = "allowed-domain.com";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`should diff a complex document 1`] = `
|
||||
"<p>This is a second test paragraph. This is a second sentence.</p>
|
||||
<p>This is a another test paragraph. This is a another sentence.</p>
|
||||
<ul>
|
||||
<li>list item 1</li>
|
||||
<li data-diff-node=\\"del\\" data-operation-index=\\"1\\"><del data-operation-index=\\"1\\">list item 2</del></li></ul>
|
||||
<pre><code>this is a codeblock
|
||||
</code></pre><p data-diff-node=\\"ins\\" data-operation-index=\\"3\\"><ins data-operation-index=\\"3\\">This is a new paragraph.</ins></p>
|
||||
<div class=\\"notice notice-info\\">
|
||||
<p>This is an info block</p>
|
||||
</div>
|
||||
<p><span class=\\"placeholder\\">This is a placeholder</span></p>
|
||||
<p><span class=\\"highlight\\">this is a highlight</span></p>
|
||||
<ul>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[<del data-operation-index=\\"5\\"> ]</del><ins data-operation-index=\\"5\\">x]</ins></span>checklist item 1</li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"7\\"><ins data-operation-index=\\"7\\">[x]</ins></span><ins data-operation-index=\\"7\\">checklist item 2</ins></li>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\">[ ]</span>checklist item <del data-operation-index=\\"9\\">2</del><ins data-operation-index=\\"9\\">3</ins></li><li class=\\"checkbox-list-item\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"9\\"><ins data-operation-index=\\"9\\">[ ]</ins></span><ins data-operation-index=\\"9\\">checklist item 4</ins></li>
|
||||
<li class=\\"checkbox-list-item\\"><span class=\\"checkbox checked\\">[x]</span>checklist item <del data-operation-index=\\"11\\">3</del><ins data-operation-index=\\"11\\">5</ins></li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`should return everything inserted when previously empty 1`] = `
|
||||
"<h1 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 1</ins></h1><h2 data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">Heading 2</ins></h2><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a test paragraph</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a second test paragraph. This is a second sentence.</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a another test paragraph. This is a another sentence.</ins></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 1</ins></li><li data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">list item 2</ins></li></ul><pre data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><code data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a codeblock
|
||||
</ins></code></pre><div class=\\"notice notice-info\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is an info block</ins></p></div><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"placeholder\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">This is a placeholder</ins></span></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"highlight\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">this is a highlight</ins></span></p><ul data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 1</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox \\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[ ]</ins></span><ins data-operation-index=\\"0\\">checklist item 2</ins></li><li class=\\"checkbox-list-item\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><span class=\\"checkbox checked\\" data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">[x]</ins></span><ins data-operation-index=\\"0\\">checklist item 3</ins></li></ul><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p><p data-diff-node=\\"ins\\" data-operation-index=\\"0\\"><ins data-operation-index=\\"0\\">same on both sides</ins></p>"
|
||||
`;
|
||||
@@ -0,0 +1,57 @@
|
||||
// @flow
|
||||
import { findIndex, findLastIndex } from "lodash";
|
||||
import diff from "node-htmldiff";
|
||||
import { renderToHtml } from "rich-markdown-editor";
|
||||
|
||||
export default function markdownDiff(
|
||||
before: string,
|
||||
after: string,
|
||||
fullDiff: boolean = false,
|
||||
buffer: number = 1
|
||||
) {
|
||||
// The basic idea here is to first render the Markdown to HTML, then diff the
|
||||
// HTML - both sides will have valid HTML so we should have a valid diff as well
|
||||
|
||||
const beforeHtml = renderToHtml(before);
|
||||
const afterHtml = renderToHtml(after);
|
||||
const diffHtml = diff(beforeHtml, afterHtml);
|
||||
|
||||
if (fullDiff) {
|
||||
return diffHtml;
|
||||
}
|
||||
|
||||
if (before === after) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Split diff at paragraphs and find the first and last changed tags
|
||||
// so we can chop around paragraphs rather than return the entire document.
|
||||
//
|
||||
// In an ideal world we'd use an AST here and parse that rather than be doing
|
||||
// operations on strings. I hope this can be revisted in the future with an
|
||||
// improved diffing library.
|
||||
const newParagraph = /(?:^|\n)<p>/;
|
||||
let lines = diffHtml.split(newParagraph);
|
||||
|
||||
const firstChangedLineIndex = findIndex(
|
||||
lines,
|
||||
(value) => value.includes("<ins ") || value.includes("<del ")
|
||||
);
|
||||
const lastChangedLineIndex = findLastIndex(
|
||||
lines,
|
||||
(value) => value.includes("</ins>") || value.includes("</del>")
|
||||
);
|
||||
|
||||
const start = Math.max(0, firstChangedLineIndex - buffer);
|
||||
const end = Math.min(lines.length, lastChangedLineIndex + buffer);
|
||||
lines = lines.slice(start, end);
|
||||
|
||||
if (!lines.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [start > 0 ? "" : undefined, ...lines]
|
||||
.filter((x) => x !== undefined)
|
||||
.join("\n<p>")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import markdownDiff from "./markdownDiff";
|
||||
|
||||
it("should diff a complex document", async () => {
|
||||
const before = await fs.promises.readFile(
|
||||
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const after = await fs.promises.readFile(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
"server",
|
||||
"test",
|
||||
"fixtures",
|
||||
"complexModified.md"
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const diff = markdownDiff(before, after);
|
||||
expect(diff).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return empty string when both sides are empty", () => {
|
||||
const diff = markdownDiff("", "");
|
||||
expect(diff).toEqual("");
|
||||
});
|
||||
|
||||
it("should return everything inserted when previously empty", async () => {
|
||||
const content = await fs.promises.readFile(
|
||||
path.resolve(process.cwd(), "server", "test", "fixtures", "complex.md"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const diff = markdownDiff("", content);
|
||||
expect(diff).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return empty for changed nodes", async () => {
|
||||
// Note: This isn't ideal behavior, but it is current behavior. If the diffing
|
||||
// library is improved then we could potentially render the old + new heading
|
||||
// with ins/del tags as appropriate.
|
||||
const diff = markdownDiff("# Heading", "## Heading");
|
||||
expect(diff).toEqual("");
|
||||
});
|
||||
|
||||
it("should return deleted nodes", async () => {
|
||||
const diff = markdownDiff("", "");
|
||||
expect(diff).toEqual(
|
||||
'<p><del data-operation-index="0"><img src="/image.png" alt="caption"></del></p>'
|
||||
);
|
||||
});
|
||||
+2
-2
@@ -163,13 +163,13 @@ export const deleteFromS3 = (key: string) => {
|
||||
.promise();
|
||||
};
|
||||
|
||||
export const getSignedImageUrl = async (key: string) => {
|
||||
export const getSignedImageUrl = async (key: string, expires: number = 60) => {
|
||||
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
|
||||
|
||||
const params = {
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
Expires: 60,
|
||||
Expires: expires,
|
||||
};
|
||||
|
||||
return isDocker
|
||||
|
||||
@@ -152,12 +152,12 @@
|
||||
"Path to document": "Path to document",
|
||||
"Group member options": "Group member options",
|
||||
"Remove": "Remove",
|
||||
"Collection": "Collection",
|
||||
"New document": "New document",
|
||||
"Import document": "Import document",
|
||||
"Edit": "Edit",
|
||||
"Permissions": "Permissions",
|
||||
"Delete": "Delete",
|
||||
"Collection": "Collection",
|
||||
"Collection permissions": "Collection permissions",
|
||||
"Edit collection": "Edit collection",
|
||||
"Delete collection": "Delete collection",
|
||||
|
||||
@@ -2354,6 +2354,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
|
||||
integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
|
||||
|
||||
"@yarnpkg/lockfile@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
|
||||
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
|
||||
|
||||
abab@^2.0.3:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"
|
||||
@@ -9432,6 +9437,11 @@ node-gyp-build@^3.8.0:
|
||||
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.9.0.tgz#53a350187dd4d5276750da21605d1cb681d09e25"
|
||||
integrity sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A==
|
||||
|
||||
node-htmldiff@^0.9.3:
|
||||
version "0.9.3"
|
||||
resolved "https://registry.yarnpkg.com/node-htmldiff/-/node-htmldiff-0.9.3.tgz#020704e381597e5e449a4708996edf23eebb7fcc"
|
||||
integrity sha1-AgcE44FZfl5EmkcImW7fI+67f8w=
|
||||
|
||||
node-int64@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
|
||||
@@ -9605,7 +9615,7 @@ oauth@0.9.x:
|
||||
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
|
||||
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
|
||||
|
||||
object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
|
||||
object-assign@^4.0.1, object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||
@@ -10747,18 +10757,10 @@ qs@~6.5.2:
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
|
||||
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
|
||||
|
||||
query-string@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
|
||||
integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s=
|
||||
dependencies:
|
||||
object-assign "^4.1.0"
|
||||
strict-uri-encode "^1.0.0"
|
||||
|
||||
query-string@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.0.0.tgz#aaad2c8d5c6a6d0c6afada877fecbd56af79e609"
|
||||
integrity sha512-Iy7moLybliR5ZgrK/1R3vjrXq03S13Vz4Rbm5Jg3EFq1LUmQppto0qtXz4vqZ386MSRjZgnTSZ9QC+NZOSd/XA==
|
||||
query-string@^7.0.0, query-string@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.0.1.tgz#45bd149cf586aaa582dffc7ec7a8ad97dd02f75d"
|
||||
integrity sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==
|
||||
dependencies:
|
||||
decode-uri-component "^0.2.0"
|
||||
filter-obj "^1.1.0"
|
||||
@@ -12449,11 +12451,6 @@ streamsearch@0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
|
||||
integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
|
||||
|
||||
strict-uri-encode@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
|
||||
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
|
||||
|
||||
strict-uri-encode@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
|
||||
@@ -14228,12 +14225,7 @@ write@1.0.3:
|
||||
dependencies:
|
||||
mkdirp "^0.5.1"
|
||||
|
||||
ws@^7.2.3:
|
||||
version "7.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.0.tgz#a5dd76a24197940d4a8bb9e0e152bb4503764da7"
|
||||
integrity sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==
|
||||
|
||||
ws@~7.4.2:
|
||||
ws@^7.2.3, ws@~7.4.2:
|
||||
version "7.4.6"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
|
||||
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
|
||||
@@ -14396,6 +14388,15 @@ yargs@^15.1.0, yargs@^15.4.1:
|
||||
y18n "^4.0.0"
|
||||
yargs-parser "^18.1.2"
|
||||
|
||||
yarn-deduplicate@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yarn-deduplicate/-/yarn-deduplicate-3.1.0.tgz#3018d93e95f855f236a215b591fe8bc4bcabba3e"
|
||||
integrity sha512-q2VZ6ThNzQpGfNpkPrkmV7x5HT9MOhCUsTxVTzyyZB0eSXz1NTodHn+r29DlLb+peKk8iXxzdUVhQG9pI7moFw==
|
||||
dependencies:
|
||||
"@yarnpkg/lockfile" "^1.1.0"
|
||||
commander "^6.1.0"
|
||||
semver "^7.3.2"
|
||||
|
||||
yeast@0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
|
||||
|
||||
Reference in New Issue
Block a user