From 4430a3129e95edf30bcf80e9a3b3fd5baed88c57 Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Thu, 19 May 2016 20:46:34 -0700 Subject: [PATCH] Saving and fetching of documents --- server/api/documents.js | 63 ++++++++++++++++++++++ server/api/index.js | 2 + server/models/Document.js | 8 +-- server/models/index.js | 7 ++- server/presenters.js | 26 ++++++++- src/Reducers/index.js | 2 + src/actions/DocumentActions.js | 63 ++++++++++++++++++++++ src/index.js | 4 +- src/reducers/document.js | 63 ++++++++++++++++++++++ src/scenes/Document/Document.js | 58 ++++++++++++++++++++ src/scenes/Document/Document.scss | 0 src/scenes/Document/index.js | 2 + src/scenes/Editor/Editor.js | 27 +++++++++- src/scenes/Editor/components/SaveAction.js | 23 +++++--- 14 files changed, 332 insertions(+), 16 deletions(-) create mode 100644 server/api/documents.js create mode 100644 src/actions/DocumentActions.js create mode 100644 src/reducers/document.js create mode 100644 src/scenes/Document/Document.js create mode 100644 src/scenes/Document/Document.scss create mode 100644 src/scenes/Document/index.js diff --git a/server/api/documents.js b/server/api/documents.js new file mode 100644 index 000000000..ee4fde880 --- /dev/null +++ b/server/api/documents.js @@ -0,0 +1,63 @@ +import Router from 'koa-router'; +import httpErrors from 'http-errors'; + +import auth from './authentication'; +import pagination from './middlewares/pagination'; +import { presentDocument } from '../presenters'; +import { Document, Atlas } from '../models'; + +const router = new Router(); + +router.post('documents.info', auth(), async (ctx) => { + let { id } = ctx.request.body; + ctx.assertPresent(id, 'id is required'); + + const team = await ctx.state.user.getTeam(); + const document = await Document.findOne({ + where: { + id: id, + teamId: team.id, + }, + }); + + if (!document) throw httpErrors.NotFound(); + + ctx.body = { + data: await presentDocument(document, true), + }; +}); + + +router.post('documents.create', auth(), async (ctx) => { + let { + atlas, + title, + text, + } = ctx.request.body; + ctx.assertPresent(atlas, 'atlas is required'); + ctx.assertPresent(title, 'title is required'); + ctx.assertPresent(text, 'text is required'); + + const team = await ctx.state.user.getTeam(); + const ownerAtlas = await Atlas.findOne({ + where: { + id: atlas, + teamId: team.id, + }, + }); + + if (!ownerAtlas) throw httpErrors.BadRequest(); + + const document = await Document.create({ + atlasId: ownerAtlas.id, + teamId: team.id, + title: title, + text: text, + }); + + ctx.body = { + data: await presentDocument(document, true), + }; +}); + +export default router; \ No newline at end of file diff --git a/server/api/index.js b/server/api/index.js index 3e0e9d072..302eb1953 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -7,6 +7,7 @@ import Sequelize from 'sequelize'; import auth from './auth'; import user from './user'; import atlases from './atlases'; +import documents from './documents'; import validation from './validation'; @@ -44,6 +45,7 @@ api.use(validation()); router.use('/', auth.routes()); router.use('/', user.routes()); router.use('/', atlases.routes()); +router.use('/', documents.routes()); // Router is embedded in a Koa application wrapper, because koa-router does not // allow middleware to catch any routes which were not explicitly defined. diff --git a/server/models/Document.js b/server/models/Document.js index f810dec4a..11342f834 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -7,11 +7,11 @@ import Team from './Team'; const Document = sequelize.define('document', { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, - content: DataTypes.STRING, + title: DataTypes.STRING, + text: DataTypes.TEXT, }); -Document.belongsTo(Atlas); +Document.belongsTo(Atlas, { as: 'atlas' }); Document.belongsTo(Team); -export default Atlas; +export default Document; diff --git a/server/models/index.js b/server/models/index.js index c154204d1..b2dc15375 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -3,4 +3,9 @@ import Team from './Team'; import Atlas from './Atlas'; import Document from './Document'; -export { User, Team, Atlas, Document }; \ No newline at end of file +export { + User, + Team, + Atlas, + Document, +}; \ No newline at end of file diff --git a/server/presenters.js b/server/presenters.js index a9baab29a..e77a0312f 100644 --- a/server/presenters.js +++ b/server/presenters.js @@ -1,3 +1,5 @@ +var marked = require('marked'); + export function presentUser(user) { return { id: user.id, @@ -15,7 +17,7 @@ export function presentTeam(team) { }; } -export function presentAtlas(atlas) { +export function presentAtlas(atlas, includeRecentDocuments=true) { return { id: atlas.id, name: atlas.name, @@ -23,4 +25,24 @@ export function presentAtlas(atlas) { type: atlas.type, recentDocuments: atlas.getRecentDocuments(), } -} \ No newline at end of file +} + +export async function presentDocument(document, includeAtlas=false) { + const data = { + id: document.id, + title: document.title, + text: document.text, + html: marked(document.text), + createdAt: document.createdAt, + updatedAt: document.updatedAt, + atlas: document.atlaId, + team: document.teamId, + } + + if (includeAtlas) { + const atlas = await document.getAtlas(); + data.atlas = presentAtlas(atlas, false); + } + + return data; +} diff --git a/src/Reducers/index.js b/src/Reducers/index.js index 0354bc2b6..ad7b94f3b 100644 --- a/src/Reducers/index.js +++ b/src/Reducers/index.js @@ -1,12 +1,14 @@ import { combineReducers } from 'redux'; import atlases from './atlases'; +import document from './document'; import team from './team'; import editor from './editor'; import user from './user'; export default combineReducers({ atlases, + document, team, editor, user, diff --git a/src/actions/DocumentActions.js b/src/actions/DocumentActions.js new file mode 100644 index 000000000..877985382 --- /dev/null +++ b/src/actions/DocumentActions.js @@ -0,0 +1,63 @@ +import makeActionCreator from '../utils/actions'; +import { replace } from 'react-router-redux'; +import { client } from 'utils/ApiClient'; + +export const FETCH_DOCUMENT_PENDING = 'FETCH_DOCUMENT_PENDING'; +export const FETCH_DOCUMENT_SUCCESS = 'FETCH_DOCUMENT_SUCCESS'; +export const FETCH_DOCUMENT_FAILURE = 'FETCH_DOCUMENT_FAILURE'; + +const fetchDocumentPending = makeActionCreator(FETCH_DOCUMENT_PENDING); +const fetchDocumentSuccess = makeActionCreator(FETCH_DOCUMENT_SUCCESS, 'data'); +const fetchDocumentFailure = makeActionCreator(FETCH_DOCUMENT_FAILURE, 'error'); + +export function fetchDocumentAsync(documentId) { + return (dispatch) => { + dispatch(fetchDocumentPending()); + + client.post('/documents.info', { + id: documentId, + }) + .then(data => { + dispatch(fetchDocumentSuccess(data.data)); + }) + .catch((err) => { + dispatch(fetchDocumentFailure(err)); + }) + }; +}; + +export const SAVE_DOCUMENT_PENDING = 'SAVE_DOCUMENT_PENDING'; +export const SAVE_DOCUMENT_SUCCESS = 'SAVE_DOCUMENT_SUCCESS'; +export const SAVE_DOCUMENT_FAILURE = 'SAVE_DOCUMENT_FAILURE'; + +const saveDocumentPending = makeActionCreator(SAVE_DOCUMENT_PENDING); +const saveDocumentSuccess = makeActionCreator(SAVE_DOCUMENT_SUCCESS, 'data'); +const saveDocumentFailure = makeActionCreator(SAVE_DOCUMENT_FAILURE, 'error'); + +export function saveDocumentAsync(atlasId, documentId, title, text) { + return (dispatch) => { + dispatch(saveDocumentPending()); + + let url; + if (documentId) { + url = '/documents.update' + } else { + url = '/documents.create' + } + + client.post(url, { + atlas: atlasId, + document: documentId, + title, + text, + }) + .then(data => { + dispatch(saveDocumentSuccess(data.data, data.pagination)); + dispatch(replace(`/documents/${data.data.id}`)); + }) + .catch((err) => { + dispatch(saveDocumentFailure(err)); + }) + }; +}; + diff --git a/src/index.js b/src/index.js index feedcd4ef..bfb8342b4 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,7 @@ import Home from 'scenes/Home'; import Editor from 'scenes/Editor'; import Dashboard from 'scenes/Dashboard'; import Atlas from 'scenes/Atlas'; +import Document from 'scenes/Document'; import SlackAuth from 'scenes/SlackAuth'; // Redux @@ -51,8 +52,7 @@ persistStore(store, { - - + diff --git a/src/reducers/document.js b/src/reducers/document.js new file mode 100644 index 000000000..e81293cfe --- /dev/null +++ b/src/reducers/document.js @@ -0,0 +1,63 @@ +import { + FETCH_DOCUMENT_PENDING, + FETCH_DOCUMENT_SUCCESS, + FETCH_DOCUMENT_FAILURE, + + SAVE_DOCUMENT_PENDING, + SAVE_DOCUMENT_SUCCESS, + SAVE_DOCUMENT_FAILURE, +} from 'actions/DocumentActions'; + +const initialState = { + data: null, + error: null, + isLoading: false, +} + +const doc = (state = initialState, action) => { + switch (action.type) { + case FETCH_DOCUMENT_PENDING: { + return { + ...state, + isLoading: true, + }; + } + case FETCH_DOCUMENT_SUCCESS: { + return { + data: action.data, + isLoading: false, + }; + } + case FETCH_DOCUMENT_FAILURE: { + return { + ...state, + error: action.error, + isLoading: false, + }; + } + + case SAVE_DOCUMENT_PENDING: { + return { + ...state, + isLoading: true, + }; + } + case SAVE_DOCUMENT_SUCCESS: { + return { + data: action.date, + isLoading: false, + }; + } + case SAVE_DOCUMENT_FAILURE: { + return { + ...state, + error: action.error, + isLoading: false, + }; + } + default: + return state; + } +}; + +export default doc; \ No newline at end of file diff --git a/src/scenes/Document/Document.js b/src/scenes/Document/Document.js new file mode 100644 index 000000000..745d35d30 --- /dev/null +++ b/src/scenes/Document/Document.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { fetchDocumentAsync } from 'actions/DocumentActions'; + +import Layout from 'components/Layout'; +import AtlasPreviewLoading from 'components/AtlasPreviewLoading'; +import CenteredContent from 'components/CenteredContent'; + +import styles from './Document.scss'; + +class Document extends React.Component { + componentDidMount = () => { + const documentId = this.props.routeParams.id; + this.props.fetchDocumentAsync(documentId); + } + + render() { + const document = this.props.document; + let title; + if (document) { + title = `${document.atlas.name} - ${document.title}`; + } + + return ( + + + { this.props.isLoading || !document ? ( + + ) : ( +
+ ) } + + + ); + } +}; + + +const mapStateToProps = (state) => { + return { + isLoading: state.document.isLoading, + document: state.document.data, + } +}; + +const mapDispatchToProps = (dispatch) => { + return bindActionCreators({ + fetchDocumentAsync, + }, dispatch) +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Document); diff --git a/src/scenes/Document/Document.scss b/src/scenes/Document/Document.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/scenes/Document/index.js b/src/scenes/Document/index.js new file mode 100644 index 000000000..ea7ca13ca --- /dev/null +++ b/src/scenes/Document/index.js @@ -0,0 +1,2 @@ +import Document from './Document'; +export default Document; \ No newline at end of file diff --git a/src/scenes/Editor/Editor.js b/src/scenes/Editor/Editor.js index a3208b65d..63727a96d 100644 --- a/src/scenes/Editor/Editor.js +++ b/src/scenes/Editor/Editor.js @@ -6,6 +6,9 @@ import { updateText, replaceText, } from 'actions/EditorActions'; +import { + saveDocumentAsync, +} from 'actions/DocumentActions'; import styles from './Editor.scss'; import 'assets/styles/codemirror.css'; @@ -21,10 +24,30 @@ class Editor extends Component { static propTypes = { updateText: React.PropTypes.func.isRequired, replaceText: React.PropTypes.func.isRequired, + saveDocumentAsync: React.PropTypes.func.isRequired, text: React.PropTypes.string, title: React.PropTypes.string, } + componentDidMount = () => { + const atlasId = this.props.routeParams.id; + this.setState({ atlasId: atlasId }); + } + + onSave = () => { + if (this.props.title.length === 0) { + alert("Please add a title before saving (hint: Write a markdown header)"); + return + } + + this.props.saveDocumentAsync( + this.state.atlasId, + null, + this.props.title, + this.props.text, + ) + } + render() { let title = ( - <SaveAction /> + <SaveAction onClick={ this.onSave } /> <MoreAction /> </Flex> )} @@ -60,6 +83,7 @@ const mapStateToProps = (state) => { return { text: state.editor.text, title: state.editor.title, + isSaving: state.document.isLoading, }; }; @@ -67,6 +91,7 @@ const mapDispatchToProps = (dispatch) => { return bindActionCreators({ updateText, replaceText, + saveDocumentAsync, }, dispatch) }; diff --git a/src/scenes/Editor/components/SaveAction.js b/src/scenes/Editor/components/SaveAction.js index 59e0dc695..cba36ddbc 100644 --- a/src/scenes/Editor/components/SaveAction.js +++ b/src/scenes/Editor/components/SaveAction.js @@ -1,12 +1,23 @@ import React from 'react'; import { Arrow } from 'rebass'; -const SaveAction = (props) => { - return ( - <div> - Save - </div> - ); +class SaveAction extends React.Component { + propTypes = { + onClick: React.PropTypes.func.isRequired, + } + + onClick = (event) => { + event.preventDefault(); + this.props.onClick(); + } + + render() { + return ( + <div> + <a href onClick={ this.onClick }>Save</a> + </div> + ); + } }; export default SaveAction; \ No newline at end of file