feat: Add event selection to Slack post integration (#2857)

This commit is contained in:
Tom Moor
2021-12-16 22:30:23 -08:00
committed by GitHub
parent 9a7b5ea1f4
commit d4695f3b5b
13 changed files with 270 additions and 69 deletions

View File

@@ -2,13 +2,15 @@ import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
type Props = { type Props = {
onClick: React.MouseEventHandler<HTMLButtonElement>; onClick?: React.MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode; children: React.ReactNode;
}; };
export default function ButtonLink(props: Props) { const ButtonLink = React.forwardRef(
return <Button {...props} />; (props: Props, ref: React.Ref<HTMLButtonElement>) => {
} return <Button {...props} ref={ref} />;
}
);
const Button = styled.button` const Button = styled.button`
margin: 0; margin: 0;
@@ -20,3 +22,5 @@ const Button = styled.button`
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
`; `;
export default ButtonLink;

View File

@@ -21,11 +21,11 @@ export default class BaseModel {
try { try {
// ensure that the id is passed if the document has one // ensure that the id is passed if the document has one
if (params) { if (!params) {
params = { ...params, id: this.id }; 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 // if saving is successful set the new values on the model itself
set(this, { ...params, ...model }); set(this, { ...params, ...model });

View File

@@ -1,13 +1,12 @@
import { extendObservable, action } from "mobx"; import { observable } from "mobx";
import BaseModel from "~/models/BaseModel"; import BaseModel from "~/models/BaseModel";
import { client } from "~/utils/ApiClient"; import Field from "./decorators/Field";
type Settings = { type Settings = {
url: string; url: string;
channel: string; channel: string;
channelId: string; channelId: string;
}; };
type Events = "documents.create" | "collections.create";
class Integration extends BaseModel { class Integration extends BaseModel {
id: string; id: string;
@@ -18,24 +17,11 @@ class Integration extends BaseModel {
collectionId: string; collectionId: string;
events: Events; @Field
@observable
events: string[];
settings: Settings; settings: Settings;
@action
update = async (data: Record<string, any>) => {
await client.post("/integrations.update", {
id: this.id,
...data,
});
extendObservable(this, data);
return true;
};
@action
delete = () => {
return this.store.delete(this);
};
} }
export default Integration; export default Integration;

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button"; import Button from "~/components/Button";
import CollectionIcon from "~/components/CollectionIcon"; import CollectionIcon from "~/components/CollectionIcon";
import Heading from "~/components/Heading"; import Heading from "~/components/Heading";
@@ -18,6 +19,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery"; import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import SlackButton from "./components/SlackButton"; import SlackButton from "./components/SlackButton";
import SlackListItem from "./components/SlackListItem";
function Slack() { function Slack() {
const team = useCurrentTeam(); const team = useCurrentTeam();
@@ -40,6 +42,16 @@ function Slack() {
(i) => i.type === "command" (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 ( return (
<Scene title="Slack" icon={<SlackIcon color="currentColor" />}> <Scene title="Slack" icon={<SlackIcon color="currentColor" />}>
<Heading>Slack</Heading> <Heading>Slack</Heading>
@@ -83,6 +95,7 @@ function Slack() {
scopes={["commands", "links:read", "links:write"]} scopes={["commands", "links:read", "links:write"]}
redirectUri={`${env.URL}/auth/slack.commands`} redirectUri={`${env.URL}/auth/slack.commands`}
state={team.id} state={team.id}
icon={<SlackIcon color="currentColor" />}
/> />
)} )}
</p> </p>
@@ -98,33 +111,13 @@ function Slack() {
</HelpText> </HelpText>
<List> <List>
{collections.orderedData.map((collection: Collection) => { {groupedCollections.map(([collection, integration]) => {
const integration = find(integrations.slackIntegrations, {
collectionId: collection.id,
});
if (integration) { if (integration) {
return ( return (
<ListItem <SlackListItem
key={integration.id} key={integration.id}
title={collection.name} collection={collection}
image={<CollectionIcon collection={collection} />} integration={integration}
subtitle={
<Trans
defaults={`Connected to the <em>{{ channelName }}</em> channel`}
values={{
channelName: integration.settings.channel,
}}
components={{
em: <strong />,
}}
/>
}
actions={
<Button onClick={integration.delete} neutral>
{t("Disconnect")}
</Button>
}
/> />
); );
} }

View File

@@ -2,17 +2,17 @@ import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { slackAuth } from "@shared/utils/routeHelpers"; import { slackAuth } from "@shared/utils/routeHelpers";
import Button from "~/components/Button"; import Button from "~/components/Button";
import SlackIcon from "~/components/SlackIcon";
import env from "~/env"; import env from "~/env";
type Props = { type Props = {
scopes?: string[]; scopes?: string[];
redirectUri: string; redirectUri: string;
icon?: React.ReactNode;
state?: string; state?: string;
label?: string; label?: string;
}; };
function SlackButton({ state = "", scopes, redirectUri, label }: Props) { function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const handleClick = () => const handleClick = () =>
@@ -24,11 +24,7 @@ function SlackButton({ state = "", scopes, redirectUri, label }: Props) {
)); ));
return ( return (
<Button <Button onClick={handleClick} icon={icon} neutral>
onClick={handleClick}
icon={<SlackIcon color="currentColor" />}
neutral
>
{label || t("Add to Slack")} {label || t("Add to Slack")}
</Button> </Button>
); );

View File

@@ -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<HTMLInputElement>) => {
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 (
<ListItem
key={integration.id}
title={
<Flex align="center" gap={6}>
<CollectionIcon collection={collection} /> {collection.name}
</Flex>
}
subtitle={
<>
<Trans
defaults={`Posting to the <em>{{ channelName }}</em> channel on`}
values={{
channelName: integration.settings.channel,
events: integration.events.map((ev) => mapping[ev]).join(", "),
}}
components={{
em: <strong />,
}}
/>{" "}
<PopoverDisclosure {...popover}>
{(props) => (
<ButtonLink {...props}>
{integration.events.map((ev) => mapping[ev]).join(", ")}
</ButtonLink>
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Settings")}>
<Events>
<h3>{t("Notifications")}</h3>
<HelpText>{t("These events should be posted to Slack")}</HelpText>
<Checkbox
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange}
/>
<Checkbox
label={t("Document updated")}
name="documents.update"
checked={integration.events.includes("documents.update")}
onChange={handleChange}
/>
</Events>
</Popover>
</>
}
actions={
<Button onClick={integration.delete} neutral>
{t("Disconnect")}
</Button>
}
/>
);
}
const Events = styled.div`
color: ${(props) => props.theme.text};
margin-top: -12px;
`;
export default observer(SlackListItem);

View File

@@ -17,8 +17,7 @@ export default async function documentUpdater({
const document = await Document.findByPk(documentId); const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc); const state = Y.encodeStateAsUpdate(ydoc);
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default")); 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, undefined);
const text = serializer.serialize(node);
const isUnchanged = document.text === text; const isUnchanged = document.text === text;
const hasMultiplayerState = !!document.state; const hasMultiplayerState = !!document.state;

View File

@@ -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'
`);
},
};

View File

@@ -1,4 +1,5 @@
import fetch from "fetch-with-proxy"; import fetch from "fetch-with-proxy";
import { Op } from "sequelize";
import { Document, Integration, Collection, Team } from "@server/models"; import { Document, Integration, Collection, Team } from "@server/models";
import { presentSlackAttachment } from "@server/presenters"; import { presentSlackAttachment } from "@server/presenters";
import { import {
@@ -38,8 +39,10 @@ export default class SlackProcessor {
], ],
}); });
if (!integration) return; if (!integration) return;
const collection = integration.collection; const collection = integration.collection;
if (!collection) return; if (!collection) return;
await fetch(integration.settings.url, { await fetch(integration.settings.url, {
method: "POST", method: "POST",
headers: { headers: {
@@ -68,14 +71,19 @@ export default class SlackProcessor {
Team.findByPk(event.teamId), Team.findByPk(event.teamId),
]); ]);
if (!document) return; if (!document) return;
// never send notifications for draft documents // never send notifications for draft documents
if (!document.publishedAt) return; if (!document.publishedAt) return;
const integration = await Integration.findOne({ const integration = await Integration.findOne({
where: { where: {
teamId: document.teamId, teamId: document.teamId,
collectionId: document.collectionId, collectionId: document.collectionId,
service: "slack", service: "slack",
type: "post", type: "post",
events: {
[Op.contains]: [event.name],
},
}, },
}); });
if (!integration) return; if (!integration) return;

View File

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

View File

@@ -4,7 +4,7 @@ import { Event } from "@server/models";
import Integration from "@server/models/Integration"; import Integration from "@server/models/Integration";
import policy from "@server/policies"; import policy from "@server/policies";
import { presentIntegration } from "@server/presenters"; import { presentIntegration } from "@server/presenters";
import { assertSort, assertUuid } from "@server/validation"; import { assertSort, assertUuid, assertArray } from "@server/validation";
import pagination from "./middlewares/pagination"; import pagination from "./middlewares/pagination";
const { authorize } = policy; 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) => { router.post("integrations.delete", auth(), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertUuid(id, "id is required"); assertUuid(id, "id is required");
const user = ctx.state.user; const { user } = ctx.state;
const integration = await Integration.findByPk(id); const integration = await Integration.findByPk(id);
authorize(user, "delete", integration); authorize(user, "delete", integration);
await integration.destroy(); await integration.destroy();
await Event.create({ await Event.create({
name: "integrations.delete", name: "integrations.delete",
@@ -46,6 +70,7 @@ router.post("integrations.delete", auth(), async (ctx) => {
actorId: user.id, actorId: user.id,
ip: ctx.request.ip, ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
success: true, success: true,
}; };

View File

@@ -153,6 +153,7 @@ if (SLACK_CLIENT_ID) {
const { code, error, state } = ctx.request.query; const { code, error, state } = ctx.request.query;
const user = ctx.state.user; const user = ctx.state.user;
assertPresent(code || error, "code is required"); assertPresent(code || error, "code is required");
const collectionId = state; const collectionId = state;
assertUuid(collectionId, "collectionId must be an uuid"); assertUuid(collectionId, "collectionId must be an uuid");
@@ -179,8 +180,7 @@ if (SLACK_CLIENT_ID) {
} }
const endpoint = `${process.env.URL || ""}/auth/slack.post`; 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 as string, endpoint);
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({ const authentication = await IntegrationAuthentication.create({
service: "slack", service: "slack",
userId: user.id, userId: user.id,
@@ -188,6 +188,7 @@ if (SLACK_CLIENT_ID) {
token: data.access_token, token: data.access_token,
scopes: data.scope.split(","), scopes: data.scope.split(","),
}); });
await Integration.create({ await Integration.create({
service: "slack", service: "slack",
type: "post", type: "post",
@@ -195,7 +196,7 @@ if (SLACK_CLIENT_ID) {
teamId: user.teamId, teamId: user.teamId,
authenticationId: authentication.id, authenticationId: authentication.id,
collectionId, collectionId,
events: [], events: ["documents.update", "documents.publish"],
settings: { settings: {
url: data.incoming_webhook.url, url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel, channel: data.incoming_webhook.channel,

View File

@@ -520,10 +520,17 @@
"by {{ name }}": "by {{ name }}", "by {{ name }}": "by {{ name }}",
"Last accessed": "Last accessed", "Last accessed": "Last accessed",
"Add to Slack": "Add to Slack", "Add to Slack": "Add to Slack",
"Settings saved": "Settings saved",
"document published": "document published",
"document updated": "document updated",
"Posting to the <em>{{ channelName }}</em> channel on": "Posting to the <em>{{ channelName }}</em> 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", "Active": "Active",
"Everyone": "Everyone", "Everyone": "Everyone",
"Admins": "Admins", "Admins": "Admins",
"Settings saved": "Settings saved",
"Unable to upload new logo": "Unable to upload new logo", "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.", "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", "Logo": "Logo",
@@ -553,9 +560,7 @@
"Requesting Export": "Requesting Export", "Requesting Export": "Requesting Export",
"Export Data": "Export Data", "Export Data": "Export Data",
"Recent exports": "Recent exports", "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", "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", "Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited",
"Collection created": "Collection created", "Collection created": "Collection created",
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is 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?", "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?", "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 <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.", "Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> 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.", "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 <em>{{ channelName }}</em> channel": "Connected to the <em>{{ channelName }}</em> channel",
"Connect": "Connect", "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.", "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", "New token": "New token",