diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 5359195926..2851b8332c 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -1,12 +1,12 @@ import { formatDistanceToNow } from "date-fns"; import { deburr, sortBy } from "lodash"; +import { observer } from "mobx-react"; import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { TextSelection } from "prosemirror-state"; import * as React from "react"; import { mergeRefs } from "react-merge-refs"; import { Optional } from "utility-types"; import insertFiles from "@shared/editor/commands/insertFiles"; -import embeds from "@shared/editor/embeds"; import { Heading } from "@shared/editor/lib/getHeadings"; import { getDataTransferFiles } from "@shared/utils/files"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; @@ -18,6 +18,7 @@ import ErrorBoundary from "~/components/ErrorBoundary"; import HoverPreview from "~/components/HoverPreview"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import useDictionary from "~/hooks/useDictionary"; +import useEmbeds from "~/hooks/useEmbeds"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import { NotFoundError } from "~/utils/errors"; @@ -58,6 +59,7 @@ function Editor(props: Props, ref: React.RefObject | null) { const { documents } = useStores(); const { showToast } = useToasts(); const dictionary = useDictionary(); + const embeds = useEmbeds(); const [ activeLinkEvent, setActiveLinkEvent, @@ -310,4 +312,4 @@ function Editor(props: Props, ref: React.RefObject | null) { ); } -export default React.forwardRef(Editor); +export default observer(React.forwardRef(Editor)); diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx index cf172db1e7..af7881951d 100644 --- a/app/editor/components/CommandMenu.tsx +++ b/app/editor/components/CommandMenu.tsx @@ -7,9 +7,10 @@ import { Portal } from "react-portal"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; import insertFiles from "@shared/editor/commands/insertFiles"; +import { EmbedDescriptor } from "@shared/editor/embeds"; import { CommandFactory } from "@shared/editor/lib/Extension"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; -import { EmbedDescriptor, MenuItem } from "@shared/editor/types"; +import { MenuItem } from "@shared/editor/types"; import { depths } from "@shared/styles"; import { getEventFiles } from "@shared/utils/files"; import { AttachmentValidation } from "@shared/validations"; @@ -427,10 +428,12 @@ class CommandMenu extends React.Component, State> { for (const embed of embeds) { if (embed.title) { - embedItems.push({ - ...embed, - name: "embed", - }); + embedItems.push( + new EmbedDescriptor({ + ...embed, + name: "embed", + }) + ); } } diff --git a/app/editor/index.tsx b/app/editor/index.tsx index f399890995..e448e30e83 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -16,6 +16,7 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state"; import { Decoration, EditorView } from "prosemirror-view"; import * as React from "react"; import { DefaultTheme, ThemeProps } from "styled-components"; +import { EmbedDescriptor } from "@shared/editor/embeds"; import Extension, { CommandFactory } from "@shared/editor/lib/Extension"; import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import getHeadings from "@shared/editor/lib/getHeadings"; @@ -25,8 +26,10 @@ import Mark from "@shared/editor/marks/Mark"; import Node from "@shared/editor/nodes/Node"; import ReactNode from "@shared/editor/nodes/ReactNode"; import fullExtensionsPackage from "@shared/editor/packages/full"; -import { EmbedDescriptor, EventType } from "@shared/editor/types"; +import { EventType } from "@shared/editor/types"; +import { IntegrationType } from "@shared/types"; import EventEmitter from "@shared/utils/events"; +import Integration from "~/models/Integration"; import Flex from "~/components/Flex"; import { Dictionary } from "~/hooks/useDictionary"; import Logger from "~/utils/Logger"; @@ -110,6 +113,8 @@ export type Props = { onShowToast: (message: string) => void; className?: string; style?: React.CSSProperties; + + embedIntegrations?: Integration[]; }; type State = { diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useAuthorizedSettingsConfig.ts index 47aafc5722..dbb4284284 100644 --- a/app/hooks/useAuthorizedSettingsConfig.ts +++ b/app/hooks/useAuthorizedSettingsConfig.ts @@ -9,12 +9,14 @@ import { LinkIcon, TeamIcon, BeakerIcon, + BuildingBlocksIcon, DownloadIcon, WebhooksIcon, } from "outline-icons"; import React from "react"; import { useTranslation } from "react-i18next"; import Details from "~/scenes/Settings/Details"; +import Drawio from "~/scenes/Settings/Drawio"; import Export from "~/scenes/Settings/Export"; import Features from "~/scenes/Settings/Features"; import Groups from "~/scenes/Settings/Groups"; @@ -170,6 +172,14 @@ const useAuthorizedSettingsConfig = () => { group: t("Integrations"), icon: WebhooksIcon, }, + Drawio: { + name: t("Draw.io"), + path: "/settings/integrations/drawio", + component: Drawio, + enabled: can.update, + group: t("Integrations"), + icon: BuildingBlocksIcon, + }, Slack: { name: "Slack", path: "/settings/integrations/slack", diff --git a/app/hooks/useEmbeds.ts b/app/hooks/useEmbeds.ts new file mode 100644 index 0000000000..800a108817 --- /dev/null +++ b/app/hooks/useEmbeds.ts @@ -0,0 +1,41 @@ +import { find } from "lodash"; +import * as React from "react"; +import embeds, { EmbedDescriptor } from "@shared/editor/embeds"; +import { IntegrationType } from "@shared/types"; +import Integration from "~/models/Integration"; +import Logger from "~/utils/Logger"; +import useStores from "./useStores"; + +export default function useEmbedIntegrations() { + const { integrations } = useStores(); + + React.useEffect(() => { + async function fetchEmbedIntegrations() { + try { + await integrations.fetchPage({ + limit: 100, + type: IntegrationType.Embed, + }); + } catch (err) { + Logger.error("Failed to fetch embed integrations", err); + } + } + + !integrations.isLoaded && fetchEmbedIntegrations(); + }, [integrations]); + + return React.useMemo( + () => + embeds.map((e) => { + const em: Integration | undefined = find( + integrations.orderedData, + (i) => i.service === e.component.name.toLowerCase() + ); + return new EmbedDescriptor({ + ...e, + settings: em?.settings, + }); + }), + [integrations.orderedData] + ); +} diff --git a/app/models/Integration.ts b/app/models/Integration.ts index 940fb80285..f62e1ff853 100644 --- a/app/models/Integration.ts +++ b/app/models/Integration.ts @@ -1,14 +1,9 @@ import { observable } from "mobx"; +import type { IntegrationSettings } from "@shared/types"; import BaseModel from "~/models/BaseModel"; import Field from "./decorators/Field"; -type Settings = { - url: string; - channel: string; - channelId: string; -}; - -class Integration extends BaseModel { +class Integration extends BaseModel { id: string; type: string; @@ -21,7 +16,7 @@ class Integration extends BaseModel { @observable events: string[]; - settings: Settings; + settings: IntegrationSettings; } export default Integration; diff --git a/app/scenes/Settings/Drawio.tsx b/app/scenes/Settings/Drawio.tsx new file mode 100644 index 0000000000..6fe5781856 --- /dev/null +++ b/app/scenes/Settings/Drawio.tsx @@ -0,0 +1,101 @@ +import { head } from "lodash"; +import { observer } from "mobx-react"; +import { BuildingBlocksIcon } from "outline-icons"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation, Trans } from "react-i18next"; +import { IntegrationType } from "@shared/types"; +import Integration from "~/models/Integration"; +import Button from "~/components/Button"; +import Heading from "~/components/Heading"; +import { ReactHookWrappedInput as Input } from "~/components/Input"; +import Scene from "~/components/Scene"; +import Text from "~/components/Text"; +import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; + +type FormData = { + url: string; +}; + +const SERVICE_NAME = "diagrams"; + +function Drawio() { + const { integrations } = useStores(); + const { t } = useTranslation(); + const { showToast } = useToasts(); + + React.useEffect(() => { + integrations.fetchPage({ + service: SERVICE_NAME, + type: IntegrationType.Embed, + }); + }, [integrations]); + + const integration = head(integrations.orderedData) as + | Integration + | undefined; + + const { register, handleSubmit: formHandleSubmit, formState } = useForm< + FormData + >({ + mode: "all", + defaultValues: { + url: integration?.settings.url, + }, + }); + + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + await integrations.save({ + id: integration?.id, + type: IntegrationType.Embed, + service: SERVICE_NAME, + settings: { + url: data.url, + }, + }); + + showToast(t("Settings saved"), { + type: "success", + }); + } catch (err) { + showToast(err.message, { + type: "error", + }); + } + }, + [integrations, integration, t, showToast] + ); + + return ( + }> + Draw.io + + + + Add your self-hosted draw.io installation url here to enable automatic + embedding of diagrams within documents. + +
+

+ + +

+
+
+
+ ); +} + +export default observer(Drawio); diff --git a/app/scenes/Settings/Slack.tsx b/app/scenes/Settings/Slack.tsx index 199140452a..c37f4bee7e 100644 --- a/app/scenes/Settings/Slack.tsx +++ b/app/scenes/Settings/Slack.tsx @@ -3,6 +3,7 @@ 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"; @@ -124,7 +125,9 @@ function Slack() { + } /> ); } diff --git a/app/scenes/Settings/components/SlackListItem.tsx b/app/scenes/Settings/components/SlackListItem.tsx index e9640fb5d4..f11c556f41 100644 --- a/app/scenes/Settings/components/SlackListItem.tsx +++ b/app/scenes/Settings/components/SlackListItem.tsx @@ -4,6 +4,7 @@ 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"; @@ -17,7 +18,7 @@ import Text from "~/components/Text"; import useToasts from "~/hooks/useToasts"; type Props = { - integration: Integration; + integration: Integration; collection: Collection; }; diff --git a/server/migrations/20220816070527-change-column-authentication-id-nullable.js b/server/migrations/20220816070527-change-column-authentication-id-nullable.js new file mode 100644 index 0000000000..05459756bc --- /dev/null +++ b/server/migrations/20220816070527-change-column-authentication-id-nullable.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.changeColumn("integrations", "authenticationId", { + type: Sequelize.UUID, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.changeColumn("integrations", "authenticationId", { + type: Sequelize.UUID, + allowNull: false, + }); + }, +}; diff --git a/server/models/Integration.ts b/server/models/Integration.ts index cd3ff95af6..1d15f96266 100644 --- a/server/models/Integration.ts +++ b/server/models/Integration.ts @@ -5,7 +5,10 @@ import { Table, DataType, Scopes, + IsIn, } from "sequelize-typescript"; +import { IntegrationType } from "@shared/types"; +import type { IntegrationSettings } from "@shared/types"; import Collection from "./Collection"; import IntegrationAuthentication from "./IntegrationAuthentication"; import Team from "./Team"; @@ -13,6 +16,15 @@ import User from "./User"; import IdModel from "./base/IdModel"; import Fix from "./decorators/Fix"; +export enum IntegrationService { + Diagrams = "diagrams", + Slack = "slack", +} + +export enum UserCreatableIntegrationService { + Diagrams = "diagrams", +} + @Scopes(() => ({ withAuthentication: { include: [ @@ -26,16 +38,19 @@ import Fix from "./decorators/Fix"; })) @Table({ tableName: "integrations", modelName: "integration" }) @Fix -class Integration extends IdModel { +class Integration extends IdModel { + @IsIn([Object.values(IntegrationType)]) @Column type: string; + @IsIn([Object.values(IntegrationService)]) @Column service: string; @Column(DataType.JSONB) - settings: Record; + settings: IntegrationSettings; + @IsIn([["documents.update", "documents.publish"]]) @Column(DataType.ARRAY(DataType.STRING)) events: string[]; diff --git a/server/queues/processors/SlackProcessor.ts b/server/queues/processors/SlackProcessor.ts index c33f5f7361..6537333e0c 100644 --- a/server/queues/processors/SlackProcessor.ts +++ b/server/queues/processors/SlackProcessor.ts @@ -1,5 +1,6 @@ import fetch from "fetch-with-proxy"; import { Op } from "sequelize"; +import { IntegrationType } from "@shared/types"; import env from "@server/env"; import { Document, Integration, Collection, Team } from "@server/models"; import { presentSlackAttachment } from "@server/presenters"; @@ -32,7 +33,7 @@ export default class SlackProcessor extends BaseProcessor { } async integrationCreated(event: IntegrationEvent) { - const integration = await Integration.findOne({ + const integration = (await Integration.findOne({ where: { id: event.modelId, service: "slack", @@ -45,7 +46,7 @@ export default class SlackProcessor extends BaseProcessor { as: "collection", }, ], - }); + })) as Integration; if (!integration) { return; } @@ -93,7 +94,7 @@ export default class SlackProcessor extends BaseProcessor { return; } - const integration = await Integration.findOne({ + const integration = (await Integration.findOne({ where: { teamId: document.teamId, collectionId: document.collectionId, @@ -105,7 +106,7 @@ export default class SlackProcessor extends BaseProcessor { ], }, }, - }); + })) as Integration; if (!integration) { return; } diff --git a/server/routes/api/integrations.ts b/server/routes/api/integrations.ts index a02a09b3d7..69e6d89937 100644 --- a/server/routes/api/integrations.ts +++ b/server/routes/api/integrations.ts @@ -1,10 +1,20 @@ import Router from "koa-router"; +import { has } from "lodash"; +import { IntegrationType } from "@shared/types"; import auth from "@server/middlewares/authentication"; import { Event } from "@server/models"; -import Integration from "@server/models/Integration"; +import Integration, { + UserCreatableIntegrationService, +} from "@server/models/Integration"; import { authorize } from "@server/policies"; import { presentIntegration } from "@server/presenters"; -import { assertSort, assertUuid, assertArray } from "@server/validation"; +import { + assertSort, + assertUuid, + assertArray, + assertIn, + assertUrl, +} from "@server/validation"; import pagination from "./middlewares/pagination"; const router = new Router(); @@ -33,8 +43,35 @@ router.post("integrations.list", auth(), pagination(), async (ctx) => { }; }); -router.post("integrations.update", auth(), async (ctx) => { - const { id, events } = ctx.body; +router.post("integrations.create", auth({ admin: true }), async (ctx) => { + const { type, service, settings } = ctx.body; + + assertIn(type, Object.values(IntegrationType)); + + const { user } = ctx.state; + authorize(user, "createIntegration", user.team); + + assertIn(service, Object.values(UserCreatableIntegrationService)); + + if (has(settings, "url")) { + assertUrl(settings.url); + } + + const integration = await Integration.create({ + userId: user.id, + teamId: user.teamId, + service, + settings, + type, + }); + + ctx.body = { + data: presentIntegration(integration), + }; +}); + +router.post("integrations.update", auth({ admin: true }), async (ctx) => { + const { id, events = [], settings } = ctx.body; assertUuid(id, "id is required"); const { user } = ctx.state; @@ -43,12 +80,18 @@ router.post("integrations.update", auth(), async (ctx) => { assertArray(events, "events must be an array"); - if (integration.type === "post") { + if (has(settings, "url")) { + assertUrl(settings.url); + } + + if (integration.type === IntegrationType.Post) { integration.events = events.filter((event: string) => ["documents.update", "documents.publish"].includes(event) ); } + integration.settings = settings; + await integration.save(); ctx.body = { @@ -56,7 +99,7 @@ router.post("integrations.update", auth(), async (ctx) => { }; }); -router.post("integrations.delete", auth(), async (ctx) => { +router.post("integrations.delete", auth({ admin: true }), async (ctx) => { const { id } = ctx.body; assertUuid(id, "id is required"); diff --git a/server/validation.ts b/server/validation.ts index 55973c5dd9..b3b9161219 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -50,6 +50,17 @@ export const assertEmail = (value = "", message?: string) => { } }; +export const assertUrl = (value = "", message?: string) => { + if ( + !validator.isURL(value, { + protocols: ["http", "https"], + require_valid_protocol: true, + }) + ) { + throw ValidationError(message ?? `${value} is an invalid url!`); + } +}; + export const assertUuid = (value: unknown, message?: string) => { if (typeof value !== "string") { throw ValidationError(message); diff --git a/shared/editor/embeds/Diagrams.tsx b/shared/editor/embeds/Diagrams.tsx index 060d867fe7..27793f720f 100644 --- a/shared/editor/embeds/Diagrams.tsx +++ b/shared/editor/embeds/Diagrams.tsx @@ -3,43 +3,38 @@ import Frame from "../components/Frame"; import Image from "../components/Image"; import { EmbedProps as Props } from "."; -const URL_REGEX = /^https:\/\/viewer\.diagrams\.net\/(?!proxy).*(title=\\w+)?/; +function Diagrams(props: Props) { + const { embed } = props; + const embedUrl = props.attrs.matches[0]; + const params = new URL(embedUrl).searchParams; + const titlePrefix = embed.settings?.url ? "Draw.io" : "Diagrams.net"; + const title = params.get("title") + ? `${titlePrefix} (${params.get("title")})` + : titlePrefix; -export default class Diagrams extends React.Component { - static ENABLED = [URL_REGEX]; - - get embedUrl() { - return this.props.attrs.matches[0]; - } - - get title() { - let title = "Diagrams.net"; - const url = new URL(this.embedUrl); - const documentTitle = url.searchParams.get("title"); - - if (documentTitle) { - title += ` (${documentTitle})`; - } - - return title; - } - - render() { - return ( - - } - /> - ); - } + return ( + + } + canonicalUrl={props.attrs.href} + title={title} + border + /> + ); } + +Diagrams.ENABLED = [ + /^https:\/\/viewer\.diagrams\.net\/(?!proxy).*(title=\\w+)?/, +]; + +Diagrams.URL_PATH_REGEX = /\/(?!proxy).*(title=\\w+)?/; + +export default Diagrams; diff --git a/shared/editor/embeds/index.tsx b/shared/editor/embeds/index.tsx index 2538ab7828..5b8b6472b0 100644 --- a/shared/editor/embeds/index.tsx +++ b/shared/editor/embeds/index.tsx @@ -1,6 +1,9 @@ +import { EditorState } from "prosemirror-state"; import * as React from "react"; import styled from "styled-components"; -import { EmbedDescriptor } from "@shared/editor/types"; +import { IntegrationType } from "../../types"; +import type { IntegrationSettings } from "../../types"; +import { urlRegex } from "../../utils/urls"; import Image from "../components/Image"; import Abstract from "./Abstract"; import Airtable from "./Airtable"; @@ -55,10 +58,57 @@ export type EmbedProps = { }; }; -function matcher(Component: React.ComponentType) { - return (url: string): boolean | [] | RegExpMatchArray => { +const Img = styled(Image)` + border-radius: 2px; + background: #fff; + box-shadow: 0 0 0 1px #fff; + margin: 4px; + width: 18px; + height: 18px; +`; + +export class EmbedDescriptor { + icon: React.FC; + name?: string; + title?: string; + shortcut?: string; + keywords?: string; + tooltip?: string; + defaultHidden?: boolean; + attrs?: Record; + visible?: boolean; + active?: (state: EditorState) => boolean; + component: typeof React.Component | React.FC; + settings?: IntegrationSettings; + + constructor(options: Omit) { + this.icon = options.icon; + this.name = options.name; + this.title = options.title; + this.shortcut = options.shortcut; + this.keywords = options.keywords; + this.tooltip = options.tooltip; + this.defaultHidden = options.defaultHidden; + this.attrs = options.attrs; + this.visible = options.visible; + this.active = options.active; + this.component = options.component; + this.settings = options.settings; + } + + matcher(url: string): boolean | [] | RegExpMatchArray { + const regex = urlRegex(this.settings?.url); + // @ts-expect-error not aware of static - const regexes = Component.ENABLED; + const regexes = this.component.ENABLED; + + regex && + regexes.unshift( + new RegExp( + // @ts-expect-error not aware of static + `^${regex.source}${this.component.URL_PATH_REGEX.source}$` + ) + ); for (const regex of regexes) { const result = url.match(regex); @@ -69,324 +119,273 @@ function matcher(Component: React.ComponentType) { } return false; - }; + } } -const Img = styled(Image)` - border-radius: 2px; - background: #fff; - box-shadow: 0 0 0 1px #fff; - margin: 4px; - width: 18px; - height: 18px; -`; - const embeds: EmbedDescriptor[] = [ - { + new EmbedDescriptor({ title: "Abstract", keywords: "design", defaultHidden: true, icon: () => Abstract, component: Abstract, - matcher: matcher(Abstract), - }, - { + }), + new EmbedDescriptor({ title: "Airtable", keywords: "spreadsheet", icon: () => Airtable, component: Airtable, - matcher: matcher(Airtable), - }, - { + }), + new EmbedDescriptor({ title: "Berrycast", keywords: "video", defaultHidden: true, icon: () => Berrycast, component: Berrycast, - matcher: matcher(Berrycast), - }, - { + }), + new EmbedDescriptor({ title: "Bilibili", keywords: "video", defaultHidden: true, icon: () => Bilibili, component: Bilibili, - matcher: matcher(Bilibili), - }, - { + }), + new EmbedDescriptor({ title: "Cawemo", keywords: "bpmn process", defaultHidden: true, icon: () => Cawemo, component: Cawemo, - matcher: matcher(Cawemo), - }, - { + }), + new EmbedDescriptor({ title: "ClickUp", keywords: "project", icon: () => ClickUp, component: ClickUp, - matcher: matcher(ClickUp), - }, - { + }), + new EmbedDescriptor({ title: "Codepen", keywords: "code editor", icon: () => Codepen, component: Codepen, - matcher: matcher(Codepen), - }, - { + }), + new EmbedDescriptor({ title: "DBDiagram", keywords: "diagrams database", icon: () => DBDiagram, component: DBDiagram, - matcher: matcher(DBDiagram), - }, - { + }), + new EmbedDescriptor({ title: "Descript", keywords: "audio", icon: () => Descript, component: Descript, - matcher: matcher(Descript), - }, - { + }), + new EmbedDescriptor({ title: "Figma", keywords: "design svg vector", icon: () => Figma, component: Figma, - matcher: matcher(Figma), - }, - { + }), + new EmbedDescriptor({ title: "Framer", keywords: "design prototyping", icon: () => Framer, component: Framer, - matcher: matcher(Framer), - }, - { + }), + new EmbedDescriptor({ title: "GitHub Gist", keywords: "code", icon: () => GitHub, component: Gist, - matcher: matcher(Gist), - }, - { + }), + new EmbedDescriptor({ title: "Gliffy", keywords: "diagram", icon: () => Gliffy, component: Gliffy, - matcher: matcher(Gliffy), - }, - { + }), + new EmbedDescriptor({ title: "Diagrams.net", keywords: "diagrams drawio", icon: () => Diagrams.net, component: Diagrams, - matcher: matcher(Diagrams), - }, - { + }), + new EmbedDescriptor({ title: "Google Drawings", keywords: "drawings", icon: () => Google Drawings, component: GoogleDrawings, - matcher: matcher(GoogleDrawings), - }, - { + }), + new EmbedDescriptor({ title: "Google Drive", keywords: "drive", icon: () => Google Drive, component: GoogleDrive, - matcher: matcher(GoogleDrive), - }, - { + }), + new EmbedDescriptor({ title: "Google Docs", keywords: "documents word", icon: () => Google Docs, component: GoogleDocs, - matcher: matcher(GoogleDocs), - }, - { + }), + new EmbedDescriptor({ title: "Google Sheets", keywords: "excel spreadsheet", icon: () => Google Sheets, component: GoogleSheets, - matcher: matcher(GoogleSheets), - }, - { + }), + new EmbedDescriptor({ title: "Google Slides", keywords: "presentation slideshow", icon: () => Google Slides, component: GoogleSlides, - matcher: matcher(GoogleSlides), - }, - { + }), + new EmbedDescriptor({ title: "Google Calendar", keywords: "calendar", icon: () => Google Calendar, component: GoogleCalendar, - matcher: matcher(GoogleCalendar), - }, - { + }), + new EmbedDescriptor({ title: "Google Data Studio", keywords: "bi business intelligence", icon: () => ( Google Data Studio ), component: GoogleDataStudio, - matcher: matcher(GoogleDataStudio), - }, - { + }), + new EmbedDescriptor({ title: "Google Forms", keywords: "form survey", icon: () => Google Forms, component: GoogleForms, - matcher: matcher(GoogleForms), - }, - { + }), + new EmbedDescriptor({ title: "Grist", keywords: "spreadsheet", icon: () => Grist, component: Grist, - matcher: matcher(Grist), - }, - { + }), + new EmbedDescriptor({ title: "InVision", keywords: "design prototype", defaultHidden: true, icon: () => InVision, component: InVision, - matcher: matcher(InVision), - }, - { + }), + new EmbedDescriptor({ title: "JSFiddle", keywords: "code", defaultHidden: true, icon: () => JSFiddle, component: JSFiddle, - matcher: matcher(JSFiddle), - }, - { + }), + new EmbedDescriptor({ title: "Loom", keywords: "video screencast", icon: () => Loom, component: Loom, - matcher: matcher(Loom), - }, - { + }), + new EmbedDescriptor({ title: "Lucidchart", keywords: "chart", icon: () => Lucidchart, component: Lucidchart, - matcher: matcher(Lucidchart), - }, - { + }), + new EmbedDescriptor({ title: "Marvel", keywords: "design prototype", icon: () => Marvel, component: Marvel, - matcher: matcher(Marvel), - }, - { + }), + new EmbedDescriptor({ title: "Mindmeister", keywords: "mindmap", icon: () => Mindmeister, component: Mindmeister, - matcher: matcher(Mindmeister), - }, - { + }), + new EmbedDescriptor({ title: "Miro", keywords: "whiteboard", icon: () => Miro, component: Miro, - matcher: matcher(Miro), - }, - { + }), + new EmbedDescriptor({ title: "Mode", keywords: "analytics", defaultHidden: true, icon: () => Mode, component: ModeAnalytics, - matcher: matcher(ModeAnalytics), - }, - { + }), + new EmbedDescriptor({ title: "Otter.ai", keywords: "audio transcription meeting notes", defaultHidden: true, icon: () => Otter.ai, component: Otter, - matcher: matcher(Otter), - }, - { + }), + new EmbedDescriptor({ title: "Pitch", keywords: "presentation", defaultHidden: true, icon: () => Pitch, component: Pitch, - matcher: matcher(Pitch), - }, - { + }), + new EmbedDescriptor({ title: "Prezi", keywords: "presentation", icon: () => Prezi, component: Prezi, - matcher: matcher(Prezi), - }, - { + }), + new EmbedDescriptor({ title: "Scribe", keywords: "screencast", icon: () => Scribe, component: Scribe, - matcher: matcher(Scribe), - }, - { + }), + new EmbedDescriptor({ title: "Spotify", keywords: "music", icon: () => Spotify, component: Spotify, - matcher: matcher(Spotify), - }, - { + }), + new EmbedDescriptor({ title: "Tldraw", keywords: "draw schematics diagrams", icon: () => Tldraw, component: Tldraw, - matcher: matcher(Tldraw), - }, - { + }), + new EmbedDescriptor({ title: "Trello", keywords: "kanban", icon: () => Trello, component: Trello, - matcher: matcher(Trello), - }, - { + }), + new EmbedDescriptor({ title: "Typeform", keywords: "form survey", icon: () => Typeform, component: Typeform, - matcher: matcher(Typeform), - }, - { + }), + new EmbedDescriptor({ title: "Vimeo", keywords: "video", icon: () => Vimeo, component: Vimeo, - matcher: matcher(Vimeo), - }, - { + }), + new EmbedDescriptor({ title: "Whimsical", keywords: "whiteboard", icon: () => Whimsical, component: Whimsical, - matcher: matcher(Whimsical), - }, - { + }), + new EmbedDescriptor({ title: "YouTube", keywords: "google video", icon: () => YouTube, component: YouTube, - matcher: matcher(YouTube), - }, + }), ]; export default embeds; diff --git a/shared/editor/lib/filterExcessSeparators.ts b/shared/editor/lib/filterExcessSeparators.ts index e599ccd9f2..0f92e9131e 100644 --- a/shared/editor/lib/filterExcessSeparators.ts +++ b/shared/editor/lib/filterExcessSeparators.ts @@ -1,4 +1,5 @@ -import { EmbedDescriptor, MenuItem } from "../types"; +import { EmbedDescriptor } from "../embeds"; +import { MenuItem } from "../types"; type Item = MenuItem | EmbedDescriptor; diff --git a/shared/editor/nodes/Embed.tsx b/shared/editor/nodes/Embed.tsx index 7bc391ee9f..1461ef666b 100644 --- a/shared/editor/nodes/Embed.tsx +++ b/shared/editor/nodes/Embed.tsx @@ -107,6 +107,7 @@ export default class Embed extends Node { attrs={{ ...node.attrs, matches }} isEditable={isEditable} isSelected={isSelected} + embed={embed} theme={theme} /> ); diff --git a/shared/editor/rules/embeds.ts b/shared/editor/rules/embeds.ts index 11c43837e0..79ebc5b631 100644 --- a/shared/editor/rules/embeds.ts +++ b/shared/editor/rules/embeds.ts @@ -1,6 +1,6 @@ import MarkdownIt from "markdown-it"; import Token from "markdown-it/lib/token"; -import { EmbedDescriptor } from "../types"; +import { EmbedDescriptor } from "../embeds"; function isParagraph(token: Token) { return token.type === "paragraph_open"; diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 88f1743ee1..0f6bf5f77d 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -27,12 +27,6 @@ export type MenuItem = { active?: (state: EditorState) => boolean; }; -export type EmbedDescriptor = MenuItem & { - icon: React.FC; - matcher: (url: string) => boolean | [] | RegExpMatchArray; - component: typeof React.Component | React.FC; -}; - export type ComponentProps = { theme: DefaultTheme; node: ProsemirrorNode; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 8015d35436..e65a2f84e9 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -202,6 +202,7 @@ "Export": "Export", "Webhooks": "Webhooks", "Integrations": "Integrations", + "Draw.io": "Draw.io", "Insert column after": "Insert column after", "Insert column before": "Insert column before", "Insert row after": "Insert row after", @@ -623,6 +624,8 @@ "Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.", "Start view": "Start view", "This is the screen that team members will first see when they sign in.": "This is the screen that team members will first see when they sign in.", + "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.", + "Draw.io deployment": "Draw.io deployment", "Export in progress…": "Export in progress…", "Export deleted": "Export deleted", "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to {{ userEmail }} when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to {{ userEmail }} when it’s complete.", diff --git a/shared/types.ts b/shared/types.ts index 86514bf177..0b68ed2c08 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -21,3 +21,20 @@ export type PublicEnv = { GOOGLE_ANALYTICS_ID: string | undefined; RELEASE: string | undefined; }; + +export enum IntegrationType { + Post = "post", + Command = "command", + Embed = "embed", +} + +export type IntegrationSettings = T extends IntegrationType.Embed + ? { url: string } + : T extends IntegrationType.Post + ? { url: string; channel: string; channelId: string } + : T extends IntegrationType.Post + ? { serviceTeamId: string } + : + | { url: string } + | { url: string; channel: string; channelId: string } + | { serviceTeamId: string }; diff --git a/shared/utils/urls.test.ts b/shared/utils/urls.test.ts new file mode 100644 index 0000000000..f8b27ea7c7 --- /dev/null +++ b/shared/utils/urls.test.ts @@ -0,0 +1,14 @@ +import { urlRegex } from "./urls"; + +describe("#urlRegex", () => { + it("should return undefined for invalid urls", () => { + expect(urlRegex(undefined)).toBeUndefined(); + expect(urlRegex(null)).toBeUndefined(); + expect(urlRegex("invalid url!")).toBeUndefined(); + }); + + it("should return corresponding regex otherwise", () => { + const regex = urlRegex("https://docs.google.com"); + expect(regex?.source).toBe(/https:\/\/docs\.google\.com/.source); + }); +}); diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index 06e5bc5d19..337d47725b 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -1,3 +1,4 @@ +import { escapeRegExp } from "lodash"; import env from "../env"; import { parseDomain } from "./domains"; @@ -92,3 +93,13 @@ export function sanitizeUrl(url: string | null | undefined) { } return url; } + +export function urlRegex(url: string | null | undefined): RegExp | undefined { + if (!url || !isUrl(url)) { + return undefined; + } + + const urlObj = new URL(sanitizeUrl(url) as string); + + return new RegExp(escapeRegExp(`${urlObj.protocol}//${urlObj.host}`)); +}