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 e7485d9cda.

* 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 { 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<SharedEditor> | 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<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 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<T = MenuItem> extends React.Component<Props<T>, State> {
for (const embed of embeds) {
if (embed.title) {
embedItems.push({
...embed,
name: "embed",
});
embedItems.push(
new EmbedDescriptor({
...embed,
name: "embed",
})
);
}
}

View File

@@ -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<IntegrationType.Embed>[];
};
type State = {

View File

@@ -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",

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 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<T = unknown> extends BaseModel {
id: string;
type: string;
@@ -21,7 +16,7 @@ class Integration extends BaseModel {
@observable
events: string[];
settings: Settings;
settings: IntegrationSettings<T>;
}
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 { 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() {
<SlackListItem
key={integration.id}
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 { 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<IntegrationType.Post>;
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,
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<T = unknown> extends IdModel {
@IsIn([Object.values(IntegrationType)])
@Column
type: string;
@IsIn([Object.values(IntegrationService)])
@Column
service: string;
@Column(DataType.JSONB)
settings: Record<string, any>;
settings: IntegrationSettings<T>;
@IsIn([["documents.update", "documents.publish"]])
@Column(DataType.ARRAY(DataType.STRING))
events: string[];

View File

@@ -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<IntegrationType.Post>;
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<IntegrationType.Post>;
if (!integration) {
return;
}

View File

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

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) => {
if (typeof value !== "string") {
throw ValidationError(message);

View File

@@ -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<Props> {
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 (
<Frame
{...this.props}
src={this.embedUrl}
title={this.title}
border
icon={
<Image
src="/images/diagrams.png"
alt="Diagrams.net"
width={16}
height={16}
/>
}
/>
);
}
return (
<Frame
{...props}
src={embedUrl}
icon={
<Image
src="/images/diagrams.png"
alt="Diagrams.net"
width={16}
height={16}
/>
}
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;

View File

@@ -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<EmbedProps>) {
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<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
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<EmbedProps>) {
}
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: () => <Img src="/images/abstract.png" alt="Abstract" />,
component: Abstract,
matcher: matcher(Abstract),
},
{
}),
new EmbedDescriptor({
title: "Airtable",
keywords: "spreadsheet",
icon: () => <Img src="/images/airtable.png" alt="Airtable" />,
component: Airtable,
matcher: matcher(Airtable),
},
{
}),
new EmbedDescriptor({
title: "Berrycast",
keywords: "video",
defaultHidden: true,
icon: () => <Img src="/images/berrycast.png" alt="Berrycast" />,
component: Berrycast,
matcher: matcher(Berrycast),
},
{
}),
new EmbedDescriptor({
title: "Bilibili",
keywords: "video",
defaultHidden: true,
icon: () => <Img src="/images/bilibili.png" alt="Bilibili" />,
component: Bilibili,
matcher: matcher(Bilibili),
},
{
}),
new EmbedDescriptor({
title: "Cawemo",
keywords: "bpmn process",
defaultHidden: true,
icon: () => <Img src="/images/cawemo.png" alt="Cawemo" />,
component: Cawemo,
matcher: matcher(Cawemo),
},
{
}),
new EmbedDescriptor({
title: "ClickUp",
keywords: "project",
icon: () => <Img src="/images/clickup.png" alt="ClickUp" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
}),
new EmbedDescriptor({
title: "Codepen",
keywords: "code editor",
icon: () => <Img src="/images/codepen.png" alt="Codepen" />,
component: Codepen,
matcher: matcher(Codepen),
},
{
}),
new EmbedDescriptor({
title: "DBDiagram",
keywords: "diagrams database",
icon: () => <Img src="/images/dbdiagram.png" alt="DBDiagram" />,
component: DBDiagram,
matcher: matcher(DBDiagram),
},
{
}),
new EmbedDescriptor({
title: "Descript",
keywords: "audio",
icon: () => <Img src="/images/descript.png" alt="Descript" />,
component: Descript,
matcher: matcher(Descript),
},
{
}),
new EmbedDescriptor({
title: "Figma",
keywords: "design svg vector",
icon: () => <Img src="/images/figma.png" alt="Figma" />,
component: Figma,
matcher: matcher(Figma),
},
{
}),
new EmbedDescriptor({
title: "Framer",
keywords: "design prototyping",
icon: () => <Img src="/images/framer.png" alt="Framer" />,
component: Framer,
matcher: matcher(Framer),
},
{
}),
new EmbedDescriptor({
title: "GitHub Gist",
keywords: "code",
icon: () => <Img src="/images/github-gist.png" alt="GitHub" />,
component: Gist,
matcher: matcher(Gist),
},
{
}),
new EmbedDescriptor({
title: "Gliffy",
keywords: "diagram",
icon: () => <Img src="/images/gliffy.png" alt="Gliffy" />,
component: Gliffy,
matcher: matcher(Gliffy),
},
{
}),
new EmbedDescriptor({
title: "Diagrams.net",
keywords: "diagrams drawio",
icon: () => <Img src="/images/diagrams.png" alt="Diagrams.net" />,
component: Diagrams,
matcher: matcher(Diagrams),
},
{
}),
new EmbedDescriptor({
title: "Google Drawings",
keywords: "drawings",
icon: () => <Img src="/images/google-drawings.png" alt="Google Drawings" />,
component: GoogleDrawings,
matcher: matcher(GoogleDrawings),
},
{
}),
new EmbedDescriptor({
title: "Google Drive",
keywords: "drive",
icon: () => <Img src="/images/google-drive.png" alt="Google Drive" />,
component: GoogleDrive,
matcher: matcher(GoogleDrive),
},
{
}),
new EmbedDescriptor({
title: "Google Docs",
keywords: "documents word",
icon: () => <Img src="/images/google-docs.png" alt="Google Docs" />,
component: GoogleDocs,
matcher: matcher(GoogleDocs),
},
{
}),
new EmbedDescriptor({
title: "Google Sheets",
keywords: "excel spreadsheet",
icon: () => <Img src="/images/google-sheets.png" alt="Google Sheets" />,
component: GoogleSheets,
matcher: matcher(GoogleSheets),
},
{
}),
new EmbedDescriptor({
title: "Google Slides",
keywords: "presentation slideshow",
icon: () => <Img src="/images/google-slides.png" alt="Google Slides" />,
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
}),
new EmbedDescriptor({
title: "Google Calendar",
keywords: "calendar",
icon: () => <Img src="/images/google-calendar.png" alt="Google Calendar" />,
component: GoogleCalendar,
matcher: matcher(GoogleCalendar),
},
{
}),
new EmbedDescriptor({
title: "Google Data Studio",
keywords: "bi business intelligence",
icon: () => (
<Img src="/images/google-datastudio.png" alt="Google Data Studio" />
),
component: GoogleDataStudio,
matcher: matcher(GoogleDataStudio),
},
{
}),
new EmbedDescriptor({
title: "Google Forms",
keywords: "form survey",
icon: () => <Img src="/images/google-forms.png" alt="Google Forms" />,
component: GoogleForms,
matcher: matcher(GoogleForms),
},
{
}),
new EmbedDescriptor({
title: "Grist",
keywords: "spreadsheet",
icon: () => <Img src="/images/grist.png" alt="Grist" />,
component: Grist,
matcher: matcher(Grist),
},
{
}),
new EmbedDescriptor({
title: "InVision",
keywords: "design prototype",
defaultHidden: true,
icon: () => <Img src="/images/invision.png" alt="InVision" />,
component: InVision,
matcher: matcher(InVision),
},
{
}),
new EmbedDescriptor({
title: "JSFiddle",
keywords: "code",
defaultHidden: true,
icon: () => <Img src="/images/jsfiddle.png" alt="JSFiddle" />,
component: JSFiddle,
matcher: matcher(JSFiddle),
},
{
}),
new EmbedDescriptor({
title: "Loom",
keywords: "video screencast",
icon: () => <Img src="/images/loom.png" alt="Loom" />,
component: Loom,
matcher: matcher(Loom),
},
{
}),
new EmbedDescriptor({
title: "Lucidchart",
keywords: "chart",
icon: () => <Img src="/images/lucidchart.png" alt="Lucidchart" />,
component: Lucidchart,
matcher: matcher(Lucidchart),
},
{
}),
new EmbedDescriptor({
title: "Marvel",
keywords: "design prototype",
icon: () => <Img src="/images/marvel.png" alt="Marvel" />,
component: Marvel,
matcher: matcher(Marvel),
},
{
}),
new EmbedDescriptor({
title: "Mindmeister",
keywords: "mindmap",
icon: () => <Img src="/images/mindmeister.png" alt="Mindmeister" />,
component: Mindmeister,
matcher: matcher(Mindmeister),
},
{
}),
new EmbedDescriptor({
title: "Miro",
keywords: "whiteboard",
icon: () => <Img src="/images/miro.png" alt="Miro" />,
component: Miro,
matcher: matcher(Miro),
},
{
}),
new EmbedDescriptor({
title: "Mode",
keywords: "analytics",
defaultHidden: true,
icon: () => <Img src="/images/mode-analytics.png" alt="Mode" />,
component: ModeAnalytics,
matcher: matcher(ModeAnalytics),
},
{
}),
new EmbedDescriptor({
title: "Otter.ai",
keywords: "audio transcription meeting notes",
defaultHidden: true,
icon: () => <Img src="/images/otter.png" alt="Otter.ai" />,
component: Otter,
matcher: matcher(Otter),
},
{
}),
new EmbedDescriptor({
title: "Pitch",
keywords: "presentation",
defaultHidden: true,
icon: () => <Img src="/images/pitch.png" alt="Pitch" />,
component: Pitch,
matcher: matcher(Pitch),
},
{
}),
new EmbedDescriptor({
title: "Prezi",
keywords: "presentation",
icon: () => <Img src="/images/prezi.png" alt="Prezi" />,
component: Prezi,
matcher: matcher(Prezi),
},
{
}),
new EmbedDescriptor({
title: "Scribe",
keywords: "screencast",
icon: () => <Img src="/images/scribe.png" alt="Scribe" />,
component: Scribe,
matcher: matcher(Scribe),
},
{
}),
new EmbedDescriptor({
title: "Spotify",
keywords: "music",
icon: () => <Img src="/images/spotify.png" alt="Spotify" />,
component: Spotify,
matcher: matcher(Spotify),
},
{
}),
new EmbedDescriptor({
title: "Tldraw",
keywords: "draw schematics diagrams",
icon: () => <Img src="/images/tldraw.png" alt="Tldraw" />,
component: Tldraw,
matcher: matcher(Tldraw),
},
{
}),
new EmbedDescriptor({
title: "Trello",
keywords: "kanban",
icon: () => <Img src="/images/trello.png" alt="Trello" />,
component: Trello,
matcher: matcher(Trello),
},
{
}),
new EmbedDescriptor({
title: "Typeform",
keywords: "form survey",
icon: () => <Img src="/images/typeform.png" alt="Typeform" />,
component: Typeform,
matcher: matcher(Typeform),
},
{
}),
new EmbedDescriptor({
title: "Vimeo",
keywords: "video",
icon: () => <Img src="/images/vimeo.png" alt="Vimeo" />,
component: Vimeo,
matcher: matcher(Vimeo),
},
{
}),
new EmbedDescriptor({
title: "Whimsical",
keywords: "whiteboard",
icon: () => <Img src="/images/whimsical.png" alt="Whimsical" />,
component: Whimsical,
matcher: matcher(Whimsical),
},
{
}),
new EmbedDescriptor({
title: "YouTube",
keywords: "google video",
icon: () => <Img src="/images/youtube.png" alt="YouTube" />,
component: YouTube,
matcher: matcher(YouTube),
},
}),
];
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;

View File

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

View File

@@ -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";

View File

@@ -27,12 +27,6 @@ export type MenuItem = {
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 = {
theme: DefaultTheme;
node: ProsemirrorNode;

View File

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