Editor embeds (#680)
- [x] Make deleting an embed easier - [x] Add document level ability to disable embeds - [x] Add team level ability to disable embeds - [x] GitHub - [x] Numeracy - [x] Mode Analytics - [x] Figma - [x] Airtable - [x] Vimeo - [x] RealtimeBoard - [x] Loom - [x] Lucidcharts - [x] Framer - [x] InVision - [x] Typeform - [x] Marvel - [x] Spotify - [x] Codepen - [x] Trello
This commit is contained in:
@ -3,12 +3,15 @@ import * as React from 'react';
|
||||
import RichMarkdownEditor from 'rich-markdown-editor';
|
||||
import { uploadFile } from 'utils/uploadFile';
|
||||
import isInternalUrl from 'utils/isInternalUrl';
|
||||
import Embed from './Embed';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
type Props = {
|
||||
titlePlaceholder?: string,
|
||||
bodyPlaceholder?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
forwardedRef: *,
|
||||
history: *,
|
||||
ui: *,
|
||||
@ -51,6 +54,22 @@ class Editor extends React.Component<Props> {
|
||||
this.props.ui.showToast(message, 'success');
|
||||
};
|
||||
|
||||
getLinkComponent = node => {
|
||||
if (this.props.disableEmbeds) return;
|
||||
|
||||
const url = node.data.get('href');
|
||||
const keys = Object.keys(embeds);
|
||||
|
||||
for (const key of keys) {
|
||||
const component = embeds[key];
|
||||
|
||||
for (const host of component.ENABLED) {
|
||||
const matches = url.match(host);
|
||||
if (matches) return Embed;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RichMarkdownEditor
|
||||
@ -58,6 +77,7 @@ class Editor extends React.Component<Props> {
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
getLinkComponent={this.getLinkComponent}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
52
app/components/Editor/Embed.js
Normal file
52
app/components/Editor/Embed.js
Normal file
@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { fadeIn } from 'shared/styles/animations';
|
||||
import embeds from '../../embeds';
|
||||
|
||||
export default class Embed extends React.Component<*> {
|
||||
get url(): string {
|
||||
return this.props.node.data.get('href');
|
||||
}
|
||||
|
||||
get matches(): ?{ component: *, matches: string[] } {
|
||||
const keys = Object.keys(embeds);
|
||||
|
||||
for (const key of keys) {
|
||||
const component = embeds[key];
|
||||
|
||||
for (const host of component.ENABLED) {
|
||||
const matches = this.url.match(host);
|
||||
if (matches) return { component, matches };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const result = this.matches;
|
||||
if (!result) return null;
|
||||
|
||||
const { attributes, isSelected } = this.props;
|
||||
const { component, matches } = result;
|
||||
const EmbedComponent = component;
|
||||
|
||||
return (
|
||||
<Container
|
||||
contentEditable={false}
|
||||
isSelected={isSelected}
|
||||
{...attributes}
|
||||
>
|
||||
<EmbedComponent matches={matches} url={this.url} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
animation: ${fadeIn} 500ms ease-in-out;
|
||||
line-height: 0;
|
||||
|
||||
border-radius: 3px;
|
||||
box-shadow: ${props =>
|
||||
props.isSelected ? `0 0 0 2px ${props.theme.selected}` : 'none'};
|
||||
`;
|
3
app/components/Editor/index.js
Normal file
3
app/components/Editor/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import Editor from './Editor';
|
||||
export default Editor;
|
27
app/embeds/Airtable.js
Normal file
27
app/embeds/Airtable.js
Normal file
@ -0,0 +1,27 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp('https://airtable.com/(?:embed/)?(shr.*)$');
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class Airtable extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const shareId = matches[1];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`https://airtable.com/embed/${shareId}`}
|
||||
title={`Airtable (${shareId})`}
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
23
app/embeds/Airtable.test.js
Normal file
23
app/embeds/Airtable.test.js
Normal file
@ -0,0 +1,23 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Airtable } = embeds;
|
||||
|
||||
describe('Airtable', () => {
|
||||
const match = Airtable.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect('https://airtable.com/shrEoQs3erLnppMie'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://airtable.com/embed/shrEoQs3erLnppMie'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://airtable.com'.match(match)).toBe(null);
|
||||
expect('https://airtable.com/features'.match(match)).toBe(null);
|
||||
expect('https://airtable.com/pricing'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
19
app/embeds/Codepen.js
Normal file
19
app/embeds/Codepen.js
Normal file
@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp('^https://codepen.io/(.*?)/(pen|embed)/(.*)$');
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Codepen extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const normalizedUrl = this.props.url.replace(/\/pen\//, '/embed/');
|
||||
|
||||
return <Frame src={normalizedUrl} title="Codepen Embed" />;
|
||||
}
|
||||
}
|
24
app/embeds/Codepen.test.js
Normal file
24
app/embeds/Codepen.test.js
Normal file
@ -0,0 +1,24 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Codepen } = embeds;
|
||||
|
||||
describe('Codepen', () => {
|
||||
const match = Codepen.ENABLED[0];
|
||||
test('to be enabled on pen link', () => {
|
||||
expect(
|
||||
'https://codepen.io/chriscoyier/pen/gfdDu'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://codepen.io/chriscoyier/embed/gfdDu'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://codepen.io'.match(match)).toBe(null);
|
||||
expect('https://codepen.io/chriscoyier'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
27
app/embeds/Figma.js
Normal file
27
app/embeds/Figma.js
Normal file
@ -0,0 +1,27 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'https://([w.-]+.)?figma.com/(file|proto)/([0-9a-zA-Z]{22,128})(?:/.*)?$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Figma extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
src={`https://www.figma.com/embed?embed_host=outline&url=${
|
||||
this.props.url
|
||||
}`}
|
||||
title="Figma Embed"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
24
app/embeds/Figma.test.js
Normal file
24
app/embeds/Figma.test.js
Normal file
@ -0,0 +1,24 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Figma } = embeds;
|
||||
|
||||
describe('Figma', () => {
|
||||
const match = Figma.ENABLED[0];
|
||||
test('to be enabled on file link', () => {
|
||||
expect(
|
||||
'https://www.figma.com/file/LKQ4FJ4bTnCSjedbRpk931'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on prototype link', () => {
|
||||
expect(
|
||||
'https://www.figma.com/proto/LKQ4FJ4bTnCSjedbRpk931'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://www.figma.com'.match(match)).toBe(null);
|
||||
expect('https://www.figma.com/features'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
17
app/embeds/Framer.js
Normal file
17
app/embeds/Framer.js
Normal file
@ -0,0 +1,17 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp('^https://framer.cloud/(.*)$');
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Framer extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.url} title="Framer Embed" border />;
|
||||
}
|
||||
}
|
15
app/embeds/Framer.test.js
Normal file
15
app/embeds/Framer.test.js
Normal file
@ -0,0 +1,15 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Framer } = embeds;
|
||||
|
||||
describe('Framer', () => {
|
||||
const match = Framer.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect('https://framer.cloud/PVwJO'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled on root', () => {
|
||||
expect('https://framer.cloud'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
70
app/embeds/Gist.js
Normal file
70
app/embeds/Gist.js
Normal file
@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'^https://gist.github.com/([a-zd](?:[a-zd]|-(?=[a-zd])){0,38})/(.*)$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
class Gist extends React.Component<Props> {
|
||||
iframeNode: ?HTMLIFrameElement;
|
||||
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
componentDidMount() {
|
||||
this.updateIframeContent();
|
||||
}
|
||||
|
||||
get id() {
|
||||
const gistUrl = new URL(this.props.url);
|
||||
return gistUrl.pathname.split('/')[2];
|
||||
}
|
||||
|
||||
updateIframeContent() {
|
||||
const id = this.id;
|
||||
const iframe = this.iframeNode;
|
||||
if (!iframe) return;
|
||||
|
||||
// $FlowFixMe
|
||||
let doc = iframe.document;
|
||||
if (iframe.contentDocument) doc = iframe.contentDocument;
|
||||
else if (iframe.contentWindow) doc = iframe.contentWindow.document;
|
||||
|
||||
const gistLink = `https://gist.github.com/${id}.js`;
|
||||
const gistScript = `<script type="text/javascript" src="${
|
||||
gistLink
|
||||
}"></script>`;
|
||||
const styles =
|
||||
'<style>*{ font-size:12px; } body { margin: 0; } .gist .blob-wrapper.data { max-height:150px; overflow:auto; }</style>';
|
||||
const iframeHtml = `<html><head><base target="_parent">${
|
||||
styles
|
||||
}</head><body>${gistScript}</body></html>`;
|
||||
|
||||
doc.open();
|
||||
doc.writeln(iframeHtml);
|
||||
doc.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
const id = this.id;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={ref => {
|
||||
this.iframeNode = ref;
|
||||
}}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
width="100%"
|
||||
height="200px"
|
||||
id={`gist-${id}`}
|
||||
title={`Github Gist (${id})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Gist;
|
19
app/embeds/Gist.test.js
Normal file
19
app/embeds/Gist.test.js
Normal file
@ -0,0 +1,19 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Gist } = embeds;
|
||||
|
||||
describe('Gist', () => {
|
||||
const match = Gist.ENABLED[0];
|
||||
test('to be enabled on gist link', () => {
|
||||
expect(
|
||||
'https://gist.github.com/wmertens/0b4fd66ca7055fd290ecc4b9d95271a9'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://gist.github.com/tommoor'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
19
app/embeds/InVision.js
Normal file
19
app/embeds/InVision.js
Normal file
@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'^https://(invis.io/.*)|(projects.invisionapp.com/share/.*)$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class InVision extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.url} title="InVision Embed" />;
|
||||
}
|
||||
}
|
23
app/embeds/InVision.test.js
Normal file
23
app/embeds/InVision.test.js
Normal file
@ -0,0 +1,23 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { InVision } = embeds;
|
||||
|
||||
describe('InVision', () => {
|
||||
const match = InVision.ENABLED[0];
|
||||
test('to be enabled on shortlink', () => {
|
||||
expect('https://invis.io/69PG07QYQTE'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on share', () => {
|
||||
expect(
|
||||
'https://projects.invisionapp.com/share/69PG07QYQTE'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://invis.io'.match(match)).toBe(null);
|
||||
expect('https://invisionapp.com'.match(match)).toBe(null);
|
||||
expect('https://projects.invisionapp.com'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
26
app/embeds/Loom.js
Normal file
26
app/embeds/Loom.js
Normal file
@ -0,0 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /^https:\/\/(www\.)?useloom.com\/(embed|share)\/(.*)$/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Loom extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const normalizedUrl = this.props.url.replace('share', 'embed');
|
||||
|
||||
return (
|
||||
<Frame
|
||||
width="420px"
|
||||
height="235px"
|
||||
src={normalizedUrl}
|
||||
title="Loom Embed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
28
app/embeds/Loom.test.js
Normal file
28
app/embeds/Loom.test.js
Normal file
@ -0,0 +1,28 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Loom } = embeds;
|
||||
|
||||
describe('Loom', () => {
|
||||
const match = Loom.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect(
|
||||
'https://www.useloom.com/share/55327cbb265743f39c2c442c029277e0'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://www.useloom.com/embed/55327cbb265743f39c2c442c029277e0'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://www.useloom.com'.match(match)).toBe(null);
|
||||
expect('https://www.useloom.com/features'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
26
app/embeds/Lucidchart.js
Normal file
26
app/embeds/Lucidchart.js
Normal file
@ -0,0 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /^https:\/\/(www\.)?lucidchart.com\/documents\/(embeddedchart|view)\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\/.*)?$/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class Lucidchart extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const chartId = matches[3];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`http://lucidchart.com/documents/embeddedchart/${chartId}`}
|
||||
title="Lucidchart Embed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
30
app/embeds/Lucidchart.test.js
Normal file
30
app/embeds/Lucidchart.test.js
Normal file
@ -0,0 +1,30 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Lucidchart } = embeds;
|
||||
|
||||
describe('Lucidchart', () => {
|
||||
const match = Lucidchart.ENABLED[0];
|
||||
test('to be enabled on view link', () => {
|
||||
expect(
|
||||
'https://www.lucidchart.com/documents/view/2f4a79cb-7637-433d-8ffb-27cce65a05e7'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on visited link', () => {
|
||||
expect(
|
||||
'https://www.lucidchart.com/documents/view/2f4a79cb-7637-433d-8ffb-27cce65a05e7/0'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://lucidchart.com'.match(match)).toBe(null);
|
||||
expect('https://www.lucidchart.com'.match(match)).toBe(null);
|
||||
expect('https://www.lucidchart.com/features'.match(match)).toBe(null);
|
||||
expect('https://www.lucidchart.com/documents/view'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
17
app/embeds/Marvel.js
Normal file
17
app/embeds/Marvel.js
Normal file
@ -0,0 +1,17 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp('^https://marvelapp.com/([A-Za-z0-9-]{6})/?$');
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Marvel extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.url} title="Marvel Embed" border />;
|
||||
}
|
||||
}
|
16
app/embeds/Marvel.test.js
Normal file
16
app/embeds/Marvel.test.js
Normal file
@ -0,0 +1,16 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Marvel } = embeds;
|
||||
|
||||
describe('Marvel', () => {
|
||||
const match = Marvel.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect('https://marvelapp.com/75hj91'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://marvelapp.com'.match(match)).toBe(null);
|
||||
expect('https://marvelapp.com/features'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
24
app/embeds/ModeAnalytics.js
Normal file
24
app/embeds/ModeAnalytics.js
Normal file
@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'https://([w.-]+.)?modeanalytics.com/(.*)/reports/(.*)$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class ModeAnalytics extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
// Allow users to paste embed or standard urls and handle them the same
|
||||
const normalizedUrl = this.props.url.replace(/\/embed$/, '');
|
||||
|
||||
return (
|
||||
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
|
||||
);
|
||||
}
|
||||
}
|
19
app/embeds/ModeAnalytics.test.js
Normal file
19
app/embeds/ModeAnalytics.test.js
Normal file
@ -0,0 +1,19 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { ModeAnalytics } = embeds;
|
||||
|
||||
describe('ModeAnalytics', () => {
|
||||
const match = ModeAnalytics.ENABLED[0];
|
||||
test('to be enabled on report link', () => {
|
||||
expect(
|
||||
'https://modeanalytics.com/outline/reports/5aca06064f56'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://modeanalytics.com'.match(match)).toBe(null);
|
||||
expect('https://modeanalytics.com/outline'.match(match)).toBe(null);
|
||||
expect('https://modeanalytics.com/outline/reports'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
22
app/embeds/Numeracy.js
Normal file
22
app/embeds/Numeracy.js
Normal file
@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp('https://([w.-]+.)?numeracy.co/(.*)/(.*)$');
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Numeracy extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
// Allow users to paste embed or standard urls and handle them the same
|
||||
const normalizedUrl = this.props.url.replace(/\.embed$/, '');
|
||||
|
||||
return (
|
||||
<Frame src={`${normalizedUrl}.embed`} title="Numeracy Embed" border />
|
||||
);
|
||||
}
|
||||
}
|
22
app/embeds/Numeracy.test.js
Normal file
22
app/embeds/Numeracy.test.js
Normal file
@ -0,0 +1,22 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Numeracy } = embeds;
|
||||
|
||||
describe('Numeracy', () => {
|
||||
const match = Numeracy.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect('https://numeracy.co/outline/n8ZIVOC2OS'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://numeracy.co/outline/n8ZIVOC2OS.embed'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://numeracy.co'.match(match)).toBe(null);
|
||||
expect('https://numeracy.co/outline'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
26
app/embeds/RealtimeBoard.js
Normal file
26
app/embeds/RealtimeBoard.js
Normal file
@ -0,0 +1,26 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /^https:\/\/realtimeboard.com\/app\/board\/(.*)$/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class RealtimeBoard extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const boardId = matches[1];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
src={`http://realtimeboard.com/app/embed/${boardId}`}
|
||||
title={`RealtimeBoard (${boardId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
18
app/embeds/RealtimeBoard.test.js
Normal file
18
app/embeds/RealtimeBoard.test.js
Normal file
@ -0,0 +1,18 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { RealtimeBoard } = embeds;
|
||||
|
||||
describe('RealtimeBoard', () => {
|
||||
const match = RealtimeBoard.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect(
|
||||
'https://realtimeboard.com/app/board/o9J_k0fwiss='.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://realtimeboard.com'.match(match)).toBe(null);
|
||||
expect('https://realtimeboard.com/features'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
36
app/embeds/Spotify.js
Normal file
36
app/embeds/Spotify.js
Normal file
@ -0,0 +1,36 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp('https?://open.spotify.com/(.*)$');
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Spotify extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
get pathname() {
|
||||
try {
|
||||
const parsed = new URL(this.props.url);
|
||||
return parsed.pathname;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const normalizedPath = this.pathname.replace(/^\/embed/, '/');
|
||||
|
||||
return (
|
||||
<Frame
|
||||
width="300px"
|
||||
height="380px"
|
||||
src={`https://open.spotify.com/embed${normalizedPath}`}
|
||||
title="Spotify Embed"
|
||||
allow="encrypted-media"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
29
app/embeds/Spotify.test.js
Normal file
29
app/embeds/Spotify.test.js
Normal file
@ -0,0 +1,29 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Spotify } = embeds;
|
||||
|
||||
describe('Spotify', () => {
|
||||
const match = Spotify.ENABLED[0];
|
||||
test('to be enabled on song link', () => {
|
||||
expect(
|
||||
'https://open.spotify.com/track/29G1ScCUhgjgI0H72qN4DE?si=DxjEUxV2Tjmk6pSVckPDRg'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on playlist link', () => {
|
||||
expect(
|
||||
'https://open.spotify.com/user/spotify/playlist/29G1ScCUhgjgI0H72qN4DE?si=DxjEUxV2Tjmk6pSVckPDRg'.match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://spotify.com'.match(match)).toBe(null);
|
||||
expect('https://open.spotify.com'.match(match)).toBe(null);
|
||||
expect('https://www.spotify.com'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
39
app/embeds/Trello.js
Normal file
39
app/embeds/Trello.js
Normal file
@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /^https:\/\/trello.com\/(c|b)\/(.*)$/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class Trello extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const objectId = matches[2];
|
||||
|
||||
if (matches[1] === 'c') {
|
||||
return (
|
||||
<Frame
|
||||
width="316px"
|
||||
height="158px"
|
||||
src={`https://trello.com/embed/card?id=${objectId}`}
|
||||
title={`Trello Card (${objectId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame
|
||||
width="248px"
|
||||
height="185px"
|
||||
src={`https://trello.com/embed/board?id=${objectId}`}
|
||||
title={`Trello Board (${objectId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
19
app/embeds/Typeform.js
Normal file
19
app/embeds/Typeform.js
Normal file
@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
'^https://([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?).typeform.com/to/(.*)$'
|
||||
);
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
};
|
||||
|
||||
export default class Typeform extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.url} title="Typeform Embed" />;
|
||||
}
|
||||
}
|
19
app/embeds/Typeform.test.js
Normal file
19
app/embeds/Typeform.test.js
Normal file
@ -0,0 +1,19 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Typeform } = embeds;
|
||||
|
||||
describe('Typeform', () => {
|
||||
const match = Typeform.ENABLED[0];
|
||||
test('to be enabled on share link', () => {
|
||||
expect(
|
||||
'https://beardyman.typeform.com/to/zvlr4L'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://www.typeform.com'.match(match)).toBe(null);
|
||||
expect('https://typeform.com/to/zvlr4L'.match(match)).toBe(null);
|
||||
expect('https://typeform.com/features'.match(match)).toBe(null);
|
||||
});
|
||||
});
|
28
app/embeds/Vimeo.js
Normal file
28
app/embeds/Vimeo.js
Normal file
@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class Vimeo extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const videoId = matches[4];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
width="420px"
|
||||
height="235px"
|
||||
src={`http://player.vimeo.com/video/${videoId}?byline=0`}
|
||||
title={`Vimeo Embed (${videoId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
20
app/embeds/Vimeo.test.js
Normal file
20
app/embeds/Vimeo.test.js
Normal file
@ -0,0 +1,20 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { Vimeo } = embeds;
|
||||
|
||||
describe('Vimeo', () => {
|
||||
const match = Vimeo.ENABLED[0];
|
||||
test('to be enabled on video link', () => {
|
||||
expect('https://vimeo.com/265045525'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://vimeo.com'.match(match)).toBe(null);
|
||||
expect('https://www.vimeo.com'.match(match)).toBe(null);
|
||||
expect('https://vimeo.com/upgrade'.match(match)).toBe(null);
|
||||
expect('https://vimeo.com/features/video-marketing'.match(match)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
});
|
28
app/embeds/YouTube.js
Normal file
28
app/embeds/YouTube.js
Normal file
@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import Frame from './components/Frame';
|
||||
|
||||
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
matches: string[],
|
||||
};
|
||||
|
||||
export default class YouTube extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
const videoId = matches[1];
|
||||
|
||||
return (
|
||||
<Frame
|
||||
width="420px"
|
||||
height="235px"
|
||||
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
|
||||
title={`YouTube (${videoId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
33
app/embeds/YouTube.test.js
Normal file
33
app/embeds/YouTube.test.js
Normal file
@ -0,0 +1,33 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import embeds from '.';
|
||||
|
||||
const { YouTube } = embeds;
|
||||
|
||||
describe('YouTube', () => {
|
||||
const match = YouTube.ENABLED[0];
|
||||
test('to be enabled on video link', () => {
|
||||
expect(
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on embed link', () => {
|
||||
expect(
|
||||
'https://www.youtube.com/embed?v=dQw4w9WgXcQ'.match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to be enabled on shortlink', () => {
|
||||
expect('https://youtu.be/dQw4w9WgXcQ'.match(match)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('to not be enabled elsewhere', () => {
|
||||
expect('https://youtu.be'.match(match)).toBe(null);
|
||||
expect('https://youtube.com'.match(match)).toBe(null);
|
||||
expect('https://www.youtube.com'.match(match)).toBe(null);
|
||||
expect('https://www.youtube.com/logout'.match(match)).toBe(null);
|
||||
expect('https://www.youtube.com/feed/subscriptions'.match(match)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
});
|
82
app/embeds/components/Frame.js
Normal file
82
app/embeds/components/Frame.js
Normal file
@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Props = {
|
||||
src?: string,
|
||||
border?: boolean,
|
||||
forwardedRef: *,
|
||||
width?: string,
|
||||
height?: string,
|
||||
};
|
||||
|
||||
type State = {
|
||||
isLoaded: boolean,
|
||||
};
|
||||
|
||||
class Frame extends React.Component<Props, State> {
|
||||
mounted: boolean;
|
||||
|
||||
state = { isLoaded: false };
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
setImmediate(this.loadIframe);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
loadIframe = () => {
|
||||
if (!this.mounted) return;
|
||||
this.setState({ isLoaded: true });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
border,
|
||||
width = '100%',
|
||||
height = '400',
|
||||
forwardedRef,
|
||||
...rest
|
||||
} = this.props;
|
||||
const Component = border ? Iframe : 'iframe';
|
||||
|
||||
return (
|
||||
<Rounded width={width} height={height}>
|
||||
{this.state.isLoaded && (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
width={width}
|
||||
height={height}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
allowFullScreen
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Rounded>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Rounded = styled.div`
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
width: ${props => props.width};
|
||||
height: ${props => props.height};
|
||||
`;
|
||||
|
||||
const Iframe = styled.iframe`
|
||||
border: 1px solid;
|
||||
border-color: #ddd #ddd #ccc;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
// $FlowIssue - https://github.com/facebook/flow/issues/6103
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<Frame {...props} forwardedRef={ref} />
|
||||
));
|
38
app/embeds/index.js
Normal file
38
app/embeds/index.js
Normal file
@ -0,0 +1,38 @@
|
||||
// @flow
|
||||
import Airtable from './Airtable';
|
||||
import Codepen from './Codepen';
|
||||
import Figma from './Figma';
|
||||
import Framer from './Framer';
|
||||
import Gist from './Gist';
|
||||
import InVision from './InVision';
|
||||
import Loom from './Loom';
|
||||
import Lucidchart from './Lucidchart';
|
||||
import Marvel from './Marvel';
|
||||
import ModeAnalytics from './ModeAnalytics';
|
||||
import Numeracy from './Numeracy';
|
||||
import RealtimeBoard from './RealtimeBoard';
|
||||
import Spotify from './Spotify';
|
||||
import Trello from './Trello';
|
||||
import Typeform from './Typeform';
|
||||
import Vimeo from './Vimeo';
|
||||
import YouTube from './YouTube';
|
||||
|
||||
export default {
|
||||
Airtable,
|
||||
Codepen,
|
||||
Figma,
|
||||
Framer,
|
||||
Gist,
|
||||
InVision,
|
||||
Loom,
|
||||
Lucidchart,
|
||||
Marvel,
|
||||
ModeAnalytics,
|
||||
Numeracy,
|
||||
RealtimeBoard,
|
||||
Spotify,
|
||||
Trello,
|
||||
Typeform,
|
||||
Vimeo,
|
||||
YouTube,
|
||||
};
|
@ -18,6 +18,7 @@ type Props = {
|
||||
document: Document,
|
||||
className: string,
|
||||
showPrint?: boolean,
|
||||
showToggleEmbeds?: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
@ -75,7 +76,14 @@ class DocumentMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { document, label, className, showPrint, auth } = this.props;
|
||||
const {
|
||||
document,
|
||||
label,
|
||||
className,
|
||||
showPrint,
|
||||
showToggleEmbeds,
|
||||
auth,
|
||||
} = this.props;
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
|
||||
return (
|
||||
@ -106,6 +114,19 @@ class DocumentMenu extends React.Component<Props> {
|
||||
Share link…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showToggleEmbeds && (
|
||||
<React.Fragment>
|
||||
{document.embedsDisabled ? (
|
||||
<DropdownMenuItem onClick={document.enableEmbeds}>
|
||||
Enable embeds
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={document.disableEmbeds}>
|
||||
Disable embeds
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
<hr />
|
||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||
Document history
|
||||
|
@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { action, set, computed } from 'mobx';
|
||||
import { action, set, computed, observable } from 'mobx';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import { client } from 'utils/ApiClient';
|
||||
@ -42,6 +42,7 @@ export default class Document extends BaseModel {
|
||||
shareUrl: ?string;
|
||||
views: number;
|
||||
revision: number;
|
||||
@observable embedsDisabled: ?boolean;
|
||||
|
||||
constructor(data?: Object = {}, store: *) {
|
||||
super(data, store);
|
||||
@ -143,6 +144,16 @@ export default class Document extends BaseModel {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
enableEmbeds = () => {
|
||||
this.embedsDisabled = false;
|
||||
};
|
||||
|
||||
@action
|
||||
disableEmbeds = () => {
|
||||
this.embedsDisabled = true;
|
||||
};
|
||||
|
||||
@action
|
||||
star = async () => {
|
||||
this.starred = true;
|
||||
|
@ -8,6 +8,7 @@ class Team extends BaseModel {
|
||||
slackConnected: boolean;
|
||||
googleConnected: boolean;
|
||||
sharing: boolean;
|
||||
documentEmbeds: boolean;
|
||||
subdomain: ?string;
|
||||
url: string;
|
||||
}
|
||||
|
@ -277,7 +277,8 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { location, match } = this.props;
|
||||
const { location, auth, match } = this.props;
|
||||
const team = auth.team;
|
||||
const Editor = this.editorComponent;
|
||||
const document = this.document;
|
||||
const revision = this.revision;
|
||||
@ -296,7 +297,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
if (!document || !Editor) {
|
||||
if (!document || !team || !Editor) {
|
||||
return (
|
||||
<Container column auto>
|
||||
<PageTitle title={location.state ? location.state.title : ''} />
|
||||
@ -307,6 +308,8 @@ class DocumentScene extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
const embedsDisabled = document.embedsDisabled || !team.documentEmbeds;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container
|
||||
@ -359,10 +362,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
<MaxWidth column auto>
|
||||
<Editor
|
||||
key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'}
|
||||
titlePlaceholder="Start with a title…"
|
||||
bodyPlaceholder="…the rest is your canvas"
|
||||
defaultValue={revision ? revision.text : document.text}
|
||||
pretitle={document.emoji}
|
||||
disableEmbeds={embedsDisabled}
|
||||
onImageUploadStart={this.onImageUploadStart}
|
||||
onImageUploadStop={this.onImageUploadStop}
|
||||
onSearchLink={this.onSearchLink}
|
||||
|
@ -95,6 +95,7 @@ class Header extends React.Component<Props> {
|
||||
auth,
|
||||
} = this.props;
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||
|
||||
return (
|
||||
<Actions
|
||||
@ -168,7 +169,11 @@ class Header extends React.Component<Props> {
|
||||
)}
|
||||
{!isEditing && (
|
||||
<Action>
|
||||
<DocumentMenu document={document} showPrint />
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing &&
|
||||
|
@ -21,10 +21,12 @@ class Security extends React.Component<Props> {
|
||||
form: ?HTMLFormElement;
|
||||
|
||||
@observable sharing: boolean;
|
||||
@observable documentEmbeds: boolean;
|
||||
|
||||
componentDidMount() {
|
||||
const { auth } = this.props;
|
||||
if (auth.team) {
|
||||
this.documentEmbeds = auth.team.documentEmbeds;
|
||||
this.sharing = auth.team.sharing;
|
||||
}
|
||||
}
|
||||
@ -34,13 +36,18 @@ class Security extends React.Component<Props> {
|
||||
|
||||
await this.props.auth.updateTeam({
|
||||
sharing: this.sharing,
|
||||
documentEmbeds: this.documentEmbeds,
|
||||
});
|
||||
this.props.ui.showToast('Settings saved', 'success');
|
||||
};
|
||||
|
||||
handleChange = (ev: SyntheticInputEvent<*>) => {
|
||||
if (ev.target.name === 'sharing') {
|
||||
this.sharing = ev.target.checked;
|
||||
switch (ev.target.name) {
|
||||
case 'sharing':
|
||||
return (this.sharing = ev.target.checked);
|
||||
case 'documentEmbeds':
|
||||
return (this.documentEmbeds = ev.target.checked);
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
@ -56,18 +63,25 @@ class Security extends React.Component<Props> {
|
||||
<PageTitle title="Security" />
|
||||
<h1>Security</h1>
|
||||
<HelpText>
|
||||
Settings that impact the access, security and privacy of your
|
||||
Settings that impact the access, security and content of your
|
||||
knowledgebase.
|
||||
</HelpText>
|
||||
|
||||
<form onSubmit={this.handleSubmit} ref={ref => (this.form = ref)}>
|
||||
<Checkbox
|
||||
label="Allow sharing documents"
|
||||
label="Public document sharing"
|
||||
name="sharing"
|
||||
checked={this.sharing}
|
||||
onChange={this.handleChange}
|
||||
note="When enabled documents can be shared publicly by any team member"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Rich service embeds"
|
||||
name="documentEmbeds"
|
||||
checked={this.documentEmbeds}
|
||||
onChange={this.handleChange}
|
||||
note="Convert links to supported services into rich embeds within your documents"
|
||||
/>
|
||||
<Button type="submit" disabled={isSaving || !this.isValid}>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
|
@ -159,7 +159,7 @@
|
||||
"react-waypoint": "^7.3.1",
|
||||
"redis": "^2.6.2",
|
||||
"redis-lock": "^0.1.0",
|
||||
"rich-markdown-editor": "^6.1.1",
|
||||
"rich-markdown-editor": "^7.0.0-1",
|
||||
"safestart": "1.1.0",
|
||||
"sequelize": "4.28.6",
|
||||
"sequelize-cli": "^2.7.0",
|
||||
|
@ -11,7 +11,7 @@ const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post('team.update', auth(), async ctx => {
|
||||
const { name, avatarUrl, subdomain, sharing } = ctx.body;
|
||||
const { name, avatarUrl, subdomain, sharing, documentEmbeds } = ctx.body;
|
||||
const endpoint = publicS3Endpoint();
|
||||
|
||||
const user = ctx.state.user;
|
||||
@ -24,6 +24,7 @@ router.post('team.update', auth(), async ctx => {
|
||||
|
||||
if (name) team.name = name;
|
||||
if (sharing !== undefined) team.sharing = sharing;
|
||||
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
|
||||
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
|
||||
team.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
12
server/migrations/20181215192422-document-embeds.js
Normal file
12
server/migrations/20181215192422-document-embeds.js
Normal file
@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('teams', 'documentEmbeds', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('teams', 'documentEmbeds');
|
||||
}
|
||||
}
|
@ -44,6 +44,11 @@ const Team = sequelize.define(
|
||||
googleId: { type: DataTypes.STRING, allowNull: true },
|
||||
avatarUrl: { type: DataTypes.STRING, allowNull: true },
|
||||
sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
|
||||
documentEmbeds: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
slackData: DataTypes.JSONB,
|
||||
},
|
||||
{
|
||||
|
@ -11,6 +11,7 @@ function present(ctx: Object, team: Team) {
|
||||
slackConnected: !!team.slackId,
|
||||
googleConnected: !!team.googleId,
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
subdomain: team.subdomain,
|
||||
url: team.url,
|
||||
};
|
||||
|
14
yarn.lock
14
yarn.lock
@ -8986,9 +8986,9 @@ retry-axios@0.3.2, retry-axios@^0.3.2:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-0.3.2.tgz#5757c80f585b4cc4c4986aa2ffd47a60c6d35e13"
|
||||
|
||||
rich-markdown-editor@^6.1.1:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-6.1.1.tgz#f3be0e5f43a804cc6c32b3cc6810907080494b54"
|
||||
rich-markdown-editor@^7.0.0-1:
|
||||
version "7.0.0-1"
|
||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-7.0.0-1.tgz#e587894609cedaacc7e5c35cfd278799ff420f61"
|
||||
dependencies:
|
||||
"@tommoor/slate-drop-or-paste-images" "^0.8.1"
|
||||
babel-plugin-transform-async-to-generator "^6.24.1"
|
||||
@ -9009,7 +9009,7 @@ rich-markdown-editor@^6.1.1:
|
||||
slate-collapse-on-escape "^0.6.1"
|
||||
slate-edit-code "^0.15.5"
|
||||
slate-edit-list "^0.11.3"
|
||||
slate-md-serializer "^5.2.0"
|
||||
slate-md-serializer "^5.2.2"
|
||||
slate-paste-linkify "^0.5.1"
|
||||
slate-plain-serializer "0.5.4"
|
||||
slate-prism "^0.5.0"
|
||||
@ -9381,9 +9381,9 @@ slate-edit-list@^0.11.3:
|
||||
version "0.11.3"
|
||||
resolved "https://registry.yarnpkg.com/slate-edit-list/-/slate-edit-list-0.11.3.tgz#d27ff2ff93a83bef49131a6a44b87a9558c9d44c"
|
||||
|
||||
slate-md-serializer@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.2.0.tgz#bc45a338f8cdc483f9b501ab8543cb920992376f"
|
||||
slate-md-serializer@^5.2.2:
|
||||
version "5.2.2"
|
||||
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.2.2.tgz#86003e476746af3361d6ccf61c1cb41e9494eb6e"
|
||||
dependencies:
|
||||
hashtag-regex "^2.0.0"
|
||||
|
||||
|
Reference in New Issue
Block a user