feat: Add event selection to Slack post integration (#2857)
This commit is contained in:
@@ -2,13 +2,15 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
const ButtonLink = React.forwardRef(
|
||||
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
|
||||
return <Button {...props} ref={ref} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
@@ -20,3 +22,5 @@ const Button = styled.button`
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default ButtonLink;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -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 (
|
||||
<Scene title="Slack" icon={<SlackIcon color="currentColor" />}>
|
||||
<Heading>Slack</Heading>
|
||||
@@ -83,6 +95,7 @@ function Slack() {
|
||||
scopes={["commands", "links:read", "links:write"]}
|
||||
redirectUri={`${env.URL}/auth/slack.commands`}
|
||||
state={team.id}
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
@@ -98,33 +111,13 @@ function Slack() {
|
||||
</HelpText>
|
||||
|
||||
<List>
|
||||
{collections.orderedData.map((collection: Collection) => {
|
||||
const integration = find(integrations.slackIntegrations, {
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
{groupedCollections.map(([collection, integration]) => {
|
||||
if (integration) {
|
||||
return (
|
||||
<ListItem
|
||||
<SlackListItem
|
||||
key={integration.id}
|
||||
title={collection.name}
|
||||
image={<CollectionIcon collection={collection} />}
|
||||
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>
|
||||
}
|
||||
collection={collection}
|
||||
integration={integration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
neutral
|
||||
>
|
||||
<Button onClick={handleClick} icon={icon} neutral>
|
||||
{label || t("Add to Slack")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
115
app/scenes/Settings/components/SlackListItem.tsx
Normal file
115
app/scenes/Settings/components/SlackListItem.tsx
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
18
server/migrations/20211217054419-integration-events.js
Normal file
18
server/migrations/20211217054419-integration-events.js
Normal 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'
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
53
server/routes/api/integrations.test.ts
Normal file
53
server/routes/api/integrations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <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",
|
||||
"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 <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.",
|
||||
"Connected to the <em>{{ channelName }}</em> channel": "Connected to the <em>{{ channelName }}</em> 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",
|
||||
|
||||
Reference in New Issue
Block a user