Merge branch 'master' into inputs

This commit is contained in:
Tom Moor
2017-06-27 21:53:11 -07:00
16 changed files with 237 additions and 136 deletions

View File

@ -14,6 +14,12 @@
Sequelize is used to create and run migrations, for example: Sequelize is used to create and run migrations, for example:
``` ```
yarn run sequelize -- migration:create yarn run sequelize migration:create
yarn run sequelize -- db:migrate yarn run sequelize db:migrate
```
Or to run migrations on test database:
```
yarn run sequelize db:migrate -- --env test
``` ```

View File

@ -117,7 +117,7 @@ type KeyData = {
<Editor <Editor
key={this.props.starred} key={this.props.starred}
ref={ref => (this.editor = ref)} ref={ref => (this.editor = ref)}
placeholder="Start with a title" placeholder="Start with a title..."
className={cx(styles.editor, { readOnly: this.props.readOnly })} className={cx(styles.editor, { readOnly: this.props.readOnly })}
schema={this.schema} schema={this.schema}
plugins={this.plugins} plugins={this.plugins}

View File

@ -59,7 +59,7 @@ type Props = {
}; };
render() { render() {
const { user, auth, ui, collections } = this.props; const { user, auth, ui } = this.props;
return ( return (
<Container column auto> <Container column auto>
@ -112,7 +112,8 @@ type Props = {
<LinkSection> <LinkSection>
{ui.activeCollection {ui.activeCollection
? <SidebarCollection ? <SidebarCollection
collection={collections.getById(ui.activeCollection)} document={ui.activeDocument}
collection={ui.activeCollection}
/> />
: <SidebarCollectionList />} : <SidebarCollectionList />}
</LinkSection> </LinkSection>

View File

@ -7,26 +7,49 @@ import styled from 'styled-components';
import SidebarLink from '../SidebarLink'; import SidebarLink from '../SidebarLink';
import Collection from 'models/Collection'; import Collection from 'models/Collection';
import Document from 'models/Document';
type Props = { type Props = {
collection: Collection, collection: ?Collection,
document: ?Document,
}; };
const SidebarCollection = ({ collection }: Props) => { class SidebarCollection extends React.Component {
props: Props;
renderDocuments(documentList) {
const { document } = this.props;
if (document) {
return documentList.map(doc => (
<Flex column key={doc.id}>
<SidebarLink key={doc.id} to={doc.url}>
{doc.title}
</SidebarLink>
{(document.pathToDocument.includes(doc.id) ||
document.id === doc.id) &&
<Children>
{doc.children && this.renderDocuments(doc.children)}
</Children>}
</Flex>
));
}
}
render() {
const { collection } = this.props;
if (collection) { if (collection) {
return ( return (
<Flex column> <Flex column>
<Header>{collection.name}</Header> <Header>{collection.name}</Header>
{collection.documents.map(document => ( {this.renderDocuments(collection.documents)}
<SidebarLink key={document.id} to={document.url}>
{document.title}
</SidebarLink>
))}
</Flex> </Flex>
); );
} }
return null; return null;
}; }
}
const Header = styled(Flex)` const Header = styled(Flex)`
font-size: 11px; font-size: 11px;
@ -36,4 +59,8 @@ const Header = styled(Flex)`
letter-spacing: 0.04em; letter-spacing: 0.04em;
`; `;
const Children = styled(Flex)`
margin-left: 20px;
`;
export default observer(SidebarCollection); export default observer(SidebarCollection);

View File

@ -1,7 +1,7 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { NavLink } from 'react-router-dom'; import { NavLink, withRouter } from 'react-router-dom';
import { Flex } from 'reflexbox'; import { Flex } from 'reflexbox';
import styled from 'styled-components'; import styled from 'styled-components';
@ -9,11 +9,20 @@ const activeStyle = {
color: '#000000', color: '#000000',
}; };
const SidebarLink = observer(props => ( @observer class SidebarLink extends React.Component {
shouldComponentUpdate(nextProps) {
// Navlink is having issues updating, forcing update on URL changes
return this.props.match !== nextProps.match;
}
render() {
return (
<LinkContainer> <LinkContainer>
<NavLink {...props} activeStyle={activeStyle} /> <NavLink exact {...this.props} activeStyle={activeStyle} />
</LinkContainer> </LinkContainer>
)); );
}
}
const LinkContainer = styled(Flex)` const LinkContainer = styled(Flex)`
padding: 5px 0; padding: 5px 0;
@ -23,4 +32,4 @@ const LinkContainer = styled(Flex)`
} }
`; `;
export default SidebarLink; export default withRouter(SidebarLink);

View File

@ -0,0 +1,83 @@
// @flow
import { extendObservable, action, runInAction, computed } from 'mobx';
import invariant from 'invariant';
import ApiClient, { client } from 'utils/ApiClient';
import stores from 'stores';
import ErrorsStore from 'stores/ErrorsStore';
import type { User } from 'types';
import Collection from './Collection';
class Document {
collaborators: Array<User>;
collection: Collection;
createdAt: string;
createdBy: User;
html: string;
id: string;
private: boolean;
starred: boolean;
team: string;
text: string;
title: string;
updatedAt: string;
updatedBy: User;
url: string;
views: number;
client: ApiClient;
errors: ErrorsStore;
/* Computed */
@computed get pathToDocument(): Array<string> {
let path;
const traveler = (nodes, previousPath) => {
nodes.forEach(childNode => {
const newPath = [...previousPath, childNode.id];
if (childNode.id === this.id) {
path = newPath;
return;
} else {
return traveler(childNode.children, newPath);
}
});
};
if (this.collection.documents) {
traveler(this.collection.documents, []);
invariant(path, 'Path is not available for collection, abort');
return path;
}
return [];
}
/* Actions */
@action update = async () => {
try {
const res = await this.client.post('/documents.info', { id: this.id });
invariant(res && res.data, 'Document API response should be available');
const { data } = res;
runInAction('Document#update', () => {
this.updateData(data);
});
} catch (e) {
this.errors.add('Document failed loading');
}
};
updateData(data: Document) {
extendObservable(this, data);
}
constructor(document: Document) {
this.updateData(document);
this.client = client;
this.errors = stores.errors;
}
}
export default Document;

View File

@ -18,7 +18,7 @@ class CollectionStore {
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
const { data } = res; const { data } = res;
if (data.type === 'atlas') this.redirectUrl = data.recentDocuments[0].url; if (data.type === 'atlas') this.redirectUrl = data.documents[0].url;
else throw new Error('TODO code up non-atlas collections'); else throw new Error('TODO code up non-atlas collections');
} catch (e) { } catch (e) {
console.log(e); console.log(e);

View File

@ -44,18 +44,27 @@ type Props = {
} }
componentDidMount() { componentDidMount() {
if (this.props.newDocument) { this.loadDocument(this.props);
this.store.collectionId = this.props.match.params.id; }
componentWillReceiveProps(nextProps) {
if (nextProps.match.params.id !== this.props.match.params.id)
this.loadDocument(nextProps);
}
loadDocument(props) {
if (props.newDocument) {
this.store.collectionId = props.match.params.id;
this.store.newDocument = true; this.store.newDocument = true;
} else if (this.props.match.params.edit) { } else if (props.match.params.edit) {
this.store.documentId = this.props.match.params.id; this.store.documentId = props.match.params.id;
this.store.fetchDocument(); this.store.fetchDocument();
} else if (this.props.newChildDocument) { } else if (props.newChildDocument) {
this.store.documentId = this.props.match.params.id; this.store.documentId = props.match.params.id;
this.store.newChildDocument = true; this.store.newChildDocument = true;
this.store.fetchDocument(); this.store.fetchDocument();
} else { } else {
this.store.documentId = this.props.match.params.id; this.store.documentId = props.match.params.id;
this.store.newDocument = false; this.store.newDocument = false;
this.store.fetchDocument(); this.store.fetchDocument();
} }
@ -64,7 +73,7 @@ type Props = {
} }
componentWillUnmount() { componentWillUnmount() {
this.props.ui.clearActiveCollection(); this.props.ui.clearActiveDocument();
} }
onEdit = () => { onEdit = () => {
@ -117,20 +126,19 @@ type Props = {
); );
return ( return (
<Container flex column> <Container column auto>
{titleText && <PageTitle title={titleText} />}
<Prompt when={this.store.hasPendingChanges} message={DISCARD_CHANGES} />
<PagePadding auto justify="center"> <PagePadding auto justify="center">
<PageTitle title={titleText} /> {this.store.isFetching
<Prompt ? <CenteredContent>
when={this.store.hasPendingChanges}
message={DISCARD_CHANGES}
/>
{this.store.isFetching &&
<CenteredContent>
<PreviewLoading /> <PreviewLoading />
</CenteredContent>} </CenteredContent>
{this.store.document && : this.store.document &&
<DocumentContainer> <DocumentContainer>
<Editor <Editor
key={this.store.document.id}
text={this.store.document.text} text={this.store.document.text}
onImageUploadStart={this.onImageUploadStart} onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop} onImageUploadStop={this.onImageUploadStop}

View File

@ -1,11 +0,0 @@
@import '~styles/constants.scss';
.container {
display: flex;
position: fixed;
justify-content: center;
top: $headerHeight;
bottom: 0;
left: 0;
right: 0;
}

View File

@ -4,7 +4,8 @@ import get from 'lodash/get';
import invariant from 'invariant'; import invariant from 'invariant';
import { client } from 'utils/ApiClient'; import { client } from 'utils/ApiClient';
import emojify from 'utils/emojify'; import emojify from 'utils/emojify';
import type { Document, NavigationNode } from 'types'; import Document from 'models/Document';
import UiStore from 'stores/UiStore';
type SaveProps = { redirect?: boolean }; type SaveProps = { redirect?: boolean };
@ -24,13 +25,14 @@ const parseHeader = text => {
type Options = { type Options = {
history: Object, history: Object,
ui: UiStore,
}; };
class DocumentStore { class DocumentStore {
document: Document;
@observable collapsedNodes: string[] = []; @observable collapsedNodes: string[] = [];
@observable documentId = null; @observable documentId = null;
@observable collectionId = null; @observable collectionId = null;
@observable document: Document;
@observable parentDocument: Document; @observable parentDocument: Document;
@observable hasPendingChanges = false; @observable hasPendingChanges = false;
@observable newDocument: ?boolean; @observable newDocument: ?boolean;
@ -42,6 +44,7 @@ class DocumentStore {
@observable isUploading: boolean = false; @observable isUploading: boolean = false;
history: Object; history: Object;
ui: UiStore;
/* Computed */ /* Computed */
@ -49,29 +52,6 @@ class DocumentStore {
return !!this.document && this.document.collection.type === 'atlas'; return !!this.document && this.document.collection.type === 'atlas';
} }
@computed get pathToDocument(): Array<NavigationNode> {
let path;
const traveler = (nodes, previousPath) => {
nodes.forEach(childNode => {
const newPath = [...previousPath, childNode];
if (childNode.id === this.document.id) {
path = previousPath;
return;
} else {
return traveler(childNode.chilren, newPath);
}
});
};
if (this.document && this.document.collection.documents) {
traveler(this.document.collection.documents, []);
invariant(path, 'Path is not available for collection, abort');
return path.splice(1);
}
return [];
}
/* Actions */ /* Actions */
@action starDocument = async () => { @action starDocument = async () => {
@ -108,18 +88,15 @@ class DocumentStore {
this.isFetching = true; this.isFetching = true;
try { try {
const res = await client.get( const res = await client.get('/documents.info', {
'/documents.info',
{
id: this.documentId, id: this.documentId,
}, });
{ cache: true }
);
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
if (this.newChildDocument) { if (this.newChildDocument) {
this.parentDocument = res.data; this.parentDocument = res.data;
} else { } else {
this.document = res.data; this.document = new Document(res.data);
this.ui.setActiveDocument(this.document);
} }
} catch (e) { } catch (e) {
console.error('Something went wrong'); console.error('Something went wrong');
@ -133,9 +110,7 @@ class DocumentStore {
this.isSaving = true; this.isSaving = true;
try { try {
const res = await client.post( const res = await client.post('/documents.create', {
'/documents.create',
{
parentDocument: get(this.parentDocument, 'id'), parentDocument: get(this.parentDocument, 'id'),
collection: get( collection: get(
this.parentDocument, this.parentDocument,
@ -144,9 +119,7 @@ class DocumentStore {
), ),
title: get(this.document, 'title', 'Untitled document'), title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'), text: get(this.document, 'text'),
}, });
{ cache: true }
);
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
const { url } = res.data; const { url } = res.data;
@ -164,15 +137,11 @@ class DocumentStore {
this.isSaving = true; this.isSaving = true;
try { try {
const res = await client.post( const res = await client.post('/documents.update', {
'/documents.update',
{
id: this.documentId, id: this.documentId,
title: get(this.document, 'title', 'Untitled document'), title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'), text: get(this.document, 'text'),
}, });
{ cache: true }
);
invariant(res && res.data, 'Data should be available'); invariant(res && res.data, 'Data should be available');
const { url } = res.data; const { url } = res.data;
@ -210,6 +179,7 @@ class DocumentStore {
constructor(options: Options) { constructor(options: Options) {
this.history = options.history; this.history = options.history;
this.ui = options.ui;
} }
} }

View File

@ -1,18 +1,26 @@
// @flow // @flow
import { observable, action } from 'mobx'; import { observable, action, computed } from 'mobx';
import type { Document } from 'types';
import Collection from 'models/Collection';
class UiStore { class UiStore {
@observable activeCollection: ?string; @observable activeDocument: ?Document;
@observable editMode: boolean = false; @observable editMode: boolean = false;
/* Computed */
@computed get activeCollection(): ?Collection {
return this.activeDocument ? this.activeDocument.collection : undefined;
}
/* Actions */ /* Actions */
@action setActiveCollection = (id: string): void => { @action setActiveDocument = (document: Document): void => {
this.activeCollection = id; this.activeDocument = document;
}; };
@action clearActiveCollection = (): void => { @action clearActiveDocument = (): void => {
this.activeCollection = null; this.activeDocument = undefined;
}; };
@action enableEditMode() { @action enableEditMode() {

View File

@ -9,5 +9,6 @@ const stores = {
ui: new UiStore(), ui: new UiStore(),
errors: new ErrorsStore(), errors: new ErrorsStore(),
}; };
window.stores = stores;
export default stores; export default stores;

View File

@ -3,7 +3,7 @@ import apiError from '../../errors';
import validator from 'validator'; import validator from 'validator';
export default function validation() { export default function validation() {
return function validationMiddleware(ctx, next) { return function validationMiddleware(ctx: Object, next: Function) {
ctx.assertPresent = function assertPresent(value, message) { ctx.assertPresent = function assertPresent(value, message) {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
throw apiError(400, 'validation_error', message); throw apiError(400, 'validation_error', message);

View File

@ -29,7 +29,6 @@ const User = sequelize.define(
classMethods: { classMethods: {
associate: models => { associate: models => {
User.hasMany(models.ApiKey, { as: 'apiKeys' }); User.hasMany(models.ApiKey, { as: 'apiKeys' });
User.hasMany(models.Collection, { as: 'collections' });
User.hasMany(models.Document, { as: 'documents' }); User.hasMany(models.Document, { as: 'documents' });
User.hasMany(models.View, { as: 'views' }); User.hasMany(models.View, { as: 'views' });
}, },

View File

@ -16,7 +16,7 @@ async function present(ctx, collection, includeRecentDocuments = false) {
}; };
if (collection.type === 'atlas') if (collection.type === 'atlas')
data.navigationTree = collection.navigationTree; data.documents = await collection.getDocumentsStructure();
if (includeRecentDocuments) { if (includeRecentDocuments) {
const documents = await Document.findAll({ const documents = await Document.findAll({