From 8619b219e7d61e3510b8937e375eb4555325f2ff Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 1 Mar 2026 17:47:29 -0500 Subject: [PATCH] feat: Configurable slash embeds (#11612) * wip * Use id instead of title Settings UI tweaks * test * Add toggle for all providers * Remove 'Abstract' embed, no longer available --- app/editor/components/PasteMenu.tsx | 5 +- app/editor/components/SuggestionsMenu.tsx | 2 +- app/hooks/useEmbeds.ts | 12 +- app/hooks/useSettingsConfig.ts | 14 ++ app/models/Team.ts | 6 +- app/scenes/Settings/Embeds.tsx | 157 ++++++++++++++++++ app/scenes/Settings/Integrations.tsx | 5 +- app/scenes/Settings/Security.tsx | 21 --- .../Settings/components/IntegrationCard.tsx | 4 +- app/scenes/Settings/components/SettingRow.tsx | 9 +- server/routes/api/teams/schema.ts | 2 + shared/constants.ts | 1 + shared/editor/embeds/index.tsx | 71 ++++++-- .../editor/lib/filterExcessSeparators.test.ts | 1 + shared/i18n/locales/en_US/translation.json | 11 +- shared/types.ts | 3 + 16 files changed, 275 insertions(+), 49 deletions(-) create mode 100644 app/scenes/Settings/Embeds.tsx diff --git a/app/editor/components/PasteMenu.tsx b/app/editor/components/PasteMenu.tsx index e90db761d2..fb032d6dca 100644 --- a/app/editor/components/PasteMenu.tsx +++ b/app/editor/components/PasteMenu.tsx @@ -67,7 +67,10 @@ function useItems({ const singleUrl = typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null; - const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null; + const matchedEmbed = singleUrl + ? getMatchingEmbed(embeds, singleUrl)?.embed + : null; + const embed = matchedEmbed?.disabled ? null : matchedEmbed; // Check embeddability for single URL useEffect(() => { diff --git a/app/editor/components/SuggestionsMenu.tsx b/app/editor/components/SuggestionsMenu.tsx index 350d1f2c40..5b23dfe006 100644 --- a/app/editor/components/SuggestionsMenu.tsx +++ b/app/editor/components/SuggestionsMenu.tsx @@ -461,7 +461,7 @@ function SuggestionsMenu(props: Props) { const embedItems: EmbedDescriptor[] = []; for (const embed of embeds) { - if (embed.title && embed.visible !== false) { + if (embed.title && embed.visible !== false && !embed.disabled) { embedItems.push( new EmbedDescriptor({ ...embed, diff --git a/app/hooks/useEmbeds.ts b/app/hooks/useEmbeds.ts index e8a7964961..f01e8f4105 100644 --- a/app/hooks/useEmbeds.ts +++ b/app/hooks/useEmbeds.ts @@ -1,9 +1,10 @@ import find from "lodash/find"; import { useEffect, useMemo } from "react"; import embeds from "@shared/editor/embeds"; -import { IntegrationType } from "@shared/types"; +import { IntegrationType, TeamPreference } from "@shared/types"; import type Integration from "~/models/Integration"; import Logger from "~/utils/Logger"; +import useCurrentTeam from "./useCurrentTeam"; import useStores from "./useStores"; /** @@ -14,6 +15,7 @@ import useStores from "./useStores"; */ export default function useEmbeds(loadIfMissing = false) { const { integrations } = useStores(); + const team = useCurrentTeam(); useEffect(() => { async function fetchEmbedIntegrations() { @@ -31,6 +33,9 @@ export default function useEmbeds(loadIfMissing = false) { } }, [integrations, loadIfMissing]); + const disabledEmbeds = + (team.getPreference(TeamPreference.DisabledEmbeds) as string[]) || []; + return useMemo( () => embeds.map((e) => { @@ -42,8 +47,11 @@ export default function useEmbeds(loadIfMissing = false) { e.settings = integration.settings; } + e.disabled = disabledEmbeds.includes(e.id); + return e; }), - [integrations.orderedData] + // eslint-disable-next-line react-hooks/exhaustive-deps + [integrations.orderedData, team.preferences] ); } diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 5188e3be80..f554c503f4 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -17,6 +17,7 @@ import { InternetIcon, SmileyIcon, BuildingBlocksIcon, + BrowserIcon, } from "outline-icons"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; @@ -48,6 +49,7 @@ const Security = lazy(() => import("~/scenes/Settings/Security")); const Shares = lazy(() => import("~/scenes/Settings/Shares")); const Templates = lazy(() => import("~/scenes/Settings/Templates")); const CustomEmojis = lazy(() => import("~/scenes/Settings/CustomEmojis")); +const Embeds = lazy(() => import("~/scenes/Settings/Embeds")); export type ConfigItem = { name: string; @@ -234,6 +236,18 @@ const useSettingsConfig = () => { icon: ExportIcon, }, // Integrations + { + name: t("Embeds"), + path: integrationSettingsPath("embeds"), + component: Embeds.Component, + preload: Embeds.preload, + description: t( + "Configure which embed providers are available in the editor." + ), + enabled: can.update, + group: t("Integrations"), + icon: BrowserIcon, + }, { name: `${t("Install")}…`, path: settingsPath("integrations"), diff --git a/app/models/Team.ts b/app/models/Team.ts index b853f2cb98..47363c7f60 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -114,10 +114,10 @@ class Team extends Model { /** * Set the value for a specific preference key. * - * @param key The TeamPreference key to retrieve - * @param value The value to set + * @param key The TeamPreference key to set. + * @param value The value to set. */ - setPreference(key: TeamPreference, value: boolean) { + setPreference(key: T, value: TeamPreferences[T]) { this.preferences = { ...this.preferences, [key]: value, diff --git a/app/scenes/Settings/Embeds.tsx b/app/scenes/Settings/Embeds.tsx new file mode 100644 index 0000000000..77f47fa507 --- /dev/null +++ b/app/scenes/Settings/Embeds.tsx @@ -0,0 +1,157 @@ +import debounce from "lodash/debounce"; +import { observer } from "mobx-react"; +import { BrowserIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation, Trans } from "react-i18next"; +import { toast } from "sonner"; +import embeds from "@shared/editor/embeds"; +import { TeamPreference } from "@shared/types"; +import Heading from "~/components/Heading"; +import Switch from "~/components/Switch"; +import Text from "~/components/Text"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import { IntegrationScene } from "./components/IntegrationScene"; +import SettingRow from "./components/SettingRow"; +import { HStack } from "~/components/primitives/HStack"; + +/** List of embed providers available for configuration. */ +const providers = embeds.filter((e) => e.id !== "embed"); + +function Embeds() { + const team = useCurrentTeam(); + const { t } = useTranslation(); + + const showSuccessMessage = React.useMemo( + () => + debounce(() => { + toast.success(t("Settings saved")); + }, 250), + [t] + ); + + const saveData = React.useCallback( + async (newData: Record) => { + try { + await team.save(newData); + showSuccessMessage(); + } catch (err) { + toast.error((err as Error).message); + } + }, + [team, showSuccessMessage] + ); + + const handleDocumentEmbedsChange = React.useCallback( + async (checked: boolean) => { + await saveData({ documentEmbeds: checked }); + }, + [saveData] + ); + + const handleToggleEmbed = React.useCallback( + async (id: string, enabled: boolean) => { + const disabledEmbeds = + (team.getPreference(TeamPreference.DisabledEmbeds) as string[]) || []; + + const updated = enabled + ? disabledEmbeds.filter((t) => t !== id) + : [...disabledEmbeds, id]; + + team.setPreference(TeamPreference.DisabledEmbeds, updated); + await saveData({ + preferences: { ...team.preferences }, + }); + }, + [team, saveData] + ); + + const handleToggleAllEmbeds = React.useCallback( + async (enabled: boolean) => { + const updated = enabled ? [] : providers.map((e) => e.id); + + team.setPreference(TeamPreference.DisabledEmbeds, updated); + await saveData({ + preferences: { ...team.preferences }, + }); + }, + [team, saveData] + ); + + const disabledEmbeds = + (team.getPreference(TeamPreference.DisabledEmbeds) as string[]) || []; + + return ( + }> + {t("Embeds")} + + + + + + {team.documentEmbeds && ( + <> + {t("Providers")} + + + Enabled providers will appear in the editor slash menu and embed + automatically when a compatible link is pasted. Existing embeds in + documents will continue to display regardless of these settings. + + + + + + {providers.map((embed) => { + const enabled = !disabledEmbeds.includes(embed.id); + return ( + + {embed.icon} + + {embed.title} + + + } + compact + > + + handleToggleEmbed(embed.id, checked) + } + /> + + ); + })} + + )} + + ); +} + +export default observer(Embeds); diff --git a/app/scenes/Settings/Integrations.tsx b/app/scenes/Settings/Integrations.tsx index 6906d3688f..df43ef1cd2 100644 --- a/app/scenes/Settings/Integrations.tsx +++ b/app/scenes/Settings/Integrations.tsx @@ -10,7 +10,7 @@ import Text from "~/components/Text"; import useSettingsConfig from "~/hooks/useSettingsConfig"; import useStores from "~/hooks/useStores"; import { settingsPath } from "~/utils/routeHelpers"; -import IntegrationCard from "./components/IntegrationCard"; +import IntegrationCard, { Card } from "./components/IntegrationCard"; import { StickyFilters } from "./components/StickyFilters"; import { observer } from "mobx-react"; @@ -62,6 +62,9 @@ function Integrations() { {groupedItems.available?.map((item) => ( ))} + {groupedItems.available?.length % 2 === 1 && ( + + )} ); diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index cad2abd510..5d4d147352 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -25,7 +25,6 @@ function Security() { const [data, setData] = useState({ sharing: team.sharing, - documentEmbeds: team.documentEmbeds, defaultUserRole: team.defaultUserRole, memberCollectionCreate: team.memberCollectionCreate, memberTeamCreate: team.memberTeamCreate, @@ -107,13 +106,6 @@ function Security() { [saveData] ); - const handleDocumentEmbedsChange = React.useCallback( - async (checked: boolean) => { - await saveData({ documentEmbeds: checked }); - }, - [saveData] - ); - const handlePasskeysEnabledChange = React.useCallback( async (checked: boolean) => { await saveData({ passkeysEnabled: checked }); @@ -327,19 +319,6 @@ function Security() { onChange={handleMembersCanDeleteAccountChange} /> - - - @@ -39,7 +39,7 @@ function IntegrationCard({ integration, isConnected }: Props) { export default IntegrationCard; -const Card = styled.div` +export const Card = styled.div` display: flex; flex-direction: column; flex-grow: 1; diff --git a/app/scenes/Settings/components/SettingRow.tsx b/app/scenes/Settings/components/SettingRow.tsx index 0b661bb4ab..a6ccaa54f7 100644 --- a/app/scenes/Settings/components/SettingRow.tsx +++ b/app/scenes/Settings/components/SettingRow.tsx @@ -12,11 +12,13 @@ type Props = { name: string; visible?: boolean; border?: boolean; + compact?: boolean; }; -const Row = styled(Flex)<{ $border?: boolean }>` +const Row = styled(Flex)<{ $border?: boolean; $compact?: boolean }>` display: block; - padding: 22px 0; + padding: ${(props) => (props.$compact ? "12px 0" : "22px 0")}; + align-items: ${(props) => (props.$compact ? "center" : "initial")}; border-bottom: 1px solid ${(props) => props.$border === false @@ -60,6 +62,7 @@ const Label = styled(Text)` const SettingRow: React.FC = ({ visible, description, + compact, name, label, border, @@ -69,7 +72,7 @@ const SettingRow: React.FC = ({ return null; } return ( - +