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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
[]
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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> </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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
9
app/typings/window.d.ts
vendored
9
app/typings/window.d.ts
vendored
@@ -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
37
app/utils/plugins.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user