From d4695f3b5b42e80adb1e4c88422339de89586836 Mon Sep 17 00:00:00 2001
From: Tom Moor
Date: Thu, 16 Dec 2021 22:30:23 -0800
Subject: [PATCH] feat: Add event selection to Slack post integration (#2857)
---
app/components/ButtonLink.tsx | 12 +-
app/models/BaseModel.ts | 6 +-
app/models/Integration.ts | 24 +---
app/scenes/Settings/Slack.tsx | 41 +++----
.../Settings/components/SlackButton.tsx | 10 +-
.../Settings/components/SlackListItem.tsx | 115 ++++++++++++++++++
server/commands/documentUpdater.ts | 3 +-
.../20211217054419-integration-events.js | 18 +++
server/queues/processors/slack.ts | 8 ++
server/routes/api/integrations.test.ts | 53 ++++++++
server/routes/api/integrations.ts | 29 ++++-
server/routes/auth/providers/slack.ts | 7 +-
shared/i18n/locales/en_US/translation.json | 13 +-
13 files changed, 270 insertions(+), 69 deletions(-)
create mode 100644 app/scenes/Settings/components/SlackListItem.tsx
create mode 100644 server/migrations/20211217054419-integration-events.js
create mode 100644 server/routes/api/integrations.test.ts
diff --git a/app/components/ButtonLink.tsx b/app/components/ButtonLink.tsx
index 89302b628..4c14c1664 100644
--- a/app/components/ButtonLink.tsx
+++ b/app/components/ButtonLink.tsx
@@ -2,13 +2,15 @@ import * as React from "react";
import styled from "styled-components";
type Props = {
- onClick: React.MouseEventHandler;
+ onClick?: React.MouseEventHandler;
children: React.ReactNode;
};
-export default function ButtonLink(props: Props) {
- return ;
-}
+const ButtonLink = React.forwardRef(
+ (props: Props, ref: React.Ref) => {
+ return ;
+ }
+);
const Button = styled.button`
margin: 0;
@@ -20,3 +22,5 @@ const Button = styled.button`
text-decoration: none;
cursor: pointer;
`;
+
+export default ButtonLink;
diff --git a/app/models/BaseModel.ts b/app/models/BaseModel.ts
index 6f0e0a97a..73fc11107 100644
--- a/app/models/BaseModel.ts
+++ b/app/models/BaseModel.ts
@@ -21,11 +21,11 @@ export default class BaseModel {
try {
// ensure that the id is passed if the document has one
- if (params) {
- params = { ...params, id: this.id };
+ if (!params) {
+ params = this.toJS();
}
- const model = await this.store.save(params || this.toJS());
+ const model = await this.store.save({ ...params, id: this.id });
// if saving is successful set the new values on the model itself
set(this, { ...params, ...model });
diff --git a/app/models/Integration.ts b/app/models/Integration.ts
index 6539b1fff..940fb8028 100644
--- a/app/models/Integration.ts
+++ b/app/models/Integration.ts
@@ -1,13 +1,12 @@
-import { extendObservable, action } from "mobx";
+import { observable } from "mobx";
import BaseModel from "~/models/BaseModel";
-import { client } from "~/utils/ApiClient";
+import Field from "./decorators/Field";
type Settings = {
url: string;
channel: string;
channelId: string;
};
-type Events = "documents.create" | "collections.create";
class Integration extends BaseModel {
id: string;
@@ -18,24 +17,11 @@ class Integration extends BaseModel {
collectionId: string;
- events: Events;
+ @Field
+ @observable
+ events: string[];
settings: Settings;
-
- @action
- update = async (data: Record) => {
- await client.post("/integrations.update", {
- id: this.id,
- ...data,
- });
- extendObservable(this, data);
- return true;
- };
-
- @action
- delete = () => {
- return this.store.delete(this);
- };
}
export default Integration;
diff --git a/app/scenes/Settings/Slack.tsx b/app/scenes/Settings/Slack.tsx
index 1f6f6204d..2b70438aa 100644
--- a/app/scenes/Settings/Slack.tsx
+++ b/app/scenes/Settings/Slack.tsx
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
+import Integration from "~/models/Integration";
import Button from "~/components/Button";
import CollectionIcon from "~/components/CollectionIcon";
import Heading from "~/components/Heading";
@@ -18,6 +19,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import SlackButton from "./components/SlackButton";
+import SlackListItem from "./components/SlackListItem";
function Slack() {
const team = useCurrentTeam();
@@ -40,6 +42,16 @@ function Slack() {
(i) => i.type === "command"
);
+ const groupedCollections = collections.orderedData
+ .map<[Collection, Integration | undefined]>((collection) => {
+ const integration = find(integrations.slackIntegrations, {
+ collectionId: collection.id,
+ });
+
+ return [collection, integration];
+ })
+ .sort((a) => (a[1] ? -1 : 1));
+
return (
}>
Slack
@@ -83,6 +95,7 @@ function Slack() {
scopes={["commands", "links:read", "links:write"]}
redirectUri={`${env.URL}/auth/slack.commands`}
state={team.id}
+ icon={}
/>
)}
@@ -98,33 +111,13 @@ function Slack() {
- {collections.orderedData.map((collection: Collection) => {
- const integration = find(integrations.slackIntegrations, {
- collectionId: collection.id,
- });
-
+ {groupedCollections.map(([collection, integration]) => {
if (integration) {
return (
- }
- subtitle={
- {{ channelName }} channel`}
- values={{
- channelName: integration.settings.channel,
- }}
- components={{
- em: ,
- }}
- />
- }
- actions={
-
- }
+ collection={collection}
+ integration={integration}
/>
);
}
diff --git a/app/scenes/Settings/components/SlackButton.tsx b/app/scenes/Settings/components/SlackButton.tsx
index 6c029ba33..f93668f82 100644
--- a/app/scenes/Settings/components/SlackButton.tsx
+++ b/app/scenes/Settings/components/SlackButton.tsx
@@ -2,17 +2,17 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { slackAuth } from "@shared/utils/routeHelpers";
import Button from "~/components/Button";
-import SlackIcon from "~/components/SlackIcon";
import env from "~/env";
type Props = {
scopes?: string[];
redirectUri: string;
+ icon?: React.ReactNode;
state?: string;
label?: string;
};
-function SlackButton({ state = "", scopes, redirectUri, label }: Props) {
+function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
const { t } = useTranslation();
const handleClick = () =>
@@ -24,11 +24,7 @@ function SlackButton({ state = "", scopes, redirectUri, label }: Props) {
));
return (
- }
- neutral
- >
+
);
diff --git a/app/scenes/Settings/components/SlackListItem.tsx b/app/scenes/Settings/components/SlackListItem.tsx
new file mode 100644
index 000000000..6ab242dbb
--- /dev/null
+++ b/app/scenes/Settings/components/SlackListItem.tsx
@@ -0,0 +1,115 @@
+import { uniq } from "lodash";
+import { observer } from "mobx-react";
+import * as React from "react";
+import { Trans, useTranslation } from "react-i18next";
+import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
+import styled from "styled-components";
+import Collection from "~/models/Collection";
+import Integration from "~/models/Integration";
+import Button from "~/components/Button";
+import ButtonLink from "~/components/ButtonLink";
+import Checkbox from "~/components/Checkbox";
+import CollectionIcon from "~/components/CollectionIcon";
+import Flex from "~/components/Flex";
+import HelpText from "~/components/HelpText";
+import ListItem from "~/components/List/Item";
+import Popover from "~/components/Popover";
+import useToasts from "~/hooks/useToasts";
+
+type Props = {
+ integration: Integration;
+ collection: Collection;
+};
+
+function SlackListItem({ integration, collection }: Props) {
+ const { t } = useTranslation();
+ const { showToast } = useToasts();
+
+ const handleChange = async (ev: React.ChangeEvent) => {
+ if (ev.target.checked) {
+ integration.events = uniq([...integration.events, ev.target.name]);
+ } else {
+ integration.events = integration.events.filter(
+ (n) => n !== ev.target.name
+ );
+ }
+
+ await integration.save();
+
+ showToast(t("Settings saved"), {
+ type: "success",
+ });
+ };
+
+ const mapping = {
+ "documents.publish": t("document published"),
+ "documents.update": t("document updated"),
+ };
+
+ const popover = usePopoverState({
+ gutter: 0,
+ placement: "bottom-start",
+ });
+
+ return (
+
+ {collection.name}
+
+ }
+ subtitle={
+ <>
+ {{ channelName }} channel on`}
+ values={{
+ channelName: integration.settings.channel,
+ events: integration.events.map((ev) => mapping[ev]).join(", "),
+ }}
+ components={{
+ em: ,
+ }}
+ />{" "}
+
+ {(props) => (
+
+ {integration.events.map((ev) => mapping[ev]).join(", ")}
+
+ )}
+
+
+
+ {t("Notifications")}
+ {t("These events should be posted to Slack")}
+
+
+
+
+ >
+ }
+ actions={
+
+ }
+ />
+ );
+}
+
+const Events = styled.div`
+ color: ${(props) => props.theme.text};
+ margin-top: -12px;
+`;
+
+export default observer(SlackListItem);
diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts
index 3a9978243..e1c05e18c 100644
--- a/server/commands/documentUpdater.ts
+++ b/server/commands/documentUpdater.ts
@@ -17,8 +17,7 @@ export default async function documentUpdater({
const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
- // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
- const text = serializer.serialize(node);
+ const text = serializer.serialize(node, undefined);
const isUnchanged = document.text === text;
const hasMultiplayerState = !!document.state;
diff --git a/server/migrations/20211217054419-integration-events.js b/server/migrations/20211217054419-integration-events.js
new file mode 100644
index 000000000..f96072d85
--- /dev/null
+++ b/server/migrations/20211217054419-integration-events.js
@@ -0,0 +1,18 @@
+"use strict";
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.sequelize.query(`
+ update integrations
+ set "events" = '{documents.update,documents.publish}'
+ where type = 'post'
+ `);
+ },
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.sequelize.query(`
+ update integrations
+ set "events" = NULL
+ where type = 'post'
+ `);
+ },
+};
diff --git a/server/queues/processors/slack.ts b/server/queues/processors/slack.ts
index 46881db91..e21fc7ad0 100644
--- a/server/queues/processors/slack.ts
+++ b/server/queues/processors/slack.ts
@@ -1,4 +1,5 @@
import fetch from "fetch-with-proxy";
+import { Op } from "sequelize";
import { Document, Integration, Collection, Team } from "@server/models";
import { presentSlackAttachment } from "@server/presenters";
import {
@@ -38,8 +39,10 @@ export default class SlackProcessor {
],
});
if (!integration) return;
+
const collection = integration.collection;
if (!collection) return;
+
await fetch(integration.settings.url, {
method: "POST",
headers: {
@@ -68,14 +71,19 @@ export default class SlackProcessor {
Team.findByPk(event.teamId),
]);
if (!document) return;
+
// never send notifications for draft documents
if (!document.publishedAt) return;
+
const integration = await Integration.findOne({
where: {
teamId: document.teamId,
collectionId: document.collectionId,
service: "slack",
type: "post",
+ events: {
+ [Op.contains]: [event.name],
+ },
},
});
if (!integration) return;
diff --git a/server/routes/api/integrations.test.ts b/server/routes/api/integrations.test.ts
new file mode 100644
index 000000000..a5b3b33d0
--- /dev/null
+++ b/server/routes/api/integrations.test.ts
@@ -0,0 +1,53 @@
+// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
+import TestServer from "fetch-test-server";
+import webService from "@server/services/web";
+import {
+ buildAdmin,
+ buildTeam,
+ buildUser,
+ buildIntegration,
+} from "@server/test/factories";
+import { flushdb } from "@server/test/support";
+
+const app = webService();
+const server = new TestServer(app.callback());
+
+beforeEach(() => flushdb());
+afterAll(() => server.close());
+
+describe("#integrations.update", () => {
+ it("should allow updating integration events", async () => {
+ const team = await buildTeam();
+ const user = await buildAdmin({ teamId: team.id });
+ const integration = await buildIntegration({
+ userId: user.id,
+ teamId: team.id,
+ });
+
+ const res = await server.post("/api/integrations.update", {
+ body: {
+ events: ["documents.update"],
+ token: user.getJwtToken(),
+ id: integration.id,
+ },
+ });
+ const body = await res.json();
+ expect(res.status).toEqual(200);
+ expect(body.data.id).toEqual(integration.id);
+ expect(body.data.events.length).toEqual(1);
+ });
+
+ it("should require authorization", async () => {
+ const user = await buildUser();
+ const integration = await buildIntegration({
+ userId: user.id,
+ });
+ const res = await server.post("/api/integrations.update", {
+ body: {
+ token: user.getJwtToken(),
+ id: integration.id,
+ },
+ });
+ expect(res.status).toEqual(403);
+ });
+});
diff --git a/server/routes/api/integrations.ts b/server/routes/api/integrations.ts
index 6a43fa875..44216341d 100644
--- a/server/routes/api/integrations.ts
+++ b/server/routes/api/integrations.ts
@@ -4,7 +4,7 @@ import { Event } from "@server/models";
import Integration from "@server/models/Integration";
import policy from "@server/policies";
import { presentIntegration } from "@server/presenters";
-import { assertSort, assertUuid } from "@server/validation";
+import { assertSort, assertUuid, assertArray } from "@server/validation";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
@@ -31,13 +31,37 @@ router.post("integrations.list", auth(), pagination(), async (ctx) => {
};
});
+router.post("integrations.update", auth(), async (ctx) => {
+ const { id, events } = ctx.body;
+ assertUuid(id, "id is required");
+
+ const { user } = ctx.state;
+ const integration = await Integration.findByPk(id);
+ authorize(user, "update", integration);
+
+ assertArray(events, "events must be an array");
+
+ if (integration.type === "post") {
+ integration.events = events.filter((event: string) =>
+ ["documents.update", "documents.publish"].includes(event)
+ );
+ }
+
+ await integration.save();
+
+ ctx.body = {
+ data: presentIntegration(integration),
+ };
+});
+
router.post("integrations.delete", auth(), async (ctx) => {
const { id } = ctx.body;
assertUuid(id, "id is required");
- const user = ctx.state.user;
+ const { user } = ctx.state;
const integration = await Integration.findByPk(id);
authorize(user, "delete", integration);
+
await integration.destroy();
await Event.create({
name: "integrations.delete",
@@ -46,6 +70,7 @@ router.post("integrations.delete", auth(), async (ctx) => {
actorId: user.id,
ip: ctx.request.ip,
});
+
ctx.body = {
success: true,
};
diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts
index 5bf3848fe..5c9f3277f 100644
--- a/server/routes/auth/providers/slack.ts
+++ b/server/routes/auth/providers/slack.ts
@@ -153,6 +153,7 @@ if (SLACK_CLIENT_ID) {
const { code, error, state } = ctx.request.query;
const user = ctx.state.user;
assertPresent(code || error, "code is required");
+
const collectionId = state;
assertUuid(collectionId, "collectionId must be an uuid");
@@ -179,8 +180,7 @@ if (SLACK_CLIENT_ID) {
}
const endpoint = `${process.env.URL || ""}/auth/slack.post`;
- // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
- const data = await Slack.oauthAccess(code, endpoint);
+ const data = await Slack.oauthAccess(code as string, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
@@ -188,6 +188,7 @@ if (SLACK_CLIENT_ID) {
token: data.access_token,
scopes: data.scope.split(","),
});
+
await Integration.create({
service: "slack",
type: "post",
@@ -195,7 +196,7 @@ if (SLACK_CLIENT_ID) {
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
- events: [],
+ events: ["documents.update", "documents.publish"],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 13589deff..1e198970a 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -520,10 +520,17 @@
"by {{ name }}": "by {{ name }}",
"Last accessed": "Last accessed",
"Add to Slack": "Add to Slack",
+ "Settings saved": "Settings saved",
+ "document published": "document published",
+ "document updated": "document updated",
+ "Posting to the {{ channelName }} channel on": "Posting to the {{ channelName }} channel on",
+ "These events should be posted to Slack": "These events should be posted to Slack",
+ "Document published": "Document published",
+ "Document updated": "Document updated",
+ "Disconnect": "Disconnect",
"Active": "Active",
"Everyone": "Everyone",
"Admins": "Admins",
- "Settings saved": "Settings saved",
"Unable to upload new logo": "Unable to upload new logo",
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
"Logo": "Logo",
@@ -553,9 +560,7 @@
"Requesting Export": "Requesting Export",
"Export Data": "Export Data",
"Recent exports": "Recent exports",
- "Document published": "Document published",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
- "Document updated": "Document updated",
"Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
"Collection created": "Collection created",
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
@@ -597,9 +602,7 @@
"Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?",
"Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?",
"Get rich previews of Outline links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of Outline links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.",
- "Disconnect": "Disconnect",
"Connect Outline collections to Slack channels and messages will be automatically posted to Slack when documents are published or updated.": "Connect Outline collections to Slack channels and messages will be automatically posted to Slack when documents are published or updated.",
- "Connected to the {{ channelName }} channel": "Connected to the {{ channelName }} channel",
"Connect": "Connect",
"The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
"New token": "New token",