feat: Backlinks (#979)

* feat: backlinks

* feat: add backlinkDocumentId to documents.list

* chore: refactor
fix: create and delete backlink handling

* fix: guard against self links

* feat: basic frontend
fix: race condition

* styling

* test: fix parse ids

* self review

* linting

* feat: Improved link styling

* fix: Increase clickable area at bottom of doc / between references

* perf: global styles are SLOW
This commit is contained in:
Tom Moor
2019-07-07 19:25:45 -07:00
committed by GitHub
parent 599e5c8f5d
commit 091e542406
23 changed files with 538 additions and 89 deletions

View File

@ -2,7 +2,7 @@
import styled from 'styled-components';
const ClickablePadding = styled.div`
min-height: 6em;
min-height: 10em;
cursor: ${({ onClick }) => (onClick ? 'text' : 'default')};
${({ grow }) => grow && `flex-grow: 100;`};
`;

View File

@ -2,14 +2,13 @@
import * as React from 'react';
import { observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import Document from 'models/Document';
import { StarredIcon } from 'outline-icons';
import styled, { withTheme } from 'styled-components';
import { darken } from 'polished';
import Flex from 'shared/components/Flex';
import Highlight from 'components/Highlight';
import { StarredIcon } from 'outline-icons';
import PublishingInfo from './components/PublishingInfo';
import PublishingInfo from 'components/PublishingInfo';
import DocumentMenu from 'menus/DocumentMenu';
import Document from 'models/Document';
type Props = {
document: Document,
@ -45,8 +44,8 @@ const StyledDocumentMenu = styled(DocumentMenu)`
const DocumentLink = styled(Link)`
display: block;
margin: 0 -16px;
padding: 10px 16px;
margin: 8px -8px;
padding: 6px 8px;
border-radius: 8px;
border: 2px solid transparent;
max-height: 50vh;
@ -62,7 +61,6 @@ const DocumentLink = styled(Link)`
&:active,
&:focus {
background: ${props => props.theme.listItemHoverBackground};
border: 2px solid ${props => props.theme.listItemHoverBorder};
outline: none;
${StyledStar}, ${StyledDocumentMenu} {
@ -73,10 +71,6 @@ const DocumentLink = styled(Link)`
}
}
}
&:focus {
border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)};
}
`;
const Heading = styled.h3`

View File

@ -3,8 +3,10 @@ import * as React from 'react';
import { Redirect } from 'react-router-dom';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { withTheme } from 'styled-components';
import { lighten } from 'polished';
import styled, { withTheme } from 'styled-components';
import RichMarkdownEditor from 'rich-markdown-editor';
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
import { uploadFile } from 'utils/uploadFile';
import isInternalUrl from 'utils/isInternalUrl';
import Tooltip from 'components/Tooltip';
@ -79,7 +81,7 @@ class Editor extends React.Component<Props> {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<RichMarkdownEditor
<StyledEditor
ref={this.props.forwardedRef}
uploadImage={this.onUploadImage}
onClickLink={this.onClickLink}
@ -92,6 +94,38 @@ class Editor extends React.Component<Props> {
}
}
const StyledEditor = styled(RichMarkdownEditor)`
justify-content: start;
> div {
transition: ${props => props.theme.backgroundTransition};
}
p {
${Placeholder} {
visibility: hidden;
}
}
p:nth-child(2):last-child {
${Placeholder} {
visibility: visible;
}
}
p {
a {
color: ${props => props.theme.link};
border-bottom: 1px solid ${props => lighten(0.5, props.theme.link)};
font-weight: 500;
&:hover {
border-bottom: 1px solid ${props => props.theme.link};
text-decoration: none;
}
}
}
`;
const EditorTooltip = props => <Tooltip offset={8} {...props} />;
export default withTheme(

View File

@ -1,7 +1,6 @@
// @flow
import * as React from 'react';
import { observer } from 'mobx-react';
import { darken } from 'polished';
import styled from 'styled-components';
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
import Flex from 'shared/components/Flex';
@ -92,13 +91,8 @@ const ResultWrapperLink = styled(ResultWrapper.withComponent('a'))`
&:active,
&:focus {
background: ${props => props.theme.listItemHoverBackground};
border: 2px solid ${props => props.theme.listItemHoverBorder};
outline: none;
}
&:focus {
border: 2px solid ${props => darken(0.5, props.theme.listItemHoverBorder)};
}
`;
export default PathToDocument;

View File

@ -201,13 +201,21 @@ type Props = {
offset?: number,
};
const Tooltip = function({ offset = 0, ...rest }: Props) {
return (
<React.Fragment>
<GlobalStyles offset={offset} />
<TooltipTrigger {...rest} />
</React.Fragment>
);
};
class Tooltip extends React.Component<Props> {
shouldComponentUpdate() {
return false;
}
render() {
const { offset = 0, ...rest } = this.props;
return (
<React.Fragment>
<GlobalStyles offset={offset} />
<TooltipTrigger {...rest} />
</React.Fragment>
);
}
}
export default Tooltip;

View File

@ -22,6 +22,7 @@ import { emojiToUrl } from 'utils/emoji';
import Header from './components/Header';
import DocumentMove from './components/DocumentMove';
import Branding from './components/Branding';
import Backlinks from './components/Backlinks';
import ErrorBoundary from 'components/ErrorBoundary';
import LoadingPlaceholder from 'components/LoadingPlaceholder';
import LoadingIndicator from 'components/LoadingIndicator';
@ -415,6 +416,12 @@ class DocumentScene extends React.Component<Props> {
ui={this.props.ui}
schema={schema}
/>
{!this.isEditing && (
<Backlinks
documents={this.props.documents}
document={document}
/>
)}
</MaxWidth>
</Container>
</Container>

View File

@ -0,0 +1,68 @@
// @flow
import * as React from 'react';
import { observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import PublishingInfo from 'components/PublishingInfo';
import Document from 'models/Document';
type Props = {
document: Document,
anchor: string,
showCollection?: boolean,
ref?: *,
};
const DocumentLink = styled(Link)`
display: block;
margin: 0 -8px;
padding: 6px 8px;
border-radius: 8px;
border: 2px solid transparent;
max-height: 50vh;
min-width: 100%;
overflow: hidden;
position: relative;
&:hover,
&:active,
&:focus {
background: ${props => props.theme.listItemHoverBackground};
outline: none;
}
`;
const Title = styled.h3`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
margin-top: 0;
margin-bottom: 0.25em;
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
`;
@observer
class Backlink extends React.Component<Props> {
render() {
const { document, showCollection, anchor, ...rest } = this.props;
return (
<DocumentLink
to={{
pathname: document.url,
hash: `d-${anchor}`,
state: { title: document.title },
}}
{...rest}
>
<Title>{document.title}</Title>
<PublishingInfo document={document} showCollection={showCollection} />
</DocumentLink>
);
}
}
export default Backlink;

View File

@ -0,0 +1,46 @@
// @flow
import * as React from 'react';
import { observer } from 'mobx-react';
import Fade from 'components/Fade';
import Subheading from 'components/Subheading';
import DocumentsStore from 'stores/DocumentsStore';
import Document from 'models/Document';
import Backlink from './Backlink';
type Props = {
document: Document,
documents: DocumentsStore,
};
@observer
class Backlinks extends React.Component<Props> {
componentDidMount() {
this.props.documents.fetchBacklinks(this.props.document.id);
}
render() {
const { documents, document } = this.props;
const backlinks = documents.getBacklinedDocuments(document.id);
const showBacklinks = !!backlinks.length;
return (
showBacklinks && (
<Fade>
<Subheading>Referenced By</Subheading>
{backlinks.map(backlinkedDocument => (
<Backlink
anchor={document.urlId}
key={backlinkedDocument.id}
document={backlinkedDocument}
showCollection={
backlinkedDocument.collectionId !== document.collectionId
}
/>
))}
</Fade>
)
);
}
}
export default Backlinks;

View File

@ -1,8 +1,6 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
import Editor from 'components/Editor';
import Placeholder from 'rich-markdown-editor/lib/components/Placeholder';
import ClickablePadding from 'components/ClickablePadding';
import plugins from './plugins';
@ -33,7 +31,7 @@ class DocumentEditor extends React.Component<Props> {
return (
<React.Fragment>
<StyledEditor
<Editor
ref={ref => (this.editor = ref)}
plugins={plugins}
{...this.props}
@ -47,23 +45,4 @@ class DocumentEditor extends React.Component<Props> {
}
}
const StyledEditor = styled(Editor)`
justify-content: start;
> div {
transition: ${props => props.theme.backgroundTransition};
}
p {
${Placeholder} {
visibility: hidden;
}
}
p:nth-child(2):last-child {
${Placeholder} {
visibility: visible;
}
}
`;
export default DocumentEditor;

View File

@ -24,6 +24,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@observable recentlyViewedIds: string[] = [];
@observable searchCache: Map<string, SearchResult[]> = new Map();
@observable starredIds: Map<string, boolean> = new Map();
@observable backlinks: Map<string, string[]> = new Map();
constructor(rootStore: RootStore) {
super(rootStore, Document);
@ -140,6 +141,28 @@ export default class DocumentsStore extends BaseStore<Document> {
: undefined;
}
@action
fetchBacklinks = async (documentId: string): Promise<?(Document[])> => {
const res = await client.post(`/documents.list`, {
backlinkDocumentId: documentId,
});
invariant(res && res.data, 'Document list not available');
const { data } = res;
runInAction('DocumentsStore#fetchBacklinks', () => {
data.forEach(this.add);
this.backlinks.set(documentId, data.map(doc => doc.id));
});
};
getBacklinedDocuments(documentId: string): Document[] {
const documentIds = this.backlinks.get(documentId) || [];
return orderBy(
compact(documentIds.map(id => this.data.get(id))),
'updatedAt',
'desc'
);
}
@action
fetchNamedPage = async (
request: string = 'list',

View File

@ -9,10 +9,19 @@ import {
presentCollection,
presentRevision,
} from '../presenters';
import { Document, Collection, Share, Star, View, Revision } from '../models';
import {
Document,
Collection,
Share,
Star,
View,
Revision,
Backlink,
} from '../models';
import { InvalidRequestError } from '../errors';
import events from '../events';
import policy from '../policies';
import { sequelize } from '../sequelize';
const Op = Sequelize.Op;
const { authorize, cannot } = policy;
@ -22,6 +31,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
const { sort = 'updatedAt' } = ctx.body;
const collectionId = ctx.body.collection;
const createdById = ctx.body.user;
const backlinkDocumentId = ctx.body.backlinkDocumentId;
let direction = ctx.body.direction;
if (direction !== 'ASC') direction = 'DESC';
@ -50,6 +60,20 @@ router.post('documents.list', auth(), pagination(), async ctx => {
where = { ...where, collectionId: collectionIds };
}
if (backlinkDocumentId) {
const backlinks = await Backlink.findAll({
attributes: ['reverseDocumentId'],
where: {
documentId: backlinkDocumentId,
},
});
where = {
...where,
id: backlinks.map(backlink => backlink.reverseDocumentId),
};
}
// add the users starred state to the response by default
const starredScope = { method: ['withStarred', user.id] };
const documents = await Document.scope('defaultScope', starredScope).findAll({
@ -620,7 +644,7 @@ router.post('documents.update', auth(), async ctx => {
// Update document
if (title) document.title = title;
//append to document
if (append) {
document.text += text;
} else if (text) {
@ -628,28 +652,40 @@ router.post('documents.update', auth(), async ctx => {
}
document.lastModifiedById = user.id;
if (publish) {
await document.publish();
let transaction;
try {
transaction = await sequelize.transaction();
events.add({
name: 'documents.publish',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
} else {
await document.save({ autosave });
if (publish) {
await document.publish({ transaction });
await transaction.commit();
events.add({
name: 'documents.update',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
autosave,
done,
});
events.add({
name: 'documents.publish',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
} else {
await document.save({ autosave, transaction });
await transaction.commit();
events.add({
name: 'documents.update',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
autosave,
done,
});
}
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
ctx.body = {

View File

@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { Document, View, Star, Revision } from '../models';
import { Document, View, Star, Revision, Backlink } from '../models';
import { flushdb, seed } from '../test/support';
import {
buildShare,
@ -252,6 +252,31 @@ describe('#documents.list', async () => {
expect(body.data.length).toEqual(1);
});
it('should return backlinks', async () => {
const { user, document } = await seed();
const anotherDoc = await buildDocument({
title: 'another document',
text: 'random text',
userId: user.id,
teamId: user.teamId,
});
await Backlink.create({
reverseDocumentId: anotherDoc.id,
documentId: document.id,
userId: user.id,
});
const res = await server.post('/api/documents.list', {
body: { token: user.getJwtToken(), backlinkDocumentId: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(anotherDoc.id);
});
it('should require authentication', async () => {
const res = await server.post('/api/documents.list');
const body = await res.json();

View File

@ -6,7 +6,7 @@ import mailer from '../mailer';
type Invite = { name: string, email: string };
export default async function documentMover({
export default async function userInviter({
user,
invites,
}: {

View File

@ -0,0 +1,46 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('backlinks', {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'users',
},
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'documents',
},
},
reverseDocumentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'documents',
},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex('backlinks', ['documentId']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('backlinks');
await queryInterface.removeIndex('backlinks', ['documentId']);
},
};

27
server/models/Backlink.js Normal file
View File

@ -0,0 +1,27 @@
// @flow
import { DataTypes, sequelize } from '../sequelize';
const Backlink = sequelize.define('backlink', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
});
Backlink.associate = models => {
Backlink.belongsTo(models.Document, {
as: 'document',
foreignKey: 'documentId',
});
Backlink.belongsTo(models.Document, {
as: 'reverseDocument',
foreignKey: 'reverseDocumentId',
});
Backlink.belongsTo(models.User, {
as: 'user',
foreignKey: 'userId',
});
};
export default Backlink;

View File

@ -32,12 +32,17 @@ const createRevision = (doc, options = {}) => {
// we don't create revisions if identical to previous
if (doc.text === doc.previous('text')) return;
return Revision.create({
title: doc.title,
text: doc.text,
userId: doc.lastModifiedById,
documentId: doc.id,
});
return Revision.create(
{
title: doc.title,
text: doc.text,
userId: doc.lastModifiedById,
documentId: doc.id,
},
{
transaction: options.transaction,
}
);
};
const createUrlId = doc => {
@ -141,6 +146,9 @@ Document.associate = models => {
as: 'revisions',
onDelete: 'cascade',
});
Document.hasMany(models.Backlink, {
as: 'backlinks',
});
Document.hasMany(models.Star, {
as: 'starred',
});
@ -363,16 +371,16 @@ Document.prototype.archiveWithChildren = async function(userId, options) {
return this.save(options);
};
Document.prototype.publish = async function() {
if (this.publishedAt) return this.save();
Document.prototype.publish = async function(options) {
if (this.publishedAt) return this.save(options);
const collection = await Collection.findByPk(this.collectionId);
if (collection.type !== 'atlas') return this.save();
if (collection.type !== 'atlas') return this.save(options);
await collection.addDocumentToStructure(this);
this.publishedAt = new Date();
await this.save();
await this.save(options);
this.collection = collection;
return this;

View File

@ -1,6 +1,7 @@
// @flow
import ApiKey from './ApiKey';
import Authentication from './Authentication';
import Backlink from './Backlink';
import Collection from './Collection';
import CollectionUser from './CollectionUser';
import Document from './Document';
@ -18,6 +19,7 @@ import View from './View';
const models = {
ApiKey,
Authentication,
Backlink,
Collection,
CollectionUser,
Document,
@ -43,6 +45,7 @@ Object.keys(models).forEach(modelName => {
export {
ApiKey,
Authentication,
Backlink,
Collection,
CollectionUser,
Document,

View File

@ -219,6 +219,11 @@ export default function Api() {
id="collection"
description="Collection ID to filter by"
/>
<Argument id="user" description="User ID to filter by" />
<Argument
id="backlinkDocumentId"
description="Backlinked document ID to filter by"
/>
</Arguments>
</Method>

View File

@ -0,0 +1,97 @@
// @flow
import { difference } from 'lodash';
import type { DocumentEvent } from '../events';
import { Document, Revision, Backlink } from '../models';
import parseDocumentIds from '../../shared/utils/parseDocumentIds';
export default class Backlinks {
async on(event: DocumentEvent) {
switch (event.name) {
case 'documents.publish': {
const document = await Document.findByPk(event.modelId);
const linkIds = parseDocumentIds(document.text);
await Promise.all(
linkIds.map(async linkId => {
const linkedDocument = await Document.findByPk(linkId);
if (linkedDocument.id === event.modelId) return;
await Backlink.findOrCreate({
where: {
documentId: linkedDocument.id,
reverseDocumentId: event.modelId,
},
defaults: {
userId: document.lastModifiedById,
},
});
})
);
break;
}
case 'documents.update': {
// no-op for now
if (event.autosave) return;
// no-op for drafts
const document = await Document.findByPk(event.modelId);
if (!document.publishedAt) return;
const [currentRevision, previsionRevision] = await Revision.findAll({
where: { documentId: event.modelId },
order: [['createdAt', 'desc']],
limit: 2,
});
const previousLinkIds = parseDocumentIds(previsionRevision.text);
const currentLinkIds = parseDocumentIds(currentRevision.text);
const addedLinkIds = difference(currentLinkIds, previousLinkIds);
const removedLinkIds = difference(previousLinkIds, currentLinkIds);
await Promise.all(
addedLinkIds.map(async linkId => {
const linkedDocument = await Document.findByPk(linkId);
if (linkedDocument.id === event.modelId) return;
await Backlink.findOrCreate({
where: {
documentId: linkedDocument.id,
reverseDocumentId: event.modelId,
},
defaults: {
userId: currentRevision.userId,
},
});
})
);
await Promise.all(
removedLinkIds.map(async linkId => {
const document = await Document.findByPk(linkId);
await Backlink.destroy({
where: {
documentId: document.id,
reverseDocumentId: event.modelId,
},
});
})
);
break;
}
case 'documents.delete': {
await Backlink.destroy({
where: {
reverseDocumentId: event.modelId,
},
});
await Backlink.destroy({
where: {
documentId: event.modelId,
},
});
break;
}
default:
}
}
}

View File

@ -22,6 +22,7 @@ const colors = {
black50: 'rgba(0, 0, 0, 0.50)',
primary: '#1AB6FF',
yellow: '#FBCA04',
warmGrey: '#EDF2F7',
danger: '#D0021B',
warning: '#f08a24',
@ -44,7 +45,6 @@ export const base = {
fontFamily:
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif",
fontWeight: 400,
link: colors.primary,
backgroundTransition: 'background 100ms ease-in-out',
zIndex: 100,
};
@ -53,12 +53,13 @@ export const light = {
...base,
background: colors.white,
link: colors.almostBlack,
text: colors.almostBlack,
textSecondary: colors.slateDark,
textTertiary: colors.slate,
placeholder: '#B1BECC',
sidebarBackground: 'rgb(244, 247, 250)',
sidebarBackground: colors.warmGrey,
sidebarItemBackground: colors.black05,
sidebarText: 'rgb(78, 92, 110)',
@ -69,8 +70,7 @@ export const light = {
inputBorder: colors.slateLight,
inputBorderFocused: colors.slate,
listItemHoverBackground: colors.smoke,
listItemHoverBorder: colors.smokeDark,
listItemHoverBackground: colors.warmGrey,
toolbarBackground: colors.lightBlack,
toolbarInput: colors.white10,
@ -104,6 +104,7 @@ export const dark = {
...base,
background: colors.almostBlack,
link: colors.almostWhite,
text: colors.almostWhite,
textSecondary: lighten(0.2, colors.slate),
textTertiary: colors.slate,
@ -121,7 +122,6 @@ export const dark = {
inputBorderFocused: colors.slate,
listItemHoverBackground: colors.black50,
listItemHoverBorder: colors.black50,
toolbarBackground: colors.white,
toolbarInput: colors.black10,

View File

@ -0,0 +1,29 @@
// @flow
import MarkdownSerializer from 'slate-md-serializer';
const Markdown = new MarkdownSerializer();
export default function parseDocumentIds(text: string) {
const value = Markdown.deserialize(text);
let links = [];
function findLinks(node) {
if (node.type === 'link') {
const href = node.data.get('href');
if (href.startsWith('/doc')) {
const tokens = href.replace(/\/$/, '').split('/');
const lastToken = tokens[tokens.length - 1];
links.push(lastToken);
}
}
if (!node.nodes) {
return;
}
node.nodes.forEach(findLinks);
}
findLinks(value.document);
return links;
}

View File

@ -0,0 +1,20 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import parseDocumentIds from './parseDocumentIds';
it('should return an array of document ids', () => {
expect(parseDocumentIds(`# Header`).length).toBe(0);
expect(
parseDocumentIds(`# Header
[title](/doc/test-456733)
`)[0]
).toBe('test-456733');
});
it('should not return non document links', () => {
expect(parseDocumentIds(`[title](http://www.google.com)`).length).toBe(0);
});
it('should not return non document relative links', () => {
expect(parseDocumentIds(`[title](/developers)`).length).toBe(0);
});