Plugin architecture (#4861)

* wip

* Refactor, tasks, processors, routes loading

* Move Slack settings config to plugin

* Fix translations in plugins

* Move Slack auth to plugin

* test

* Move other slack-related files into plugin

* Forgot to save

* refactor
This commit is contained in:
Tom Moor
2023-02-12 13:11:30 -05:00
committed by GitHub
parent 492beedf00
commit 33afa2f029
30 changed files with 273 additions and 117 deletions

View File

@@ -1,25 +0,0 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
};
export default function SlackIcon({
size = 24,
color = "currentColor",
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path d="M7.36156352,14.1107492 C7.36156352,15.0358306 6.60586319,15.7915309 5.68078176,15.7915309 C4.75570033,15.7915309 4,15.0358306 4,14.1107492 C4,13.1856678 4.75570033,12.4299674 5.68078176,12.4299674 L7.36156352,12.4299674 L7.36156352,14.1107492 Z M8.20846906,14.1107492 C8.20846906,13.1856678 8.96416938,12.4299674 9.88925081,12.4299674 C10.8143322,12.4299674 11.5700326,13.1856678 11.5700326,14.1107492 L11.5700326,18.3192182 C11.5700326,19.2442997 10.8143322,20 9.88925081,20 C8.96416938,20 8.20846906,19.2442997 8.20846906,18.3192182 C8.20846906,18.3192182 8.20846906,14.1107492 8.20846906,14.1107492 Z M9.88925081,7.36156352 C8.96416938,7.36156352 8.20846906,6.60586319 8.20846906,5.68078176 C8.20846906,4.75570033 8.96416938,4 9.88925081,4 C10.8143322,4 11.5700326,4.75570033 11.5700326,5.68078176 L11.5700326,7.36156352 L9.88925081,7.36156352 Z M9.88925081,8.20846906 C10.8143322,8.20846906 11.5700326,8.96416938 11.5700326,9.88925081 C11.5700326,10.8143322 10.8143322,11.5700326 9.88925081,11.5700326 L5.68078176,11.5700326 C4.75570033,11.5700326 4,10.8143322 4,9.88925081 C4,8.96416938 4.75570033,8.20846906 5.68078176,8.20846906 C5.68078176,8.20846906 9.88925081,8.20846906 9.88925081,8.20846906 Z M16.6384365,9.88925081 C16.6384365,8.96416938 17.3941368,8.20846906 18.3192182,8.20846906 C19.2442997,8.20846906 20,8.96416938 20,9.88925081 C20,10.8143322 19.2442997,11.5700326 18.3192182,11.5700326 L16.6384365,11.5700326 L16.6384365,9.88925081 Z M15.7915309,9.88925081 C15.7915309,10.8143322 15.0358306,11.5700326 14.1107492,11.5700326 C13.1856678,11.5700326 12.4299674,10.8143322 12.4299674,9.88925081 L12.4299674,5.68078176 C12.4299674,4.75570033 13.1856678,4 14.1107492,4 C15.0358306,4 15.7915309,4.75570033 15.7915309,5.68078176 L15.7915309,9.88925081 Z M14.1107492,16.6384365 C15.0358306,16.6384365 15.7915309,17.3941368 15.7915309,18.3192182 C15.7915309,19.2442997 15.0358306,20 14.1107492,20 C13.1856678,20 12.4299674,19.2442997 12.4299674,18.3192182 L12.4299674,16.6384365 L14.1107492,16.6384365 Z M14.1107492,15.7915309 C13.1856678,15.7915309 12.4299674,15.0358306 12.4299674,14.1107492 C12.4299674,13.1856678 13.1856678,12.4299674 14.1107492,12.4299674 L18.3192182,12.4299674 C19.2442997,12.4299674 20,13.1856678 20,14.1107492 C20,15.0358306 19.2442997,15.7915309 18.3192182,15.7915309 L14.1107492,15.7915309 Z" />
</svg>
);
}

View File

@@ -1,3 +1,4 @@
import { mapValues } from "lodash";
import {
EmailIcon,
ProfileIcon,
@@ -16,6 +17,7 @@ import {
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import Details from "~/scenes/Settings/Details";
import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features";
@@ -29,36 +31,18 @@ import Profile from "~/scenes/Settings/Profile";
import Security from "~/scenes/Settings/Security";
import SelfHosted from "~/scenes/Settings/SelfHosted";
import Shares from "~/scenes/Settings/Shares";
import Slack from "~/scenes/Settings/Slack";
import Tokens from "~/scenes/Settings/Tokens";
import Webhooks from "~/scenes/Settings/Webhooks";
import Zapier from "~/scenes/Settings/Zapier";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import SlackIcon from "~/components/Icons/SlackIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import env from "~/env";
import isCloudHosted from "~/utils/isCloudHosted";
import { loadPlugins } from "~/utils/plugins";
import { accountPreferencesPath } from "~/utils/routeHelpers";
import useCurrentTeam from "./useCurrentTeam";
import usePolicy from "./usePolicy";
type SettingsGroups = "Account" | "Team" | "Integrations";
type SettingsPage =
| "Profile"
| "Notifications"
| "Api"
| "Details"
| "Security"
| "Features"
| "Members"
| "Groups"
| "Shares"
| "Import"
| "Export"
| "Webhooks"
| "Slack"
| "Zapier"
| "GoogleAnalytics";
export type ConfigItem = {
name: string;
@@ -70,7 +54,7 @@ export type ConfigItem = {
};
type ConfigType = {
[key in SettingsPage]: ConfigItem;
[key in string]: ConfigItem;
};
const useSettingsConfig = () => {
@@ -178,6 +162,16 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
...mapValues(loadPlugins(), (plugin) => {
return {
name: plugin.config.name,
path: integrationSettingsPath(plugin.id),
group: t("Integrations"),
component: plugin.settings,
enabled: !!plugin.settings && can.update,
icon: plugin.icon,
} as ConfigItem;
}),
Webhooks: {
name: t("Webhooks"),
path: "/settings/webhooks",
@@ -188,23 +182,15 @@ const useSettingsConfig = () => {
},
SelfHosted: {
name: t("Self Hosted"),
path: "/settings/integrations/self-hosted",
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
component: Slack,
enabled: can.update && (!!env.SLACK_CLIENT_ID || isCloudHosted),
group: t("Integrations"),
icon: SlackIcon,
},
GoogleAnalytics: {
name: t("Google Analytics"),
path: "/settings/integrations/google-analytics",
path: integrationSettingsPath("google-analytics"),
component: GoogleAnalytics,
enabled: can.update,
group: t("Integrations"),
@@ -212,7 +198,7 @@ const useSettingsConfig = () => {
},
Zapier: {
name: "Zapier",
path: "/settings/integrations/zapier",
path: integrationSettingsPath("zapier"),
component: Zapier,
enabled: can.update && isCloudHosted,
group: t("Integrations"),
@@ -232,7 +218,7 @@ const useSettingsConfig = () => {
const enabledConfigs = React.useMemo(
() =>
Object.keys(config).reduce(
(acc, key: SettingsPage) =>
(acc, key: string) =>
config[key].enabled ? [...acc, config[key]] : acc,
[]
),

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import { Switch } from "react-router-dom";
import Error404 from "~/scenes/Error404";
import Route from "~/components/ProfiledRoute";
import useSettingsConfig from "~/hooks/useSettingsConfig";
@@ -17,10 +17,6 @@ export default function SettingsRoutes() {
component={config.component}
/>
))}
{/* old routes */}
<Redirect from="/settings/import-export" to="/settings/export" />
<Redirect from="/settings/people" to="/settings/members" />
<Redirect from="/settings/profile" to="/settings" />
<Route component={Error404} />
</Switch>
);

View File

@@ -1,176 +0,0 @@
import { find } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import SlackIcon from "~/components/Icons/SlackIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
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();
const { collections, integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
React.useEffect(() => {
collections.fetchPage({
limit: 100,
});
integrations.fetchPage({
limit: 100,
});
}, [collections, integrations]);
const commandIntegration = find(
integrations.slackIntegrations,
(i) => i.type === IntegrationType.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));
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon color="currentColor" />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Slack to connect
{{ appName }} to your team. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again?
</Trans>
</Notice>
)}
<Text type="secondary">
<Trans
defaults="Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat."
values={{
command: "/outline",
appName,
}}
components={{
em: <Code />,
}}
/>
</Text>
{env.SLACK_CLIENT_ID ? (
<>
<p>
{commandIntegration ? (
<Button onClick={() => commandIntegration.delete()}>
{t("Disconnect")}
</Button>
) : (
<SlackButton
scopes={[
"commands",
"links:read",
"links:write",
// TODO: Wait forever for Slack to approve these scopes.
//"users:read",
//"users:read.email",
]}
redirectUri={`${env.URL}/auth/slack.commands`}
state={team.id}
icon={<SlackIcon color="currentColor" />}
/>
)}
</p>
<p>&nbsp;</p>
<h2>{t("Collections")}</h2>
<Text type="secondary">
<Trans>
Connect {{ appName }} collections to Slack channels. Messages will
be automatically posted to Slack when documents are published or
updated.
</Trans>
</Text>
<List>
{groupedCollections.map(([collection, integration]) => {
if (integration) {
return (
<SlackListItem
key={integration.id}
collection={collection}
integration={
integration as Integration<IntegrationType.Post>
}
/>
);
}
return (
<ListItem
key={collection.id}
title={collection.name}
image={<CollectionIcon collection={collection} />}
actions={
<SlackButton
scopes={["incoming-webhook"]}
redirectUri={`${env.URL}/auth/slack.post`}
state={collection.id}
label={t("Connect")}
/>
}
/>
);
})}
</List>
</>
) : (
<Notice>
<Trans>
The Slack integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
const Code = styled.code`
padding: 4px 6px;
margin: 0 2px;
background: ${(props) => props.theme.codeBackground};
border-radius: 4px;
`;
export default observer(Slack);

View File

@@ -1,38 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { slackAuth } from "@shared/utils/urlHelpers";
import Button from "~/components/Button";
import env from "~/env";
type Props = {
scopes?: string[];
redirectUri: string;
icon?: React.ReactNode;
state?: string;
label?: string;
};
function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
const { t } = useTranslation();
const handleClick = () => {
if (!env.SLACK_CLIENT_ID) {
return;
}
window.location.href = slackAuth(
state,
scopes,
env.SLACK_CLIENT_ID,
redirectUri
);
};
return (
<Button onClick={handleClick} icon={icon} neutral>
{label || t("Add to Slack")}
</Button>
);
}
export default SlackButton;

View File

@@ -1,118 +0,0 @@
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 { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import ListItem from "~/components/List/Item";
import Popover from "~/components/Popover";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts";
type Props = {
integration: Integration<IntegrationType.Post>;
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>
<Text type="secondary">
{t("These events should be posted to Slack")}
</Text>
<Switch
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange}
/>
<Switch
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

@@ -1,4 +1,13 @@
declare global {
interface NodeRequire {
/** A special feature supported by webpack's compiler that allows you to get all matching modules starting from some base directory. */
context: (
directory: string,
useSubdirectories: boolean,
regExp: RegExp
) => any;
}
interface Window {
dataLayer: any[];
gtag: (...args: any[]) => void;

37
app/utils/plugins.ts Normal file
View File

@@ -0,0 +1,37 @@
interface Plugin {
id: string;
config: {
name: string;
description: string;
requiredEnvVars?: string[];
};
settings: React.FC;
icon: React.FC;
}
export function loadPlugins(): { [id: string]: Plugin } {
const plugins = {};
function importAll(r: any, property: string) {
r.keys().forEach((key: string) => {
const id = key.split("/")[1];
plugins[id] = plugins[id] || {
id,
};
const plugin = r(key);
plugins[id][property] = "default" in plugin ? plugin.default : plugin;
});
}
importAll(
require.context("../../plugins", true, /client\/Settings\.[tj]sx?$/),
"settings"
);
importAll(
require.context("../../plugins", true, /client\/Icon\.[tj]sx?$/),
"icon"
);
importAll(require.context("../../plugins", true, /plugin\.json?$/), "config");
return plugins;
}