diff --git a/README.md b/README.md index 4306f2cf..b0154c0b 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 9dab713a..125b1f43 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -117,7 +117,7 @@ type KeyData = { (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} diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 4890fc96..af07a7e1 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -59,7 +59,7 @@ type Props = { }; render() { - const { user, auth, ui, collections } = this.props; + const { user, auth, ui } = this.props; return ( @@ -112,7 +112,8 @@ type Props = { {ui.activeCollection ? : } diff --git a/frontend/components/Layout/components/SidebarCollection/SidebarCollection.js b/frontend/components/Layout/components/SidebarCollection/SidebarCollection.js index de6a1244..82822118 100644 --- a/frontend/components/Layout/components/SidebarCollection/SidebarCollection.js +++ b/frontend/components/Layout/components/SidebarCollection/SidebarCollection.js @@ -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 ( - -
{collection.name}
- {collection.documents.map(document => ( - - {document.title} +class SidebarCollection extends React.Component { + props: Props; + + renderDocuments(documentList) { + const { document } = this.props; + + if (document) { + return documentList.map(doc => ( + + + {doc.title} - ))} - - ); + {(document.pathToDocument.includes(doc.id) || + document.id === doc.id) && + + {doc.children && this.renderDocuments(doc.children)} + } +
+ )); + } } - return null; -}; + + render() { + const { collection } = this.props; + + if (collection) { + return ( + +
{collection.name}
+ {this.renderDocuments(collection.documents)} +
+ ); + } + 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); diff --git a/frontend/components/Layout/components/SidebarLink/SidebarLink.js b/frontend/components/Layout/components/SidebarLink/SidebarLink.js index 73543635..df60708c 100644 --- a/frontend/components/Layout/components/SidebarLink/SidebarLink.js +++ b/frontend/components/Layout/components/SidebarLink/SidebarLink.js @@ -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 => ( - - - -)); +@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 ( + + + + ); + } +} const LinkContainer = styled(Flex)` padding: 5px 0; @@ -23,4 +32,4 @@ const LinkContainer = styled(Flex)` } `; -export default SidebarLink; +export default withRouter(SidebarLink); diff --git a/frontend/models/Document.js b/frontend/models/Document.js new file mode 100644 index 00000000..1c69baee --- /dev/null +++ b/frontend/models/Document.js @@ -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; + 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 { + 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; diff --git a/frontend/scenes/Collection/CollectionStore.js b/frontend/scenes/Collection/CollectionStore.js index 338fdfa6..e10870e7 100644 --- a/frontend/scenes/Collection/CollectionStore.js +++ b/frontend/scenes/Collection/CollectionStore.js @@ -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); diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index 4a219c8f..a535432f 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -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 ( - + + {titleText && } + + - - - {this.store.isFetching && - - - } - {this.store.document && - - - } + {this.store.isFetching + ? + + + : this.store.document && + + + } {this.store.document && diff --git a/frontend/scenes/Document/Document.scss b/frontend/scenes/Document/Document.scss deleted file mode 100644 index c31a12f3..00000000 --- a/frontend/scenes/Document/Document.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import '~styles/constants.scss'; - -.container { - display: flex; - position: fixed; - justify-content: center; - top: $headerHeight; - bottom: 0; - left: 0; - right: 0; -} diff --git a/frontend/scenes/Document/DocumentStore.js b/frontend/scenes/Document/DocumentStore.js index 1a146e41..bcc59500 100644 --- a/frontend/scenes/Document/DocumentStore.js +++ b/frontend/scenes/Document/DocumentStore.js @@ -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 { - 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; } } diff --git a/frontend/stores/UiStore.js b/frontend/stores/UiStore.js index 4a3bb7aa..74068d0d 100644 --- a/frontend/stores/UiStore.js +++ b/frontend/stores/UiStore.js @@ -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() { diff --git a/frontend/stores/index.js b/frontend/stores/index.js index 70eb0853..ccfaa9c8 100644 --- a/frontend/stores/index.js +++ b/frontend/stores/index.js @@ -9,5 +9,6 @@ const stores = { ui: new UiStore(), errors: new ErrorsStore(), }; +window.stores = stores; export default stores; diff --git a/server/api/middlewares/validation.js b/server/api/middlewares/validation.js index 4078453f..874cbecf 100644 --- a/server/api/middlewares/validation.js +++ b/server/api/middlewares/validation.js @@ -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); diff --git a/server/migrations/20170601032359-add-views.js b/server/migrations/20170604052346-add-views.js similarity index 100% rename from server/migrations/20170601032359-add-views.js rename to server/migrations/20170604052346-add-views.js diff --git a/server/models/User.js b/server/models/User.js index 96dd5a4a..2b09a268 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -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' }); }, diff --git a/server/presenters/collection.js b/server/presenters/collection.js index 59423db1..8dc1777d 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -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({