Merge pull request #7 from jorilallo/jori-tree

tree
This commit is contained in:
Jori Lallo
2016-06-25 22:39:29 -07:00
committed by GitHub
32 changed files with 1133 additions and 81 deletions

10
.sequelizerc Normal file
View File

@@ -0,0 +1,10 @@
require('localenv');
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
'seeders-path': path.resolve('server/models', 'fixtures'),
}

View File

@@ -11,7 +11,8 @@
"start": "cross-env NODE_ENV=development DEBUG=1 ./node_modules/.bin/nodemon --watch server index.js",
"lint": "eslint src",
"deploy": "git push heroku master",
"heroku-postbuild": "npm run build"
"heroku-postbuild": "npm run build && npm run sequelize db:migrate",
"sequelize": "./node_modules/.bin/sequelize"
},
"repository": {
"type": "git",
@@ -53,6 +54,7 @@
"http-errors": "^1.4.0",
"imports-loader": "^0.6.5",
"isomorphic-fetch": "^2.2.1",
"js-tree": "^1.1.0",
"json-loader": "^0.5.4",
"jsonwebtoken": "^5.7.0",
"koa": "^2.0.0",
@@ -96,7 +98,7 @@
"safestart": "^0.8.0",
"sass-loader": "^3.2.0",
"sequelize": "^3.21.0",
"sequelize-cli": "^2.3.1",
"sequelize-cli": "^2.4.0",
"sequelize-encrypted": "^0.1.0",
"slug": "^0.9.1",
"style-loader": "^0.13.0",

View File

@@ -40,13 +40,27 @@ router.post('auth.slack', async (ctx) => {
const authResponse = await fetch(`https://slack.com/api/auth.test?token=${data.access_token}`);
const authData = await authResponse.json();
// Team
let team = await Team.findOne({ where: { slackId: data.team.id } });
if (!team) {
team = await Team.create({
name: data.team.name,
slackId: data.team.id,
slackData: data.team,
});
const atlas = await team.createFirstAtlas();
} else {
team.name = data.team.name;
team.slackData = data.team;
team = await team.save();
}
if (user) {
user.slackAccessToken = data.access_token;
user.slackData = data.user;
user = await user.save();
} else {
// Existing user
user = await User.create({
user = await team.createUser({
slackId: data.user.id,
username: authData.user,
name: data.user.name,
@@ -56,30 +70,11 @@ router.post('auth.slack', async (ctx) => {
});
}
// Team
let team = await Team.findOne({ where: { slackId: data.team.id } });
if (!team) {
team = await Team.create({
name: data.team.name,
slackId: data.team.id,
slackData: data.team,
});
} else {
// Update data
team.name = data.team.name;
team.slackData = data.team;
team = await team.save();
}
// Add to correct team
user.setTeam(team);
ctx.body = { data: {
user: await presentUser(user),
team: await presentTeam(team),
accessToken: user.getJwtToken(),
}};
console.log("enf")
});
export default router;

View File

@@ -13,11 +13,11 @@ router.post('atlases.info', auth(), async (ctx) => {
let { id } = ctx.request.body;
ctx.assertPresent(id, 'id is required');
const team = await ctx.state.user.getTeam();
const user = ctx.state.user;
const atlas = await Atlas.findOne({
where: {
id: id,
teamId: team.id,
teamId: user.teamId,
},
});
@@ -30,10 +30,10 @@ router.post('atlases.info', auth(), async (ctx) => {
router.post('atlases.list', auth(), pagination(), async (ctx) => {
const team = await ctx.state.user.getTeam();
const user = ctx.state.user;
const atlases = await Atlas.findAll({
where: {
teamId: team.id,
teamId: user.teamId,
},
order: [
['updatedAt', 'DESC'],
@@ -56,4 +56,26 @@ router.post('atlases.list', auth(), pagination(), async (ctx) => {
};
});
export default router;
router.post('atlases.updateNavigationTree', auth(), async (ctx) => {
let { id, tree } = ctx.request.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const atlas = await Atlas.findOne({
where: {
id: id,
teamId: user.teamId,
},
});
if (!atlas) throw httpErrors.NotFound();
const newTree = await atlas.updateNavigationTree(tree);
ctx.body = {
data: await presentAtlas(atlas, true),
tree: newTree,
};
});
export default router;

View File

@@ -23,8 +23,8 @@ router.post('documents.info', auth({ require: false }), async (ctx) => {
if (document.private) {
if (!ctx.state.user) throw httpErrors.NotFound();
const team = await ctx.state.user.getTeam();
if (document.teamId !== team.id) {
const user = await ctx.state.user;
if (document.teamId !== user.teamId) {
throw httpErrors.NotFound();
}
@@ -46,30 +46,45 @@ router.post('documents.create', auth(), async (ctx) => {
atlas,
title,
text,
parentDocument,
} = ctx.request.body;
ctx.assertPresent(atlas, 'atlas is required');
ctx.assertPresent(title, 'title is required');
ctx.assertPresent(text, 'text is required');
const user = ctx.state.user;
const team = await user.getTeam();
const ownerAtlas = await Atlas.findOne({
where: {
id: atlas,
teamId: team.id,
teamId: user.teamId,
},
});
if (!ownerAtlas) throw httpErrors.BadRequest();
let parentDocumentObj;
if (parentDocument && ownerAtlas.type === 'atlas') {
parentDocumentObj = await Document.findOne({
where: {
id: parentDocument,
atlasId: ownerAtlas.id,
},
});
}
const document = await Document.create({
parentDocumentId: parentDocumentObj.id,
atlasId: ownerAtlas.id,
teamId: team.id,
teamId: user.teamId,
userId: user.id,
title: title,
text: text,
});
// TODO: Move to afterSave hook if possible with imports
ownerAtlas.addNodeToNavigationTree(document);
await ownerAtlas.save();
ctx.body = {
data: await presentDocument(document, true),
};
@@ -86,11 +101,10 @@ router.post('documents.update', auth(), async (ctx) => {
ctx.assertPresent(text, 'text is required');
const user = ctx.state.user;
const team = await user.getTeam();
let document = await Document.findOne({
where: {
id: id,
teamId: team.id,
teamId: user.teamId,
},
});
@@ -112,16 +126,18 @@ router.post('documents.delete', auth(), async (ctx) => {
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const team = await user.getTeam();
let document = await Document.findOne({
where: {
id: id,
teamId: team.id,
teamId: user.teamId,
},
});
if (!document) throw httpErrors.BadRequest();
// TODO: Don't allow to destroy root docs
// TODO: handle sub documents
try {
await document.destroy();
} catch (e) {

View File

@@ -6,7 +6,7 @@ import Sequelize from 'sequelize';
import auth from './auth';
import user from './user';
import atlases from './atlases';
import collections from './collections';
import documents from './documents';
import validation from './validation';
@@ -44,7 +44,7 @@ api.use(validation());
router.use('/', auth.routes());
router.use('/', user.routes());
router.use('/', atlases.routes());
router.use('/', collections.routes());
router.use('/', documents.routes());
// Router is embedded in a Koa application wrapper, because koa-router does not

View File

@@ -0,0 +1,14 @@
{
"development": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
},
"test": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
},
"production": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
}
}

View File

@@ -0,0 +1,199 @@
'use strict';
module.exports = {
up: function (queryInterface, Sequelize) {
queryInterface.createTable('teams', {
id: {
type: 'UUID',
allowNull: false,
primaryKey: true
},
name: {
type: 'CHARACTER VARYING',
allowNull: true,
},
slackId: {
type: 'CHARACTER VARYING',
allowNull: true,
unique: true
},
slackData: {
type: 'JSONB',
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
}
});
queryInterface.createTable('atlases', {
id: {
type: 'UUID',
allowNull: false,
primaryKey: true
},
name: {
type: 'CHARACTER VARYING',
allowNull: true,
},
description: {
type: 'CHARACTER VARYING',
allowNull: true,
},
type: {
type: 'CHARACTER VARYING',
allowNull: true,
},
atlasStructure: {
type: 'JSONB',
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
teamId: {
type: 'UUID',
allowNull: false,
// references: {
// model: "teams",
// key: "id",
// }
}
});
queryInterface.createTable('users', {
id: {
type: 'UUID',
allowNull: false,
primaryKey: true
},
email: {
type: 'CHARACTER VARYING',
allowNull: false,
},
username: {
type: 'CHARACTER VARYING',
allowNull: false,
},
name: {
type: 'CHARACTER VARYING',
allowNull: false,
},
isAdmin: {
type: 'BOOLEAN',
allowNull: true,
defaultValue: false,
},
slackAccessToken: {
type: 'bytea',
allowNull: true, },
slackId: {
type: 'CHARACTER VARYING',
unique: true,
allowNull: false,
},
slackData: {
type: 'JSONB',
allowNull: true,
},
jwtSecret: {
type: 'bytea',
allowNull: true,
},
createdAt: {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
updatedAt: {
type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
teamId: {
type: 'UUID',
allowNull: true,
// references: {
// model: "teams",
// key: "id",
// }
}
});
queryInterface.createTable('documents', {
id:
{ type: 'UUID',
allowNull: false,
primaryKey: true },
urlId:
{ type: 'CHARACTER VARYING',
allowNull: false,
unique: true, },
private:
{ type: 'BOOLEAN',
allowNull: false,
defaultValue: true,
},
title:
{ type: 'CHARACTER VARYING',
allowNull: false,
},
text:
{ type: 'TEXT',
allowNull: true,
},
html:
{ type: 'TEXT',
allowNull: true,
},
preview:
{ type: 'TEXT',
allowNull: true,
},
createdAt:
{ type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
updatedAt:
{ type: 'TIMESTAMP WITH TIME ZONE',
allowNull: false,
},
userId: {
type: 'UUID',
allowNull: true,
// references: {
// model: "users",
// key: "id",
// }
},
atlasId: {
type: 'UUID',
allowNull: true,
// references: {
// model: "atlases",
// key: "id",
// }
},
teamId: {
type: 'UUID',
allowNull: true,
// references: {
// model: "teams",
// key: "id",
// }
}
});
},
down: function (queryInterface, Sequelize) {
queryInterface.dropAllTables();
}
};

View File

@@ -0,0 +1,22 @@
'use strict';
module.exports = {
up: function (queryInterface, Sequelize) {
queryInterface.addColumn(
'documents',
'parentDocumentId',
{
type: Sequelize.UUID,
allowNull: true,
references: {
model: "documents",
key: "id",
}
}
);
},
down: function (queryInterface, Sequelize) {
queryInterface.removeColumn('documents', 'parentDocumentId');
}
};

View File

@@ -2,7 +2,8 @@ import {
DataTypes,
sequelize,
} from '../sequelize';
import Team from './Team';
import _isEqual from 'lodash/isEqual';
import Document from './Document';
const allowedAtlasTypes = [['atlas', 'journal']];
@@ -11,8 +12,146 @@ const Atlas = sequelize.define('atlas', {
name: DataTypes.STRING,
description: DataTypes.STRING,
type: { type: DataTypes.STRING, validate: { isIn: allowedAtlasTypes }},
/* type: atlas */
navigationTree: DataTypes.JSONB,
}, {
tableName: 'atlases',
hooks: {
// beforeValidate: (doc) => {
// doc.urlId = randomstring.generate(15);
// },
// beforeCreate: (doc) => {
// doc.html = convertToMarkdown(doc.text);
// doc.preview = truncateMarkdown(doc.text, 160);
// },
// beforeUpdate: (doc) => {
// doc.html = convertToMarkdown(doc.text);
// doc.preview = truncateMarkdown(doc.text, 160);
// },
},
instanceMethods: {
async getStructure() {
if (this.navigationTree) {
return this.navigationTree;
}
const getNodeForDocument = async (document) => {
const children = await Document.findAll({ where: {
parentDocumentId: document.id,
atlasId: this.id,
}});
let childNodes = []
await Promise.all(children.map(async (child) => {
childNodes.push(await getNodeForDocument(child));
}));
return {
title: document.title,
id: document.id,
url: document.getUrl(),
children: childNodes,
};
}
const rootDocument = await Document.findOne({
where: {
parentDocumentId: null,
atlasId: this.id,
}
});
if (rootDocument) {
return await getNodeForDocument(rootDocument);
} else {
return; // TODO should create a root doc
}
},
async updateNavigationTree(tree) {
let nodeIds = [];
nodeIds.push(tree.id);
const rootDocument = await Document.findOne({
where: {
id: tree.id,
atlasId: this.id,
},
});
if (!rootDocument) throw new Error;
let newTree = {
id: tree.id,
title: rootDocument.title,
url: rootDocument.getUrl(),
children: [],
};
const getIdsForChildren = async (children) => {
const childNodes = [];
for (const child of children) {
const childDocument = await Document.findOne({
where: {
id: child.id,
atlasId: this.id,
},
});
if (!childDocument) throw new Error;
childNodes.push({
id: childDocument.id,
title: childDocument.title,
url: childDocument.getUrl(),
children: await getIdsForChildren(child.children),
})
nodeIds.push(child.id);
}
return childNodes;
};
newTree.children = await getIdsForChildren(tree.children);
const documents = await Document.findAll({
attributes: ['id'],
where: {
atlasId: this.id,
}
});
const documentIds = documents.map(doc => doc.id);
if (!_isEqual(nodeIds.sort(), documentIds.sort())) {
throw new Error('Invalid navigation tree');
}
this.navigationTree = newTree;
await this.save();
return newTree;
},
async addNodeToNavigationTree(document) {
const newNode = {
id: document.id,
title: document.title,
url: document.getUrl(),
children: [],
}
const insertNode = (node) => {
if (document.parentDocumentId === node.id) {
node.children.push(newNode);
} else {
node.children = node.children.map(childNode => {
return insertNode(childNode);
})
}
return node;
};
this.navigationTree = insertNode(this.navigationTree);
}
}
});
Atlas.belongsTo(Team);
Atlas.hasMany(Document, { as: 'documents', foreignKey: 'atlasId' });
export default Atlas;

View File

@@ -10,8 +10,6 @@ import {
import {
truncateMarkdown,
} from '../utils/truncate';
import Atlas from './Atlas';
import Team from './Team';
import User from './User';
slug.defaults.mode ='rfc3986';
@@ -29,6 +27,8 @@ const Document = sequelize.define('document', {
text: DataTypes.TEXT,
html: DataTypes.TEXT,
preview: DataTypes.TEXT,
parentDocumentId: DataTypes.UUID,
}, {
hooks: {
beforeValidate: (doc) => {
@@ -47,12 +47,13 @@ const Document = sequelize.define('document', {
buildUrl() {
const slugifiedTitle = slug(this.title);
return `${slugifiedTitle}-${this.urlId}`;
}
},
getUrl() {
return `/documents/${ this.id }`;
},
}
});
Document.belongsTo(Atlas, { as: 'atlas' });
Document.belongsTo(Team);
Document.belongsTo(User);
export default Document;

View File

@@ -2,6 +2,9 @@ import {
DataTypes,
sequelize,
} from '../sequelize';
import Atlas from './Atlas';
import Document from './Document';
import User from './User';
const Team = sequelize.define('team', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
@@ -9,6 +12,17 @@ const Team = sequelize.define('team', {
slackId: { type: DataTypes.STRING, unique: true },
slackData: DataTypes.JSONB,
}, {
instanceMethods: {
async createFirstAtlas() {
const atlas = await Atlas.create({
name: this.name,
description: 'Your first Atlas',
type: 'journal',
teamId: this.id,
});
return atlas;
}
},
indexes: [
{
unique: true,
@@ -17,4 +31,8 @@ const Team = sequelize.define('team', {
],
});
Team.hasMany(Atlas, { as: 'atlases' });
Team.hasMany(Document, { as: 'documents' });
Team.hasMany(User, { as: 'users' });
export default Team;

View File

@@ -4,7 +4,6 @@ import {
sequelize,
encryptedFields
} from '../sequelize';
import Team from './Team';
import JWT from 'jsonwebtoken';
@@ -39,8 +38,5 @@ const setRandomJwtSecret = (model) => {
};
User.beforeCreate(setRandomJwtSecret);
User.belongsTo(Team);
sequelize.sync();
export default User;

View File

@@ -1,5 +1,5 @@
import _orderBy from 'lodash.orderby';
import Document from './models/Document';
import { Document, Atlas } from './models';
export function presentUser(user) {
return new Promise(async (resolve, reject) => {
@@ -31,6 +31,10 @@ export function presentAtlas(atlas, includeRecentDocuments=false) {
type: atlas.type,
}
if (atlas.type === 'atlas') {
data.navigationTree = await atlas.getStructure();
}
if (includeRecentDocuments) {
const documents = await Document.findAll({
where: {
@@ -65,12 +69,14 @@ export async function presentDocument(document, includeAtlas=false) {
private: document.private,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
atlas: document.atlaId,
atlas: document.atlasId,
team: document.teamId,
}
if (includeAtlas) {
const atlas = await document.getAtlas();
const atlas = await Atlas.findOne({ where: {
id: document.atlasId,
}});
data.atlas = await presentAtlas(atlas, false);
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 125" enable-background="new 0 0 100 100" xml:space="preserve"><path d="M82.641,40.452L69.984,53.109c-1.164,1.164-3.054,1.164-4.218,0s-1.164-3.054,0-4.218l12.657-12.657 c3.489-3.489,3.489-9.165,0-12.657s-9.165-3.489-12.657,0L48.891,40.452c-1.035,1.059-5.955,6.702,0,12.657 c1.164,1.164,1.164,3.054,0,4.218s-3.054,1.164-4.218,0c-8.343-8.343-3.648-17.445,0-21.093l16.875-16.875 c5.814-5.814,15.279-5.814,21.093,0C88.455,25.173,88.452,34.638,82.641,40.452z M57.327,44.673c-1.164-1.164-3.054-1.164-4.218,0 c-1.164,1.164-1.164,3.054,0,4.218c5.955,5.955,1.035,11.595,0,12.657L36.234,78.423c-3.489,3.489-9.165,3.489-12.657,0 c-3.492-3.489-3.489-9.165,0-12.657l12.657-12.657c1.164-1.164,1.164-3.054,0-4.218s-3.054-1.164-4.218,0L19.359,61.548 c-5.814,5.814-5.814,15.279,0,21.093c5.814,5.814,15.279,5.814,21.093,0l16.875-16.875C60.975,62.118,65.67,53.013,57.327,44.673z"/></svg>

Before

Width:  |  Height:  |  Size: 1003 B

View File

@@ -8,13 +8,7 @@
:global {
.anchor {
visibility: hidden;
background-image: url('../../assets/icons/anchor.svg');
background-repeat: no-repeat;
background-size: 100%;
background-position: 0 center;
margin-left: -26px;
width: 20px;
display: inline-block;
color: #ccc;
}
}
@@ -28,9 +22,10 @@
}
ul {
padding-left: 1.5em;
ul {
margin: 0;
padding-left: 1.5em;
}
}
}

110
src/components/Tree/Node.js Normal file
View File

@@ -0,0 +1,110 @@
var React = require('react');
import history from 'utils/History';
import styles from './Tree.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
var Node = React.createClass({
displayName: 'UITreeNode',
renderCollapse() {
var index = this.props.index;
if(index.children && index.children.length) {
var collapsed = index.node.collapsed;
return (
<span
className={cx(styles.collapse, collapsed ? styles.caretRight : styles.caretDown)}
onMouseDown={function(e) {e.stopPropagation()}}
onClick={this.handleCollapse}
>
<img src={ require("./assets/chevron.svg") } />
</span>
);
}
return null;
},
renderChildren() {
var index = this.props.index;
var tree = this.props.tree;
var dragging = this.props.dragging;
if(index.children && index.children.length) {
var childrenStyles = {};
if (!this.props.rootNode) {
if(index.node.collapsed) childrenStyles.display = 'none';
childrenStyles['paddingLeft'] = this.props.paddingLeft + 'px';
}
return (
<div className={ styles.children } style={childrenStyles}>
{index.children.map((child) => {
var childIndex = tree.getIndex(child);
return (
<Node
tree={tree}
index={childIndex}
key={childIndex.id}
dragging={dragging}
paddingLeft={this.props.paddingLeft}
onCollapse={this.props.onCollapse}
onDragStart={this.props.onDragStart}
/>
);
})}
</div>
);
}
return null;
},
render() {
var tree = this.props.tree;
var index = this.props.index;
var dragging = this.props.dragging;
var node = index.node;
var style = {};
return (
<div className={cx(styles.node, {
placeholder: index.id === dragging,
rootNode: this.props.rootNode,
})} style={style}>
<div className={ styles.inner } ref="inner" onMouseDown={this.handleMouseDown}>
{!this.props.rootNode && this.renderCollapse()}
<span
className={ cx(styles.nodeLabel, { rootLabel: this.props.rootNode }) }
onClick={() => { history.push(node.url) }}
onMouseDown={this.props.rootNode ? function(e){e.stopPropagation()} : undefined}
>
{ node.title }
</span>
</div>
{this.renderChildren()}
</div>
);
},
handleCollapse(e) {
e.stopPropagation();
var nodeId = this.props.index.id;
if(this.props.onCollapse) this.props.onCollapse(nodeId);
},
handleMouseDown(e) {
var nodeId = this.props.index.id;
var dom = this.refs.inner;
if(this.props.onDragStart) {
this.props.onDragStart(nodeId, dom, e);
}
}
});
module.exports = Node;

View File

@@ -0,0 +1,68 @@
var Tree = require('js-tree');
var proto = Tree.prototype;
proto.updateNodesPosition = function() {
var top = 1;
var left = 1;
var root = this.getIndex(1);
var self = this;
root.top = top++;
root.left = left++;
if(root.children && root.children.length) {
walk(root.children, root, left, root.node.collapsed);
}
function walk(children, parent, left, collapsed) {
var height = 1;
children.forEach(function(id) {
var node = self.getIndex(id);
if(collapsed) {
node.top = null;
node.left = null;
} else {
node.top = top++;
node.left = left;
}
if(node.children && node.children.length) {
height += walk(node.children, node, left+1, collapsed || node.node.collapsed);
} else {
node.height = 1;
height += 1;
}
});
if(parent.node.collapsed) parent.height = 1;
else parent.height = height;
return parent.height;
}
};
proto.move = function(fromId, toId, placement) {
if(fromId === toId || toId === 1) return;
var obj = this.remove(fromId);
var index = null;
if(placement === 'before') index = this.insertBefore(obj, toId);
else if(placement === 'after') index = this.insertAfter(obj, toId);
else if(placement === 'prepend') index = this.prepend(obj, toId);
else if(placement === 'append') index = this.append(obj, toId);
// todo: perf
this.updateNodesPosition();
return index;
};
proto.getNodeByTop = function(top) {
var indexes = this.indexes;
for(var id in indexes) {
if(indexes.hasOwnProperty(id)) {
if(indexes[id].top === top) return indexes[id];
}
}
};
module.exports = Tree;

View File

@@ -0,0 +1,79 @@
@mixin no-select {
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.tree {
position: relative;
overflow: hidden;
@include no-select;
}
.draggable {
position: absolute;
opacity: 0.8;
@include no-select;
}
.node {
&.placeholder > * {
visibility: hidden;
}
&.placeholder {
border: 1px dashed #ccc;
}
.inner {
position: relative;
cursor: pointer;
padding-left: 10px;
}
.collapse {
position: absolute;
left: 0;
cursor: pointer;
width: 20px;
height: 25px;
}
.caretRight {
margin-top: 3px;
margin-left: -3px;
}
.caretDown {
transform: rotate(90deg);
margin-left: -4px;
margin-top: 2px;
}
}
.node {
&.placeholder {
border: 1px dashed #1385e5;
}
.inner {
font-size: 14px;
}
.nodeLabel {
display: inline-block;
width: 100%;
padding: 4px 5px;
&.isActive {
background-color: #31363F;
}
}
.rootLabel {
color: #ccc;
}
}

View File

@@ -0,0 +1,266 @@
var React = require('react');
var Tree = require('./tree');
var Node = require('./node');
import styles from './Tree.scss';
module.exports = React.createClass({
displayName: 'UITree',
propTypes: {
tree: React.PropTypes.object.isRequired,
paddingLeft: React.PropTypes.number,
renderNode: React.PropTypes.func.isRequired
},
getDefaultProps() {
return {
paddingLeft: 20
};
},
getInitialState() {
return this.init(this.props);
},
componentWillReceiveProps(nextProps) {
if(!this._updated) this.setState(this.init(nextProps));
else this._updated = false;
},
init(props) {
var tree = new Tree(props.tree);
tree.isNodeCollapsed = props.isNodeCollapsed;
tree.renderNode = props.renderNode;
tree.changeNodeCollapsed = props.changeNodeCollapsed;
tree.updateNodesPosition();
return {
tree: tree,
dragging: {
id: null,
x: null,
y: null,
w: null,
h: null
}
};
},
getDraggingDom() {
var tree = this.state.tree;
var dragging = this.state.dragging;
if(dragging && dragging.id) {
var draggingIndex = tree.getIndex(dragging.id);
var draggingStyles = {
top: dragging.y,
left: dragging.x,
width: dragging.w
};
return (
<div className={ styles.draggable } style={draggingStyles}>
<Node
tree={tree}
index={draggingIndex}
paddingLeft={this.props.paddingLeft}
/>
</div>
);
}
return null;
},
render() {
var tree = this.state.tree;
var dragging = this.state.dragging;
var draggingDom = this.getDraggingDom();
return (
<div className={ styles.tree }>
{draggingDom}
<Node
rootNode={ true }
tree={tree}
index={tree.getIndex(1)}
key={1}
paddingLeft={this.props.paddingLeft}
onDragStart={this.dragStart}
onCollapse={this.toggleCollapse}
dragging={dragging && dragging.id}
/>
</div>
);
},
dragStart(id, dom, e) {
this.dragging = {
id: id,
w: dom.offsetWidth,
h: dom.offsetHeight,
x: dom.offsetLeft,
y: dom.offsetTop
};
this._startX = dom.offsetLeft;
this._startY = dom.offsetTop;
this._offsetX = e.clientX;
this._offsetY = e.clientY;
this._start = true;
window.addEventListener('mousemove', this.drag);
window.addEventListener('mouseup', this.dragEnd);
},
// oh
drag(e) {
if(this._start) {
this.setState({
dragging: this.dragging
});
this._start = false;
}
var tree = this.state.tree;
var dragging = this.state.dragging;
var paddingLeft = this.props.paddingLeft;
var newIndex = null;
var index = tree.getIndex(dragging.id);
var collapsed = index.node.collapsed;
var _startX = this._startX;
var _startY = this._startY;
var _offsetX = this._offsetX;
var _offsetY = this._offsetY;
var pos = {
x: _startX + e.clientX - _offsetX,
y: _startY + e.clientY - _offsetY
};
dragging.x = pos.x;
dragging.y = pos.y;
var diffX = dragging.x - paddingLeft/2 - (index.left-2) * paddingLeft;
var diffY = dragging.y - dragging.h/2 - (index.top-2) * dragging.h;
if(diffX < 0) { // left
if(index.parent && !index.next) {
newIndex = tree.move(index.id, index.parent, 'after');
}
} else if(diffX > paddingLeft) { // right
if(index.prev) {
var prevNode = tree.getIndex(index.prev).node;
if(!prevNode.collapsed && !prevNode.leaf) {
newIndex = tree.move(index.id, index.prev, 'append');
}
}
}
if(newIndex) {
index = newIndex;
newIndex.node.collapsed = collapsed;
dragging.id = newIndex.id;
}
if(diffY < 0) { // up
var above = tree.getNodeByTop(index.top-1);
newIndex = tree.move(index.id, above.id, 'before');
} else if(diffY > dragging.h) { // down
if(index.next) {
var below = tree.getIndex(index.next);
if(below.children && below.children.length && !below.node.collapsed) {
newIndex = tree.move(index.id, index.next, 'prepend');
} else {
newIndex = tree.move(index.id, index.next, 'after');
}
} else {
var below = tree.getNodeByTop(index.top+index.height);
if(below && below.parent !== index.id) {
if(below.children && below.children.length) {
newIndex = tree.move(index.id, below.id, 'prepend');
} else {
newIndex = tree.move(index.id, below.id, 'after');
}
}
}
}
if(newIndex) {
newIndex.node.collapsed = collapsed;
dragging.id = newIndex.id;
}
this.setState({
tree: tree,
dragging: dragging
});
},
dragEnd() {
this.setState({
dragging: {
id: null,
x: null,
y: null,
w: null,
h: null
}
});
this.change(this.state.tree);
window.removeEventListener('mousemove', this.drag);
window.removeEventListener('mouseup', this.dragEnd);
},
change(tree) {
this._updated = true;
if(this.props.onChange) this.props.onChange(tree.obj);
},
toggleCollapse(nodeId) {
var tree = this.state.tree;
var index = tree.getIndex(nodeId);
var node = index.node;
node.collapsed = !node.collapsed;
tree.updateNodesPosition();
this.setState({
tree: tree
});
this.change(tree);
},
// buildTreeNumbering(tree) {
// const numberBuilder = (index, node, parentNumbering) => {
// let numbering = parentNumbering ? `${parentNumbering}.${index}` : index;
// let children;
// if (node.children) {
// children = node.children.map((child, childIndex) => {
// return numberBuilder(childIndex+1, child, numbering);
// });
// }
// const data = {
// module: {
// ...node.module,
// index: numbering,
// }
// }
// if (children) {
// data.children = children;
// }
// return data;
// };
// const newTree = {...tree};
// newTree.children = [];
// tree.children.forEach((child, index) => {
// newTree.children.push(numberBuilder(index+1, child));
// })
// return newTree;
// }
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 24 30" x="0px" y="0px"><path d="M8.59,18.16L14.25,12.5L8.59,6.84L7.89,7.55L12.84,12.5L7.89,17.45L8.59,18.16Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@@ -0,0 +1,2 @@
import UiTree from './UiTree';
export default UiTree;

View File

@@ -44,6 +44,7 @@ render((
<Route path="/atlas/:id/new" component={ DocumentEdit } onEnter={ requireAuth } newDocument={ true } />
<Route path="/documents/:id" component={ DocumentScene } onEnter={ requireAuth } />
<Route path="/documents/:id/edit" component={ DocumentEdit } onEnter={ requireAuth } />
<Route path="/documents/:id/new" component={ DocumentEdit } onEnter={ requireAuth } newChildDocument={ true } />
<Route path="/auth/slack" component={SlackAuth} />
</Route>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { observer } from 'mobx-react';
import Link from 'react-router/lib/Link';
import History from 'utils/History';
import store from './AtlasStore';
@@ -16,7 +17,13 @@ import styles from './Atlas.scss';
class Atlas extends React.Component {
componentDidMount = () => {
const { id } = this.props.params;
store.fetchAtlas(id);
store.fetchAtlas(id, data => {
// Forward directly to root document
if (data.type === 'atlas') {
History.replace(data.navigationTree.url);
}
})
}
render() {

View File

@@ -8,7 +8,7 @@ const store = new class AtlasStore {
/* Actions */
@action fetchAtlas = async (id) => {
@action fetchAtlas = async (id, successCallback) => {
this.isFetching = true;
this.atlas = null;
@@ -16,6 +16,7 @@ const store = new class AtlasStore {
const res = await client.post('/atlases.info', { id: id });
const { data } = res;
this.atlas = data;
successCallback(data);
} catch (e) {
console.error("Something went wrong");
}

View File

@@ -26,6 +26,10 @@ class DocumentEdit extends Component {
if (this.props.route.newDocument) {
store.atlasId = this.props.params.id;
store.newDocument = true;
} else if (this.props.route.newChildDocument) {
store.documentId = this.props.params.id;
store.newChildDocument = true;
store.fetchDocument();
} else {
store.documentId = this.props.params.id;
store.newDocument = false;
@@ -44,7 +48,7 @@ class DocumentEdit extends Component {
// alert("Please add a title before saving (hint: Write a markdown header)");
// return
// }
if (store.newDocument) {
if (store.newDocument || store.newChildDocument) {
store.saveDocument();
} else {
store.updateDocument();

View File

@@ -18,9 +18,11 @@ const parseHeader = (text) => {
const documentEditStore = new class DocumentEditStore {
@observable documentId = null;
@observable atlasId = null;
@observable parentDocument;
@observable title;
@observable text;
@observable newDocument;
@observable newChildDocument;
@observable preview;
@observable isFetching;
@@ -35,9 +37,13 @@ const documentEditStore = new class DocumentEditStore {
const data = await client.post('/documents.info', {
id: this.documentId,
})
const { title, text } = data.data;
this.title = title;
this.text = text;
if (this.newDocument) {
const { title, text } = data.data;
this.title = title;
this.text = text;
} else {
this.parentDocument = data.data;
}
} catch (e) {
console.error("Something went wrong");
}
@@ -51,7 +57,8 @@ const documentEditStore = new class DocumentEditStore {
try {
const data = await client.post('/documents.create', {
atlas: this.atlasId,
parentDocument: this.parentDocument && this.parentDocument.id,
atlas: this.atlasId || this.parentDocument.atlas.id,
title: this.title,
text: this.text,
})

View File

@@ -3,7 +3,7 @@ import { observer } from 'mobx-react';
@observer
class SaveAction extends React.Component {
propTypes = {
static propTypes = {
onClick: React.PropTypes.func.isRequired,
disabled: React.PropTypes.bool,
}

View File

@@ -9,8 +9,14 @@ import AtlasPreviewLoading from 'components/AtlasPreviewLoading';
import CenteredContent from 'components/CenteredContent';
import Document from 'components/Document';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import Flex from 'components/Flex';
import Tree from 'components/Tree';
import styles from './DocumentScene.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);
import treeStyles from 'components/Tree/Tree.scss';
@observer
class DocumentScene extends React.Component {
@@ -24,6 +30,13 @@ class DocumentScene extends React.Component {
}
componentWillReceiveProps = (nextProps) => {
// Reload on url change
const oldId = this.props.params.id;
const newId = nextProps.params.id;
if (oldId !== newId) {
store.fetchDocument(newId);
}
// Scroll to anchor after loading, and only once
const { hash } = this.props.location;
@@ -43,6 +56,19 @@ class DocumentScene extends React.Component {
};
}
renderNode = (node) => {
return (
<span className={ treeStyles.nodeLabel } onClick={this.onClickNode.bind(null, node)}>
{node.module.name}
</span>
);
}
handleChange = (tree) => {
console.log(tree);
store.updateNavigationTree(tree);
}
render() {
const doc = store.document;
let title;
@@ -51,6 +77,11 @@ class DocumentScene extends React.Component {
if (doc) {
actions = (
<div className={ styles.actions }>
{ store.isAtlas ? (
<HeaderAction>
<Link to={ `/documents/${doc.id}/new` }>New document</Link>
</HeaderAction>
) : null }
<HeaderAction>
<Link to={ `/documents/${doc.id}/edit` }>Edit</Link>
</HeaderAction>
@@ -74,13 +105,30 @@ class DocumentScene extends React.Component {
titleText={ titleText }
actions={ actions }
>
<CenteredContent>
{ store.isFetching ? (
{ store.isFetching ? (
<CenteredContent>
<AtlasPreviewLoading />
) : (
<Document document={ doc } />
) }
</CenteredContent>
</CenteredContent>
) : (
<Flex flex={ true }>
{ store.isAtlas ? (
<div className={ styles.sidebar }>
<Tree
paddingLeft={10}
tree={ doc.atlas.navigationTree }
onChange={this.handleChange}
isNodeCollapsed={this.isNodeCollapsed}
renderNode={this.renderNode}
/>
</div>
) : null }
<Flex flex={ true } justify={ 'center' }>
<CenteredContent>
<Document document={ doc } />
</CenteredContent>
</Flex>
</Flex>
) }
</Layout>
);
}

View File

@@ -1,4 +1,9 @@
.actions {
display: flex;
flex-direction: row;
}
}
.sidebar {
width: 250px;
padding: 40px 20px;
}

View File

@@ -1,4 +1,4 @@
import { observable, action } from 'mobx';
import { observable, action, computed } from 'mobx';
import { client } from 'utils/ApiClient';
import { browserHistory } from 'react-router';
@@ -8,6 +8,13 @@ const store = new class DocumentSceneStore {
@observable isFetching = true;
@observable isDeleting;
/* Computed */
@computed get isAtlas() {
return this.document &&
this.document.atlas.type === 'atlas';
}
/* Actions */
@action fetchDocument = async (id) => {
@@ -35,6 +42,20 @@ const store = new class DocumentSceneStore {
}
this.isFetching = false;
}
@action updateNavigationTree = async (tree) => {
this.isFetching = true;
try {
const res = await client.post('/atlases.updateNavigationTree', {
id: this.document.atlas.id,
tree: tree,
});
} catch (e) {
console.error("Something went wrong");
}
this.isFetching = false;
}
}();
export default store;

View File

@@ -16,10 +16,8 @@ renderer.heading = (text, level) => {
const headingSlug = slug(text);
return `
<h${level}>
<a name="${headingSlug}" class="anchor" href="#${headingSlug}">
<span class="header-link">&nbsp;</span>
</a>
${text}
<a name="${headingSlug}" class="anchor" href="#${headingSlug}">#</a>
</h${level}>
`;
},