From d0bd2baa9f8ecf21f341a0f080fb30c045d68bd0 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 10 May 2025 09:59:41 -0400 Subject: [PATCH] Add integrations page (#9155) * update useSettings * Integration page skeleton * add descriptions * update design * Integration page style update * clean up * update integration card Co-authored-by: Tom Moor * Update integration icon size Co-authored-by: Tom Moor * Update all integrations menu item * update IntegrationCard to use the `Text` component * update card status * fix: Google analytics never shows as installed fix: Styling tweaks Move webhooks out of integrations * Add breadcrumbs * Add filtering * refactor * Add hover state, tweak descriptions --------- Co-authored-by: Tess99854 Co-authored-by: Mahmoud Mohammed Ali Co-authored-by: Mahmoud Ali --- app/components/Sidebar/Settings.tsx | 9 +- app/hooks/useSettingsConfig.ts | 23 +++++ app/scenes/Settings/Integrations.tsx | 62 +++++++++++++ .../Settings/components/IntegrationCard.tsx | 91 +++++++++++++++++++ .../Settings/components/IntegrationScene.tsx | 33 +++++++ app/utils/PluginManager.ts | 4 +- plugins/github/client/Settings.tsx | 6 +- plugins/github/client/index.tsx | 2 + plugins/googleanalytics/client/Settings.tsx | 6 +- plugins/googleanalytics/client/index.tsx | 2 + plugins/googleanalytics/plugin.json | 2 +- plugins/linear/client/index.tsx | 2 + plugins/matomo/client/Settings.tsx | 6 +- plugins/matomo/client/index.tsx | 2 + plugins/slack/client/Settings.tsx | 6 +- plugins/slack/client/index.tsx | 2 + plugins/umami/client/Settings.tsx | 6 +- plugins/umami/client/index.tsx | 2 + plugins/webhooks/client/index.tsx | 5 +- shared/i18n/locales/en_US/translation.json | 5 +- 20 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 app/scenes/Settings/Integrations.tsx create mode 100644 app/scenes/Settings/components/IntegrationCard.tsx create mode 100644 app/scenes/Settings/components/IntegrationScene.tsx diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx index a156bbb3a8..8b6fd79a73 100644 --- a/app/components/Sidebar/Settings.tsx +++ b/app/components/Sidebar/Settings.tsx @@ -27,7 +27,12 @@ function SettingsSidebar() { const { t } = useTranslation(); const history = useHistory(); const location = useLocation(); - const configs = useSettingsConfig(); + let configs = useSettingsConfig(); + + configs = configs.filter((item) => + "isActive" in item ? item.isActive : true + ); + const groupedConfig = groupBy(configs, "group"); const returnToApp = React.useCallback(() => { @@ -65,7 +70,7 @@ function SettingsSidebar() { to={item.path} onClickIntent={item.preload} active={ - item.path !== settingsPath() + item.path.startsWith(settingsPath("templates")) ? location.pathname.startsWith(item.path) : undefined } diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index a212fb1ea8..c1fe3ce989 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -13,11 +13,13 @@ import { ImportIcon, ShapesIcon, Icon, + PlusIcon, InternetIcon, } from "outline-icons"; import React, { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { integrationSettingsPath } from "@shared/utils/routeHelpers"; +import { Integrations } from "~/scenes/Settings/Integrations"; import ZapierIcon from "~/components/Icons/ZapierIcon"; import { createLazyComponent as lazy } from "~/components/LazyLoad"; import { Hook, PluginManager } from "~/utils/PluginManager"; @@ -27,6 +29,7 @@ import { useComputed } from "./useComputed"; import useCurrentTeam from "./useCurrentTeam"; import useCurrentUser from "./useCurrentUser"; import usePolicy from "./usePolicy"; +import useStores from "./useStores"; const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys")); const Applications = lazy(() => import("~/scenes/Settings/Applications")); @@ -50,17 +53,24 @@ export type ConfigItem = { path: string; icon: React.FC>; component: React.ComponentType; + description?: string; preload?: () => void; enabled: boolean; group: string; + isActive?: boolean; }; const useSettingsConfig = () => { + const { integrations } = useStores(); const user = useCurrentUser(); const team = useCurrentTeam(); const can = usePolicy(team); const { t } = useTranslation(); + React.useEffect(() => { + void integrations.fetchAll(); + }, [integrations]); + const config = useComputed(() => { const items: ConfigItem[] = [ // Account @@ -210,6 +220,14 @@ const useSettingsConfig = () => { group: t("Integrations"), icon: ZapierIcon, }, + { + name: `${t("Install")}…`, + path: settingsPath("integrations"), + component: Integrations, + enabled: true, + group: t("Integrations"), + icon: PlusIcon, + }, ]; // Plugins @@ -225,12 +243,17 @@ const useSettingsConfig = () => { ? integrationSettingsPath(plugin.id) : settingsPath(plugin.id), group: t(group), + description: plugin.value.description, component: plugin.value.component.Component, preload: plugin.value.component.preload, enabled: plugin.value.enabled ? plugin.value.enabled(team, user) : can.update, icon: plugin.value.icon, + isActive: integrations.orderedData.some( + (integration) => + integration.service.toLowerCase() === plugin.id.toLowerCase() + ), } as ConfigItem); }); diff --git a/app/scenes/Settings/Integrations.tsx b/app/scenes/Settings/Integrations.tsx new file mode 100644 index 0000000000..8865acabfa --- /dev/null +++ b/app/scenes/Settings/Integrations.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Flex from "@shared/components/Flex"; +import Heading from "~/components/Heading"; +import InputSearch from "~/components/InputSearch"; +import Scene from "~/components/Scene"; +import Text from "~/components/Text"; +import useSettingsConfig from "~/hooks/useSettingsConfig"; +import { settingsPath } from "~/utils/routeHelpers"; +import IntegrationCard from "./components/IntegrationCard"; +import { StickyFilters } from "./components/StickyFilters"; + +export function Integrations() { + const { t } = useTranslation(); + let items = useSettingsConfig(); + const [query, setQuery] = React.useState(""); + + const handleQuery = (event: React.ChangeEvent) => { + setQuery(event.target.value); + }; + + items = items + .filter( + (item) => + item.group === "Integrations" && + item.enabled && + item.path !== settingsPath("integrations") && + item.name.toLowerCase().includes(query.toLowerCase()) + ) + .sort((item) => (item.isActive ? -1 : 1)); + + return ( + + {t("Integrations")} + + + Configure a variety of integrations with third-party services. + + + + + + + + {items.map((item) => ( + + ))} + + + ); +} + +const CardsFlex = styled(Flex)` + margin-top: 20px; + width: "100%"; +`; diff --git a/app/scenes/Settings/components/IntegrationCard.tsx b/app/scenes/Settings/components/IntegrationCard.tsx new file mode 100644 index 0000000000..336d78038d --- /dev/null +++ b/app/scenes/Settings/components/IntegrationCard.tsx @@ -0,0 +1,91 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { s, ellipsis } from "@shared/styles"; +import { ConfigItem } from "~/hooks/useSettingsConfig"; +import Button from "../../../components/Button"; +import Flex from "../../../components/Flex"; +import Text from "../../../components/Text"; + +type Props = { + integration: ConfigItem; +}; + +function IntegrationCard({ integration }: Props) { + const { t } = useTranslation(); + return ( + + + + + {integration.name} + {integration.isActive && {t("Connected")}} + + + + + {integration.description} + + ); +} + +export default IntegrationCard; + +const Card = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 20px; + width: 300px; + background: ${s("background")}; + border: 1px solid ${s("inputBorder")}; + color: ${s("text")}; + border-radius: 8px; + transition: box-shadow 200ms ease; + cursor: var(--pointer); + + &:hover { + box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px; + } +`; + +const Name = styled(Text)` + margin: 0; + font-size: 16px; + font-weight: 600; + color: ${s("text")}; + ${ellipsis()} +`; + +const Description = styled(Text)` + margin: 8px 0 0; + font-size: 15px; + max-width: 100%; + color: ${s("textTertiary")}; +`; + +const Status = styled(Text).attrs({ + type: "secondary", + size: "small", + as: "span", +})` + display: inline-flex; + align-items: center; + + &::after { + content: ""; + display: inline-block; + width: 17px; + height: 17px; + + background: radial-gradient( + circle at center, + ${s("accent")} 0 33%, + transparent 33% + ); + border-radius: 50%; + } +`; diff --git a/app/scenes/Settings/components/IntegrationScene.tsx b/app/scenes/Settings/components/IntegrationScene.tsx new file mode 100644 index 0000000000..c534114d2c --- /dev/null +++ b/app/scenes/Settings/components/IntegrationScene.tsx @@ -0,0 +1,33 @@ +import { SettingsIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Breadcrumb from "~/components/Breadcrumb"; +import Scene from "~/components/Scene"; +import { settingsPath } from "~/utils/routeHelpers"; + +export function IntegrationScene({ + children, + ...rest +}: React.ComponentProps) { + const { t } = useTranslation(); + + return ( + , + to: settingsPath("integrations"), + }, + ]} + /> + } + {...rest} + > + {children} + + ); +} diff --git a/app/utils/PluginManager.ts b/app/utils/PluginManager.ts index 27d80ca7d5..1bda8a2479 100644 --- a/app/utils/PluginManager.ts +++ b/app/utils/PluginManager.ts @@ -28,8 +28,10 @@ type PluginValueMap = { after?: string; /** The displayed icon of the plugin. */ icon: React.ElementType; - /** The settings screen somponent, should be lazy loaded. */ + /** The lazy loaded settings screen component. */ component: LazyComponent; + /** The description that will show on the plugins card. */ + description?: string; /** Whether the plugin is enabled in the current context. */ enabled?: (team: Team, user: User) => boolean; }; diff --git a/plugins/github/client/Settings.tsx b/plugins/github/client/Settings.tsx index c1cd713367..f7e9e4f014 100644 --- a/plugins/github/client/Settings.tsx +++ b/plugins/github/client/Settings.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { IntegrationService } from "@shared/types"; import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton"; +import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; import { AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; @@ -11,7 +12,6 @@ import List from "~/components/List"; import ListItem from "~/components/List/Item"; import Notice from "~/components/Notice"; import PlaceholderText from "~/components/PlaceholderText"; -import Scene from "~/components/Scene"; import TeamLogo from "~/components/TeamLogo"; import Text from "~/components/Text"; import Time from "~/components/Time"; @@ -38,7 +38,7 @@ function GitHub() { }, [integrations]); return ( - }> + }> GitHub {error === "access_denied" && ( @@ -146,7 +146,7 @@ function GitHub() { )} - + ); } diff --git a/plugins/github/client/index.tsx b/plugins/github/client/index.tsx index 2506721686..4be8ac722a 100644 --- a/plugins/github/client/index.tsx +++ b/plugins/github/client/index.tsx @@ -10,6 +10,8 @@ PluginManager.add([ value: { group: "Integrations", icon: Icon, + description: + "Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.", component: createLazyComponent(() => import("./Settings")), }, }, diff --git a/plugins/googleanalytics/client/Settings.tsx b/plugins/googleanalytics/client/Settings.tsx index 632384c4d1..1fe07eea5d 100644 --- a/plugins/googleanalytics/client/Settings.tsx +++ b/plugins/googleanalytics/client/Settings.tsx @@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next"; import { toast } from "sonner"; import { IntegrationType, IntegrationService } from "@shared/types"; import Integration from "~/models/Integration"; +import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; import SettingRow from "~/scenes/Settings/components/SettingRow"; import Button from "~/components/Button"; import Heading from "~/components/Heading"; import GoogleIcon from "~/components/Icons/GoogleIcon"; import Input from "~/components/Input"; -import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; @@ -75,7 +75,7 @@ function GoogleAnalytics() { ); return ( - }> + }> {t("Google Analytics")} @@ -100,7 +100,7 @@ function GoogleAnalytics() { {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} - + ); } diff --git a/plugins/googleanalytics/client/index.tsx b/plugins/googleanalytics/client/index.tsx index 2506721686..9387ee3074 100644 --- a/plugins/googleanalytics/client/index.tsx +++ b/plugins/googleanalytics/client/index.tsx @@ -10,6 +10,8 @@ PluginManager.add([ value: { group: "Integrations", icon: Icon, + description: + "Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.", component: createLazyComponent(() => import("./Settings")), }, }, diff --git a/plugins/googleanalytics/plugin.json b/plugins/googleanalytics/plugin.json index 9548ca4f3e..ca67e10212 100644 --- a/plugins/googleanalytics/plugin.json +++ b/plugins/googleanalytics/plugin.json @@ -1,5 +1,5 @@ { - "id": "googleanalytics", + "id": "google-analytics", "name": "Google Analytics", "priority": 30, "description": "Adds support for reporting analytics to a Google." diff --git a/plugins/linear/client/index.tsx b/plugins/linear/client/index.tsx index 2506721686..bf1bb39e14 100644 --- a/plugins/linear/client/index.tsx +++ b/plugins/linear/client/index.tsx @@ -10,6 +10,8 @@ PluginManager.add([ value: { group: "Integrations", icon: Icon, + description: + "Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.", component: createLazyComponent(() => import("./Settings")), }, }, diff --git a/plugins/matomo/client/Settings.tsx b/plugins/matomo/client/Settings.tsx index 7ad7565d1f..b636b2814e 100644 --- a/plugins/matomo/client/Settings.tsx +++ b/plugins/matomo/client/Settings.tsx @@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next"; import { toast } from "sonner"; import { IntegrationType, IntegrationService } from "@shared/types"; import Integration from "~/models/Integration"; +import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; import SettingRow from "~/scenes/Settings/components/SettingRow"; import Button from "~/components/Button"; import Heading from "~/components/Heading"; import Input from "~/components/Input"; -import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; import Icon from "./Icon"; @@ -82,7 +82,7 @@ function Matomo() { ); return ( - }> + }> Matomo @@ -121,7 +121,7 @@ function Matomo() { {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} - + ); } diff --git a/plugins/matomo/client/index.tsx b/plugins/matomo/client/index.tsx index 1d2aeaf097..3ccfc61a19 100644 --- a/plugins/matomo/client/index.tsx +++ b/plugins/matomo/client/index.tsx @@ -11,6 +11,8 @@ PluginManager.add([ value: { group: "Integrations", icon: Icon, + description: + "Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.", component: createLazyComponent(() => import("./Settings")), enabled: (_, user) => user.role === UserRole.Admin, }, diff --git a/plugins/slack/client/Settings.tsx b/plugins/slack/client/Settings.tsx index 18d51fefa1..bcc1c8382e 100644 --- a/plugins/slack/client/Settings.tsx +++ b/plugins/slack/client/Settings.tsx @@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types"; import Collection from "~/models/Collection"; import Integration from "~/models/Integration"; import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton"; +import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; import SettingRow from "~/scenes/Settings/components/SettingRow"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; @@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon"; 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"; @@ -67,7 +67,7 @@ function Slack() { const appName = env.APP_NAME; return ( - }> + }> Slack {error === "access_denied" && ( @@ -205,7 +205,7 @@ function Slack() { )} - + ); } diff --git a/plugins/slack/client/index.tsx b/plugins/slack/client/index.tsx index 0ac987c26d..c6a5a5e774 100644 --- a/plugins/slack/client/index.tsx +++ b/plugins/slack/client/index.tsx @@ -11,6 +11,8 @@ PluginManager.add([ value: { group: "Integrations", icon: Icon, + description: + "Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.", component: createLazyComponent(() => import("./Settings")), enabled: (_, user) => [UserRole.Member, UserRole.Admin].includes(user.role), diff --git a/plugins/umami/client/Settings.tsx b/plugins/umami/client/Settings.tsx index 89784e0a74..22abd17b80 100644 --- a/plugins/umami/client/Settings.tsx +++ b/plugins/umami/client/Settings.tsx @@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next"; import { toast } from "sonner"; import { IntegrationType, IntegrationService } from "@shared/types"; import Integration from "~/models/Integration"; +import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; import SettingRow from "~/scenes/Settings/components/SettingRow"; import Button from "~/components/Button"; import Heading from "~/components/Heading"; import Input from "~/components/Input"; -import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useStores from "~/hooks/useStores"; import Icon from "./Icon"; @@ -85,7 +85,7 @@ function Umami() { ); return ( - }> + }> Umami @@ -145,7 +145,7 @@ function Umami() { {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} - + ); } diff --git a/plugins/umami/client/index.tsx b/plugins/umami/client/index.tsx index 1d2aeaf097..a839452c91 100644 --- a/plugins/umami/client/index.tsx +++ b/plugins/umami/client/index.tsx @@ -11,6 +11,8 @@ PluginManager.add([ value: { group: "Integrations", icon: Icon, + description: + "Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.", component: createLazyComponent(() => import("./Settings")), enabled: (_, user) => user.role === UserRole.Admin, }, diff --git a/plugins/webhooks/client/index.tsx b/plugins/webhooks/client/index.tsx index 2506721686..00d84afedd 100644 --- a/plugins/webhooks/client/index.tsx +++ b/plugins/webhooks/client/index.tsx @@ -8,8 +8,11 @@ PluginManager.add([ ...config, type: Hook.Settings, value: { - group: "Integrations", + group: "Workspace", + after: "Shared Links", icon: Icon, + description: + "Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.", component: createLazyComponent(() => import("./Settings")), }, }, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index fa45a461cb..c897f10deb 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -529,6 +529,7 @@ "Shared Links": "Shared Links", "Import": "Import", "Integrations": "Integrations", + "Install": "Install", "Revoke token": "Revoke token", "Revoke": "Revoke", "Show path to document": "Show path to document", @@ -974,6 +975,8 @@ "{{ count }} document imported_plural": "{{ count }} documents imported", "You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open Export in the Settings sidebar and click on Export Data.": "You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open Export in the Settings sidebar and click on Export Data.", "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload", + "Configure": "Configure", + "Connect": "Connect", "Last active": "Last active", "Guest": "Guest", "Never used": "Never used", @@ -1034,6 +1037,7 @@ "Enterprise": "Enterprise", "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.", "Recent imports": "Recent imports", + "Configure a variety of integrations with third-party services.": "Configure a variety of integrations with third-party services.", "Could not load members": "Could not load members", "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.", "Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published", @@ -1144,7 +1148,6 @@ "Expires today": "Expires today", "Expires tomorrow": "Expires tomorrow", "Expires {{ date }}": "Expires {{ date }}", - "Connect": "Connect", "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. 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.", "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.",