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:
@@ -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));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
41
app/hooks/useEmbeds.ts
Normal 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
101
app/scenes/Settings/Drawio.tsx
Normal file
101
app/scenes/Settings/Drawio.tsx
Normal 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);
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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 <em>{{ userEmail }}</em> 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 <em>{{ userEmail }}</em> 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 <em>{{ userEmail }}</em> when it’s complete.",
|
||||||
|
|||||||
@@ -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
14
shared/utils/urls.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user