Merge branch 'master' into inputs

This commit is contained in:
Tom Moor 2017-06-27 21:53:11 -07:00
commit ab43e2af54
No known key found for this signature in database
GPG Key ID: 495FE29B5F21BD41
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:
```
yarn run sequelize -- migration:create
yarn run sequelize -- db:migrate
yarn run sequelize migration:create
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
key={this.props.starred}
ref={ref => (this.editor = ref)}
placeholder="Start with a title"
placeholder="Start with a title..."
className={cx(styles.editor, { readOnly: this.props.readOnly })}
schema={this.schema}
plugins={this.plugins}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import { NavLink } from 'react-router-dom';
import { NavLink, withRouter } from 'react-router-dom';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
@ -9,11 +9,20 @@ const activeStyle = {
color: '#000000',
};
const SidebarLink = observer(props => (
<LinkContainer>
<NavLink {...props} activeStyle={activeStyle} />
</LinkContainer>
));
@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>
<NavLink exact {...this.props} activeStyle={activeStyle} />
</LinkContainer>
);
}
}
const LinkContainer = styled(Flex)`
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');
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');
} catch (e) {
console.log(e);

View File

@ -44,18 +44,27 @@ type Props = {
}
componentDidMount() {
if (this.props.newDocument) {
this.store.collectionId = this.props.match.params.id;
this.loadDocument(this.props);
}
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;
} else if (this.props.match.params.edit) {
this.store.documentId = this.props.match.params.id;
} else if (props.match.params.edit) {
this.store.documentId = props.match.params.id;
this.store.fetchDocument();
} else if (this.props.newChildDocument) {
this.store.documentId = this.props.match.params.id;
} else if (props.newChildDocument) {
this.store.documentId = props.match.params.id;
this.store.newChildDocument = true;
this.store.fetchDocument();
} else {
this.store.documentId = this.props.match.params.id;
this.store.documentId = props.match.params.id;
this.store.newDocument = false;
this.store.fetchDocument();
}
@ -64,7 +73,7 @@ type Props = {
}
componentWillUnmount() {
this.props.ui.clearActiveCollection();
this.props.ui.clearActiveDocument();
}
onEdit = () => {
@ -117,32 +126,31 @@ type Props = {
);
return (
<Container flex column>
<Container column auto>
{titleText && <PageTitle title={titleText} />}
<Prompt when={this.store.hasPendingChanges} message={DISCARD_CHANGES} />
<PagePadding auto justify="center">
<PageTitle title={titleText} />
<Prompt
when={this.store.hasPendingChanges}
message={DISCARD_CHANGES}
/>
{this.store.isFetching &&
<CenteredContent>
<PreviewLoading />
</CenteredContent>}
{this.store.document &&
<DocumentContainer>
<Editor
text={this.store.document.text}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onChange={this.store.updateText}
onSave={this.onSave}
onCancel={this.onCancel}
onStar={this.store.starDocument}
onUnstar={this.store.unstarDocument}
starred={this.store.document.starred}
readOnly={!isEditing}
/>
</DocumentContainer>}
{this.store.isFetching
? <CenteredContent>
<PreviewLoading />
</CenteredContent>
: this.store.document &&
<DocumentContainer>
<Editor
key={this.store.document.id}
text={this.store.document.text}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onChange={this.store.updateText}
onSave={this.onSave}
onCancel={this.onCancel}
onStar={this.store.starDocument}
onUnstar={this.store.unstarDocument}
starred={this.store.document.starred}
readOnly={!isEditing}
/>
</DocumentContainer>}
</PagePadding>
{this.store.document &&
<Meta align="center" readOnly={!isEditing}>

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 { client } from 'utils/ApiClient';
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 };
@ -24,13 +25,14 @@ const parseHeader = text => {
type Options = {
history: Object,
ui: UiStore,
};
class DocumentStore {
document: Document;
@observable collapsedNodes: string[] = [];
@observable documentId = null;
@observable collectionId = null;
@observable document: Document;
@observable parentDocument: Document;
@observable hasPendingChanges = false;
@observable newDocument: ?boolean;
@ -42,6 +44,7 @@ class DocumentStore {
@observable isUploading: boolean = false;
history: Object;
ui: UiStore;
/* Computed */
@ -49,29 +52,6 @@ class DocumentStore {
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 */
@action starDocument = async () => {
@ -108,18 +88,15 @@ class DocumentStore {
this.isFetching = true;
try {
const res = await client.get(
'/documents.info',
{
id: this.documentId,
},
{ cache: true }
);
const res = await client.get('/documents.info', {
id: this.documentId,
});
invariant(res && res.data, 'Data should be available');
if (this.newChildDocument) {
this.parentDocument = res.data;
} else {
this.document = res.data;
this.document = new Document(res.data);
this.ui.setActiveDocument(this.document);
}
} catch (e) {
console.error('Something went wrong');
@ -133,20 +110,16 @@ class DocumentStore {
this.isSaving = true;
try {
const res = await client.post(
'/documents.create',
{
parentDocument: get(this.parentDocument, 'id'),
collection: get(
this.parentDocument,
'collection.id',
this.collectionId
),
title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'),
},
{ cache: true }
);
const res = await client.post('/documents.create', {
parentDocument: get(this.parentDocument, 'id'),
collection: get(
this.parentDocument,
'collection.id',
this.collectionId
),
title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'),
});
invariant(res && res.data, 'Data should be available');
const { url } = res.data;
@ -164,15 +137,11 @@ class DocumentStore {
this.isSaving = true;
try {
const res = await client.post(
'/documents.update',
{
id: this.documentId,
title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'),
},
{ cache: true }
);
const res = await client.post('/documents.update', {
id: this.documentId,
title: get(this.document, 'title', 'Untitled document'),
text: get(this.document, 'text'),
});
invariant(res && res.data, 'Data should be available');
const { url } = res.data;
@ -210,6 +179,7 @@ class DocumentStore {
constructor(options: Options) {
this.history = options.history;
this.ui = options.ui;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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