feat: Support embed configuration (#3980)

* wip

* stash

* fix: make authenticationId nullable fk

* fix: apply generics to resolve compile time type errors

* fix: loosen integration settings

* chore: refactor into functional component

* feat: pass integrations all the way to embeds

* perf: avoid re-fetching integrations

* fix: change attr name to avoid type overlap

* feat: use hostname from embed settings in matcher

* Revert "feat: use hostname from embed settings in matcher"

This reverts commit e7485d9cda4dcf45104e460465ca104a56c67ddc.

* feat: refactor  into a class

* chore: refactor url regex formation as a util

* fix: escape regex special chars

* fix: remove in-house escapeRegExp in favor of lodash's

* fix: sanitize url

* perf: memoize embeds

* fix: rename hostname to url and allow spreading entire settings instead of just url

* fix: replace diagrams with drawio

* fix: rename

* fix: support self-hosted and saas both

* fix: assert on settings url

* fix: move embed integrations loading to hook

* fix: address review comments

* fix: use observer in favor of explicit state setters

* fix: refactor useEmbedIntegrations into useEmbeds

* fix: use translations for toasts

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2022-08-26 12:21:46 +05:30
committed by GitHub
parent 24c71c38a5
commit 4dbad4e46c
24 changed files with 499 additions and 216 deletions

View File

@@ -1,12 +1,12 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash"; import { deburr, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state"; import { TextSelection } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import { mergeRefs } from "react-merge-refs"; import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types"; import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles"; import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings"; import { Heading } from "@shared/editor/lib/getHeadings";
import { getDataTransferFiles } from "@shared/utils/files"; import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
@@ -18,6 +18,7 @@ import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview"; import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useDictionary from "~/hooks/useDictionary"; import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors"; import { NotFoundError } from "~/utils/errors";
@@ -58,6 +59,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { documents } = useStores(); const { documents } = useStores();
const { showToast } = useToasts(); const { showToast } = useToasts();
const dictionary = useDictionary(); const dictionary = useDictionary();
const embeds = useEmbeds();
const [ const [
activeLinkEvent, activeLinkEvent,
setActiveLinkEvent, setActiveLinkEvent,
@@ -310,4 +312,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
); );
} }
export default React.forwardRef(Editor); export default observer(React.forwardRef(Editor));

View File

@@ -7,9 +7,10 @@ import { Portal } from "react-portal";
import { VisuallyHidden } from "reakit/VisuallyHidden"; import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components"; import styled from "styled-components";
import insertFiles from "@shared/editor/commands/insertFiles"; import insertFiles from "@shared/editor/commands/insertFiles";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { CommandFactory } from "@shared/editor/lib/Extension"; import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; 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 { depths } from "@shared/styles";
import { getEventFiles } from "@shared/utils/files"; import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations"; import { AttachmentValidation } from "@shared/validations";
@@ -427,10 +428,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
for (const embed of embeds) { for (const embed of embeds) {
if (embed.title) { if (embed.title) {
embedItems.push({ embedItems.push(
...embed, new EmbedDescriptor({
name: "embed", ...embed,
}); name: "embed",
})
);
} }
} }

View File

@@ -16,6 +16,7 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
import { Decoration, EditorView } from "prosemirror-view"; import { Decoration, EditorView } from "prosemirror-view";
import * as React from "react"; import * as React from "react";
import { DefaultTheme, ThemeProps } from "styled-components"; import { DefaultTheme, ThemeProps } from "styled-components";
import { EmbedDescriptor } from "@shared/editor/embeds";
import Extension, { CommandFactory } from "@shared/editor/lib/Extension"; import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
import ExtensionManager from "@shared/editor/lib/ExtensionManager"; import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import getHeadings from "@shared/editor/lib/getHeadings"; 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 Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode"; import ReactNode from "@shared/editor/nodes/ReactNode";
import fullExtensionsPackage from "@shared/editor/packages/full"; 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 EventEmitter from "@shared/utils/events";
import Integration from "~/models/Integration";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
import Logger from "~/utils/Logger"; import Logger from "~/utils/Logger";
@@ -110,6 +113,8 @@ export type Props = {
onShowToast: (message: string) => void; onShowToast: (message: string) => void;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
embedIntegrations?: Integration<IntegrationType.Embed>[];
}; };
type State = { type State = {

View File

@@ -9,12 +9,14 @@ import {
LinkIcon, LinkIcon,
TeamIcon, TeamIcon,
BeakerIcon, BeakerIcon,
BuildingBlocksIcon,
DownloadIcon, DownloadIcon,
WebhooksIcon, WebhooksIcon,
} from "outline-icons"; } from "outline-icons";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Details from "~/scenes/Settings/Details"; import Details from "~/scenes/Settings/Details";
import Drawio from "~/scenes/Settings/Drawio";
import Export from "~/scenes/Settings/Export"; import Export from "~/scenes/Settings/Export";
import Features from "~/scenes/Settings/Features"; import Features from "~/scenes/Settings/Features";
import Groups from "~/scenes/Settings/Groups"; import Groups from "~/scenes/Settings/Groups";
@@ -170,6 +172,14 @@ const useAuthorizedSettingsConfig = () => {
group: t("Integrations"), group: t("Integrations"),
icon: WebhooksIcon, icon: WebhooksIcon,
}, },
Drawio: {
name: t("Draw.io"),
path: "/settings/integrations/drawio",
component: Drawio,
enabled: can.update,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
Slack: { Slack: {
name: "Slack", name: "Slack",
path: "/settings/integrations/slack", path: "/settings/integrations/slack",

41
app/hooks/useEmbeds.ts Normal file
View File

@@ -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<IntegrationType.Embed> | undefined = find(
integrations.orderedData,
(i) => i.service === e.component.name.toLowerCase()
);
return new EmbedDescriptor({
...e,
settings: em?.settings,
});
}),
[integrations.orderedData]
);
}

View File

@@ -1,14 +1,9 @@
import { observable } from "mobx"; import { observable } from "mobx";
import type { IntegrationSettings } from "@shared/types";
import BaseModel from "~/models/BaseModel"; import BaseModel from "~/models/BaseModel";
import Field from "./decorators/Field"; import Field from "./decorators/Field";
type Settings = { class Integration<T = unknown> extends BaseModel {
url: string;
channel: string;
channelId: string;
};
class Integration extends BaseModel {
id: string; id: string;
type: string; type: string;
@@ -21,7 +16,7 @@ class Integration extends BaseModel {
@observable @observable
events: string[]; events: string[];
settings: Settings; settings: IntegrationSettings<T>;
} }
export default Integration; export default Integration;

View File

@@ -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<IntegrationType.Embed>
| 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 (
<Scene title="Draw.io" icon={<BuildingBlocksIcon color="currentColor" />}>
<Heading>Draw.io</Heading>
<Text type="secondary">
<Trans>
Add your self-hosted draw.io installation url here to enable automatic
embedding of diagrams within documents.
</Trans>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<p>
<Input
label={t("Draw.io deployment")}
placeholder={"https://app.diagrams.net/"}
pattern="https?://.*"
{...register("url", {
required: true,
})}
/>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</p>
</form>
</Text>
</Scene>
);
}
export default observer(Drawio);

View File

@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Integration from "~/models/Integration"; import Integration from "~/models/Integration";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -124,7 +125,9 @@ function Slack() {
<SlackListItem <SlackListItem
key={integration.id} key={integration.id}
collection={collection} collection={collection}
integration={integration} integration={
integration as Integration<IntegrationType.Post>
}
/> />
); );
} }

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components"; import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import Integration from "~/models/Integration"; import Integration from "~/models/Integration";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -17,7 +18,7 @@ import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
type Props = { type Props = {
integration: Integration; integration: Integration<IntegrationType.Post>;
collection: Collection; collection: Collection;
}; };

View File

@@ -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,
});
},
};

View File

@@ -5,7 +5,10 @@ import {
Table, Table,
DataType, DataType,
Scopes, Scopes,
IsIn,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { IntegrationType } from "@shared/types";
import type { IntegrationSettings } from "@shared/types";
import Collection from "./Collection"; import Collection from "./Collection";
import IntegrationAuthentication from "./IntegrationAuthentication"; import IntegrationAuthentication from "./IntegrationAuthentication";
import Team from "./Team"; import Team from "./Team";
@@ -13,6 +16,15 @@ import User from "./User";
import IdModel from "./base/IdModel"; import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
export enum IntegrationService {
Diagrams = "diagrams",
Slack = "slack",
}
export enum UserCreatableIntegrationService {
Diagrams = "diagrams",
}
@Scopes(() => ({ @Scopes(() => ({
withAuthentication: { withAuthentication: {
include: [ include: [
@@ -26,16 +38,19 @@ import Fix from "./decorators/Fix";
})) }))
@Table({ tableName: "integrations", modelName: "integration" }) @Table({ tableName: "integrations", modelName: "integration" })
@Fix @Fix
class Integration extends IdModel { class Integration<T = unknown> extends IdModel {
@IsIn([Object.values(IntegrationType)])
@Column @Column
type: string; type: string;
@IsIn([Object.values(IntegrationService)])
@Column @Column
service: string; service: string;
@Column(DataType.JSONB) @Column(DataType.JSONB)
settings: Record<string, any>; settings: IntegrationSettings<T>;
@IsIn([["documents.update", "documents.publish"]])
@Column(DataType.ARRAY(DataType.STRING)) @Column(DataType.ARRAY(DataType.STRING))
events: string[]; events: string[];

View File

@@ -1,5 +1,6 @@
import fetch from "fetch-with-proxy"; import fetch from "fetch-with-proxy";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { IntegrationType } from "@shared/types";
import env from "@server/env"; import env from "@server/env";
import { Document, Integration, Collection, Team } from "@server/models"; import { Document, Integration, Collection, Team } from "@server/models";
import { presentSlackAttachment } from "@server/presenters"; import { presentSlackAttachment } from "@server/presenters";
@@ -32,7 +33,7 @@ export default class SlackProcessor extends BaseProcessor {
} }
async integrationCreated(event: IntegrationEvent) { async integrationCreated(event: IntegrationEvent) {
const integration = await Integration.findOne({ const integration = (await Integration.findOne({
where: { where: {
id: event.modelId, id: event.modelId,
service: "slack", service: "slack",
@@ -45,7 +46,7 @@ export default class SlackProcessor extends BaseProcessor {
as: "collection", as: "collection",
}, },
], ],
}); })) as Integration<IntegrationType.Post>;
if (!integration) { if (!integration) {
return; return;
} }
@@ -93,7 +94,7 @@ export default class SlackProcessor extends BaseProcessor {
return; return;
} }
const integration = await Integration.findOne({ const integration = (await Integration.findOne({
where: { where: {
teamId: document.teamId, teamId: document.teamId,
collectionId: document.collectionId, collectionId: document.collectionId,
@@ -105,7 +106,7 @@ export default class SlackProcessor extends BaseProcessor {
], ],
}, },
}, },
}); })) as Integration<IntegrationType.Post>;
if (!integration) { if (!integration) {
return; return;
} }

View File

@@ -1,10 +1,20 @@
import Router from "koa-router"; import Router from "koa-router";
import { has } from "lodash";
import { IntegrationType } from "@shared/types";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { Event } from "@server/models"; import { Event } from "@server/models";
import Integration from "@server/models/Integration"; import Integration, {
UserCreatableIntegrationService,
} from "@server/models/Integration";
import { authorize } from "@server/policies"; import { authorize } from "@server/policies";
import { presentIntegration } from "@server/presenters"; 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"; import pagination from "./middlewares/pagination";
const router = new Router(); const router = new Router();
@@ -33,8 +43,35 @@ router.post("integrations.list", auth(), pagination(), async (ctx) => {
}; };
}); });
router.post("integrations.update", auth(), async (ctx) => { router.post("integrations.create", auth({ admin: true }), async (ctx) => {
const { id, events } = ctx.body; 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"); assertUuid(id, "id is required");
const { user } = ctx.state; const { user } = ctx.state;
@@ -43,12 +80,18 @@ router.post("integrations.update", auth(), async (ctx) => {
assertArray(events, "events must be an array"); 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) => integration.events = events.filter((event: string) =>
["documents.update", "documents.publish"].includes(event) ["documents.update", "documents.publish"].includes(event)
); );
} }
integration.settings = settings;
await integration.save(); await integration.save();
ctx.body = { 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; const { id } = ctx.body;
assertUuid(id, "id is required"); assertUuid(id, "id is required");

View File

@@ -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) => { export const assertUuid = (value: unknown, message?: string) => {
if (typeof value !== "string") { if (typeof value !== "string") {
throw ValidationError(message); throw ValidationError(message);

View File

@@ -3,43 +3,38 @@ import Frame from "../components/Frame";
import Image from "../components/Image"; import Image from "../components/Image";
import { EmbedProps as Props } from "."; 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<Props> { return (
static ENABLED = [URL_REGEX]; <Frame
{...props}
get embedUrl() { src={embedUrl}
return this.props.attrs.matches[0]; icon={
} <Image
src="/images/diagrams.png"
get title() { alt="Diagrams.net"
let title = "Diagrams.net"; width={16}
const url = new URL(this.embedUrl); height={16}
const documentTitle = url.searchParams.get("title"); />
}
if (documentTitle) { canonicalUrl={props.attrs.href}
title += ` (${documentTitle})`; title={title}
} border
/>
return title; );
}
render() {
return (
<Frame
{...this.props}
src={this.embedUrl}
title={this.title}
border
icon={
<Image
src="/images/diagrams.png"
alt="Diagrams.net"
width={16}
height={16}
/>
}
/>
);
}
} }
Diagrams.ENABLED = [
/^https:\/\/viewer\.diagrams\.net\/(?!proxy).*(title=\\w+)?/,
];
Diagrams.URL_PATH_REGEX = /\/(?!proxy).*(title=\\w+)?/;
export default Diagrams;

View File

@@ -1,6 +1,9 @@
import { EditorState } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; 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 Image from "../components/Image";
import Abstract from "./Abstract"; import Abstract from "./Abstract";
import Airtable from "./Airtable"; import Airtable from "./Airtable";
@@ -55,10 +58,57 @@ export type EmbedProps = {
}; };
}; };
function matcher(Component: React.ComponentType<EmbedProps>) { const Img = styled(Image)`
return (url: string): boolean | [] | RegExpMatchArray => { border-radius: 2px;
background: #fff;
box-shadow: 0 0 0 1px #fff;
margin: 4px;
width: 18px;
height: 18px;
`;
export class EmbedDescriptor {
icon: React.FC<any>;
name?: string;
title?: string;
shortcut?: string;
keywords?: string;
tooltip?: string;
defaultHidden?: boolean;
attrs?: Record<string, any>;
visible?: boolean;
active?: (state: EditorState) => boolean;
component: typeof React.Component | React.FC<any>;
settings?: IntegrationSettings<IntegrationType.Embed>;
constructor(options: Omit<EmbedDescriptor, "matcher">) {
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 // @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) { for (const regex of regexes) {
const result = url.match(regex); const result = url.match(regex);
@@ -69,324 +119,273 @@ function matcher(Component: React.ComponentType<EmbedProps>) {
} }
return false; 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[] = [ const embeds: EmbedDescriptor[] = [
{ new EmbedDescriptor({
title: "Abstract", title: "Abstract",
keywords: "design", keywords: "design",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/abstract.png" alt="Abstract" />, icon: () => <Img src="/images/abstract.png" alt="Abstract" />,
component: Abstract, component: Abstract,
matcher: matcher(Abstract), }),
}, new EmbedDescriptor({
{
title: "Airtable", title: "Airtable",
keywords: "spreadsheet", keywords: "spreadsheet",
icon: () => <Img src="/images/airtable.png" alt="Airtable" />, icon: () => <Img src="/images/airtable.png" alt="Airtable" />,
component: Airtable, component: Airtable,
matcher: matcher(Airtable), }),
}, new EmbedDescriptor({
{
title: "Berrycast", title: "Berrycast",
keywords: "video", keywords: "video",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/berrycast.png" alt="Berrycast" />, icon: () => <Img src="/images/berrycast.png" alt="Berrycast" />,
component: Berrycast, component: Berrycast,
matcher: matcher(Berrycast), }),
}, new EmbedDescriptor({
{
title: "Bilibili", title: "Bilibili",
keywords: "video", keywords: "video",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/bilibili.png" alt="Bilibili" />, icon: () => <Img src="/images/bilibili.png" alt="Bilibili" />,
component: Bilibili, component: Bilibili,
matcher: matcher(Bilibili), }),
}, new EmbedDescriptor({
{
title: "Cawemo", title: "Cawemo",
keywords: "bpmn process", keywords: "bpmn process",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/cawemo.png" alt="Cawemo" />, icon: () => <Img src="/images/cawemo.png" alt="Cawemo" />,
component: Cawemo, component: Cawemo,
matcher: matcher(Cawemo), }),
}, new EmbedDescriptor({
{
title: "ClickUp", title: "ClickUp",
keywords: "project", keywords: "project",
icon: () => <Img src="/images/clickup.png" alt="ClickUp" />, icon: () => <Img src="/images/clickup.png" alt="ClickUp" />,
component: ClickUp, component: ClickUp,
matcher: matcher(ClickUp), }),
}, new EmbedDescriptor({
{
title: "Codepen", title: "Codepen",
keywords: "code editor", keywords: "code editor",
icon: () => <Img src="/images/codepen.png" alt="Codepen" />, icon: () => <Img src="/images/codepen.png" alt="Codepen" />,
component: Codepen, component: Codepen,
matcher: matcher(Codepen), }),
}, new EmbedDescriptor({
{
title: "DBDiagram", title: "DBDiagram",
keywords: "diagrams database", keywords: "diagrams database",
icon: () => <Img src="/images/dbdiagram.png" alt="DBDiagram" />, icon: () => <Img src="/images/dbdiagram.png" alt="DBDiagram" />,
component: DBDiagram, component: DBDiagram,
matcher: matcher(DBDiagram), }),
}, new EmbedDescriptor({
{
title: "Descript", title: "Descript",
keywords: "audio", keywords: "audio",
icon: () => <Img src="/images/descript.png" alt="Descript" />, icon: () => <Img src="/images/descript.png" alt="Descript" />,
component: Descript, component: Descript,
matcher: matcher(Descript), }),
}, new EmbedDescriptor({
{
title: "Figma", title: "Figma",
keywords: "design svg vector", keywords: "design svg vector",
icon: () => <Img src="/images/figma.png" alt="Figma" />, icon: () => <Img src="/images/figma.png" alt="Figma" />,
component: Figma, component: Figma,
matcher: matcher(Figma), }),
}, new EmbedDescriptor({
{
title: "Framer", title: "Framer",
keywords: "design prototyping", keywords: "design prototyping",
icon: () => <Img src="/images/framer.png" alt="Framer" />, icon: () => <Img src="/images/framer.png" alt="Framer" />,
component: Framer, component: Framer,
matcher: matcher(Framer), }),
}, new EmbedDescriptor({
{
title: "GitHub Gist", title: "GitHub Gist",
keywords: "code", keywords: "code",
icon: () => <Img src="/images/github-gist.png" alt="GitHub" />, icon: () => <Img src="/images/github-gist.png" alt="GitHub" />,
component: Gist, component: Gist,
matcher: matcher(Gist), }),
}, new EmbedDescriptor({
{
title: "Gliffy", title: "Gliffy",
keywords: "diagram", keywords: "diagram",
icon: () => <Img src="/images/gliffy.png" alt="Gliffy" />, icon: () => <Img src="/images/gliffy.png" alt="Gliffy" />,
component: Gliffy, component: Gliffy,
matcher: matcher(Gliffy), }),
}, new EmbedDescriptor({
{
title: "Diagrams.net", title: "Diagrams.net",
keywords: "diagrams drawio", keywords: "diagrams drawio",
icon: () => <Img src="/images/diagrams.png" alt="Diagrams.net" />, icon: () => <Img src="/images/diagrams.png" alt="Diagrams.net" />,
component: Diagrams, component: Diagrams,
matcher: matcher(Diagrams), }),
}, new EmbedDescriptor({
{
title: "Google Drawings", title: "Google Drawings",
keywords: "drawings", keywords: "drawings",
icon: () => <Img src="/images/google-drawings.png" alt="Google Drawings" />, icon: () => <Img src="/images/google-drawings.png" alt="Google Drawings" />,
component: GoogleDrawings, component: GoogleDrawings,
matcher: matcher(GoogleDrawings), }),
}, new EmbedDescriptor({
{
title: "Google Drive", title: "Google Drive",
keywords: "drive", keywords: "drive",
icon: () => <Img src="/images/google-drive.png" alt="Google Drive" />, icon: () => <Img src="/images/google-drive.png" alt="Google Drive" />,
component: GoogleDrive, component: GoogleDrive,
matcher: matcher(GoogleDrive), }),
}, new EmbedDescriptor({
{
title: "Google Docs", title: "Google Docs",
keywords: "documents word", keywords: "documents word",
icon: () => <Img src="/images/google-docs.png" alt="Google Docs" />, icon: () => <Img src="/images/google-docs.png" alt="Google Docs" />,
component: GoogleDocs, component: GoogleDocs,
matcher: matcher(GoogleDocs), }),
}, new EmbedDescriptor({
{
title: "Google Sheets", title: "Google Sheets",
keywords: "excel spreadsheet", keywords: "excel spreadsheet",
icon: () => <Img src="/images/google-sheets.png" alt="Google Sheets" />, icon: () => <Img src="/images/google-sheets.png" alt="Google Sheets" />,
component: GoogleSheets, component: GoogleSheets,
matcher: matcher(GoogleSheets), }),
}, new EmbedDescriptor({
{
title: "Google Slides", title: "Google Slides",
keywords: "presentation slideshow", keywords: "presentation slideshow",
icon: () => <Img src="/images/google-slides.png" alt="Google Slides" />, icon: () => <Img src="/images/google-slides.png" alt="Google Slides" />,
component: GoogleSlides, component: GoogleSlides,
matcher: matcher(GoogleSlides), }),
}, new EmbedDescriptor({
{
title: "Google Calendar", title: "Google Calendar",
keywords: "calendar", keywords: "calendar",
icon: () => <Img src="/images/google-calendar.png" alt="Google Calendar" />, icon: () => <Img src="/images/google-calendar.png" alt="Google Calendar" />,
component: GoogleCalendar, component: GoogleCalendar,
matcher: matcher(GoogleCalendar), }),
}, new EmbedDescriptor({
{
title: "Google Data Studio", title: "Google Data Studio",
keywords: "bi business intelligence", keywords: "bi business intelligence",
icon: () => ( icon: () => (
<Img src="/images/google-datastudio.png" alt="Google Data Studio" /> <Img src="/images/google-datastudio.png" alt="Google Data Studio" />
), ),
component: GoogleDataStudio, component: GoogleDataStudio,
matcher: matcher(GoogleDataStudio), }),
}, new EmbedDescriptor({
{
title: "Google Forms", title: "Google Forms",
keywords: "form survey", keywords: "form survey",
icon: () => <Img src="/images/google-forms.png" alt="Google Forms" />, icon: () => <Img src="/images/google-forms.png" alt="Google Forms" />,
component: GoogleForms, component: GoogleForms,
matcher: matcher(GoogleForms), }),
}, new EmbedDescriptor({
{
title: "Grist", title: "Grist",
keywords: "spreadsheet", keywords: "spreadsheet",
icon: () => <Img src="/images/grist.png" alt="Grist" />, icon: () => <Img src="/images/grist.png" alt="Grist" />,
component: Grist, component: Grist,
matcher: matcher(Grist), }),
}, new EmbedDescriptor({
{
title: "InVision", title: "InVision",
keywords: "design prototype", keywords: "design prototype",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/invision.png" alt="InVision" />, icon: () => <Img src="/images/invision.png" alt="InVision" />,
component: InVision, component: InVision,
matcher: matcher(InVision), }),
}, new EmbedDescriptor({
{
title: "JSFiddle", title: "JSFiddle",
keywords: "code", keywords: "code",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/jsfiddle.png" alt="JSFiddle" />, icon: () => <Img src="/images/jsfiddle.png" alt="JSFiddle" />,
component: JSFiddle, component: JSFiddle,
matcher: matcher(JSFiddle), }),
}, new EmbedDescriptor({
{
title: "Loom", title: "Loom",
keywords: "video screencast", keywords: "video screencast",
icon: () => <Img src="/images/loom.png" alt="Loom" />, icon: () => <Img src="/images/loom.png" alt="Loom" />,
component: Loom, component: Loom,
matcher: matcher(Loom), }),
}, new EmbedDescriptor({
{
title: "Lucidchart", title: "Lucidchart",
keywords: "chart", keywords: "chart",
icon: () => <Img src="/images/lucidchart.png" alt="Lucidchart" />, icon: () => <Img src="/images/lucidchart.png" alt="Lucidchart" />,
component: Lucidchart, component: Lucidchart,
matcher: matcher(Lucidchart), }),
}, new EmbedDescriptor({
{
title: "Marvel", title: "Marvel",
keywords: "design prototype", keywords: "design prototype",
icon: () => <Img src="/images/marvel.png" alt="Marvel" />, icon: () => <Img src="/images/marvel.png" alt="Marvel" />,
component: Marvel, component: Marvel,
matcher: matcher(Marvel), }),
}, new EmbedDescriptor({
{
title: "Mindmeister", title: "Mindmeister",
keywords: "mindmap", keywords: "mindmap",
icon: () => <Img src="/images/mindmeister.png" alt="Mindmeister" />, icon: () => <Img src="/images/mindmeister.png" alt="Mindmeister" />,
component: Mindmeister, component: Mindmeister,
matcher: matcher(Mindmeister), }),
}, new EmbedDescriptor({
{
title: "Miro", title: "Miro",
keywords: "whiteboard", keywords: "whiteboard",
icon: () => <Img src="/images/miro.png" alt="Miro" />, icon: () => <Img src="/images/miro.png" alt="Miro" />,
component: Miro, component: Miro,
matcher: matcher(Miro), }),
}, new EmbedDescriptor({
{
title: "Mode", title: "Mode",
keywords: "analytics", keywords: "analytics",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/mode-analytics.png" alt="Mode" />, icon: () => <Img src="/images/mode-analytics.png" alt="Mode" />,
component: ModeAnalytics, component: ModeAnalytics,
matcher: matcher(ModeAnalytics), }),
}, new EmbedDescriptor({
{
title: "Otter.ai", title: "Otter.ai",
keywords: "audio transcription meeting notes", keywords: "audio transcription meeting notes",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/otter.png" alt="Otter.ai" />, icon: () => <Img src="/images/otter.png" alt="Otter.ai" />,
component: Otter, component: Otter,
matcher: matcher(Otter), }),
}, new EmbedDescriptor({
{
title: "Pitch", title: "Pitch",
keywords: "presentation", keywords: "presentation",
defaultHidden: true, defaultHidden: true,
icon: () => <Img src="/images/pitch.png" alt="Pitch" />, icon: () => <Img src="/images/pitch.png" alt="Pitch" />,
component: Pitch, component: Pitch,
matcher: matcher(Pitch), }),
}, new EmbedDescriptor({
{
title: "Prezi", title: "Prezi",
keywords: "presentation", keywords: "presentation",
icon: () => <Img src="/images/prezi.png" alt="Prezi" />, icon: () => <Img src="/images/prezi.png" alt="Prezi" />,
component: Prezi, component: Prezi,
matcher: matcher(Prezi), }),
}, new EmbedDescriptor({
{
title: "Scribe", title: "Scribe",
keywords: "screencast", keywords: "screencast",
icon: () => <Img src="/images/scribe.png" alt="Scribe" />, icon: () => <Img src="/images/scribe.png" alt="Scribe" />,
component: Scribe, component: Scribe,
matcher: matcher(Scribe), }),
}, new EmbedDescriptor({
{
title: "Spotify", title: "Spotify",
keywords: "music", keywords: "music",
icon: () => <Img src="/images/spotify.png" alt="Spotify" />, icon: () => <Img src="/images/spotify.png" alt="Spotify" />,
component: Spotify, component: Spotify,
matcher: matcher(Spotify), }),
}, new EmbedDescriptor({
{
title: "Tldraw", title: "Tldraw",
keywords: "draw schematics diagrams", keywords: "draw schematics diagrams",
icon: () => <Img src="/images/tldraw.png" alt="Tldraw" />, icon: () => <Img src="/images/tldraw.png" alt="Tldraw" />,
component: Tldraw, component: Tldraw,
matcher: matcher(Tldraw), }),
}, new EmbedDescriptor({
{
title: "Trello", title: "Trello",
keywords: "kanban", keywords: "kanban",
icon: () => <Img src="/images/trello.png" alt="Trello" />, icon: () => <Img src="/images/trello.png" alt="Trello" />,
component: Trello, component: Trello,
matcher: matcher(Trello), }),
}, new EmbedDescriptor({
{
title: "Typeform", title: "Typeform",
keywords: "form survey", keywords: "form survey",
icon: () => <Img src="/images/typeform.png" alt="Typeform" />, icon: () => <Img src="/images/typeform.png" alt="Typeform" />,
component: Typeform, component: Typeform,
matcher: matcher(Typeform), }),
}, new EmbedDescriptor({
{
title: "Vimeo", title: "Vimeo",
keywords: "video", keywords: "video",
icon: () => <Img src="/images/vimeo.png" alt="Vimeo" />, icon: () => <Img src="/images/vimeo.png" alt="Vimeo" />,
component: Vimeo, component: Vimeo,
matcher: matcher(Vimeo), }),
}, new EmbedDescriptor({
{
title: "Whimsical", title: "Whimsical",
keywords: "whiteboard", keywords: "whiteboard",
icon: () => <Img src="/images/whimsical.png" alt="Whimsical" />, icon: () => <Img src="/images/whimsical.png" alt="Whimsical" />,
component: Whimsical, component: Whimsical,
matcher: matcher(Whimsical), }),
}, new EmbedDescriptor({
{
title: "YouTube", title: "YouTube",
keywords: "google video", keywords: "google video",
icon: () => <Img src="/images/youtube.png" alt="YouTube" />, icon: () => <Img src="/images/youtube.png" alt="YouTube" />,
component: YouTube, component: YouTube,
matcher: matcher(YouTube), }),
},
]; ];
export default embeds; export default embeds;

View File

@@ -1,4 +1,5 @@
import { EmbedDescriptor, MenuItem } from "../types"; import { EmbedDescriptor } from "../embeds";
import { MenuItem } from "../types";
type Item = MenuItem | EmbedDescriptor; type Item = MenuItem | EmbedDescriptor;

View File

@@ -107,6 +107,7 @@ export default class Embed extends Node {
attrs={{ ...node.attrs, matches }} attrs={{ ...node.attrs, matches }}
isEditable={isEditable} isEditable={isEditable}
isSelected={isSelected} isSelected={isSelected}
embed={embed}
theme={theme} theme={theme}
/> />
); );

View File

@@ -1,6 +1,6 @@
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import Token from "markdown-it/lib/token"; import Token from "markdown-it/lib/token";
import { EmbedDescriptor } from "../types"; import { EmbedDescriptor } from "../embeds";
function isParagraph(token: Token) { function isParagraph(token: Token) {
return token.type === "paragraph_open"; return token.type === "paragraph_open";

View File

@@ -27,12 +27,6 @@ export type MenuItem = {
active?: (state: EditorState) => boolean; active?: (state: EditorState) => boolean;
}; };
export type EmbedDescriptor = MenuItem & {
icon: React.FC<any>;
matcher: (url: string) => boolean | [] | RegExpMatchArray;
component: typeof React.Component | React.FC<any>;
};
export type ComponentProps = { export type ComponentProps = {
theme: DefaultTheme; theme: DefaultTheme;
node: ProsemirrorNode; node: ProsemirrorNode;

View File

@@ -202,6 +202,7 @@
"Export": "Export", "Export": "Export",
"Webhooks": "Webhooks", "Webhooks": "Webhooks",
"Integrations": "Integrations", "Integrations": "Integrations",
"Draw.io": "Draw.io",
"Insert column after": "Insert column after", "Insert column after": "Insert column after",
"Insert column before": "Insert column before", "Insert column before": "Insert column before",
"Insert row after": "Insert row after", "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.", "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", "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.", "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 in progress…": "Export in progress…",
"Export deleted": "Export deleted", "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 <em>{{ userEmail }}</em> when its 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 <em>{{ userEmail }}</em> when its 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 <em>{{ userEmail }}</em> when its 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 <em>{{ userEmail }}</em> when its complete.",

View File

@@ -21,3 +21,20 @@ export type PublicEnv = {
GOOGLE_ANALYTICS_ID: string | undefined; GOOGLE_ANALYTICS_ID: string | undefined;
RELEASE: string | undefined; RELEASE: string | undefined;
}; };
export enum IntegrationType {
Post = "post",
Command = "command",
Embed = "embed",
}
export type IntegrationSettings<T> = 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 };

14
shared/utils/urls.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -1,3 +1,4 @@
import { escapeRegExp } from "lodash";
import env from "../env"; import env from "../env";
import { parseDomain } from "./domains"; import { parseDomain } from "./domains";
@@ -92,3 +93,13 @@ export function sanitizeUrl(url: string | null | undefined) {
} }
return url; 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}`));
}