This commit is contained in:
Radek Stepan 2016-01-21 15:55:18 +01:00
parent bddd68b723
commit c3b9f40af8
34 changed files with 186 additions and 179 deletions

View File

@ -1,13 +1,13 @@
import React from 'react';
import { RouterMixin, navigate } from 'react-mini-router';
import _ from 'lodash';
import lodash from './mixins/lodash.js';
import './modules/lodash.js';
import ProjectsPage from './pages/ProjectsPage.jsx';
import MilestonesPage from './pages/MilestonesPage.jsx';
import ChartPage from './pages/ChartPage.jsx';
import AddProjectPage from './pages/AddProjectPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
import ProjectsPage from './components/pages/ProjectsPage.jsx';
import MilestonesPage from './components/pages/MilestonesPage.jsx';
import ChartPage from './components/pages/ChartPage.jsx';
import AddProjectPage from './components/pages/AddProjectPage.jsx';
import NotFoundPage from './components/pages/NotFoundPage.jsx';
import actions from './actions/appActions.js';
@ -33,7 +33,7 @@ let find = ({ to, params, query }) => {
let re = /:[^\/]+/g;
// Skip empty objects.
[ params, query ] = [_.isObject(params) ? params : {}, query ].map(o => _.pick(o, _.identity));
[ params, query ] = [ _.isObject(params) ? params : {}, query ].map(o => _.pick(o, _.identity));
// Find among the routes.
_.find(routes, (name, url) => {
@ -73,9 +73,7 @@ export default React.createClass({
statics: {
// Build a link to a page.
link: (route) => {
return find(route);
},
link: (route) => find(route),
// Route to a link.
navigate: (route) => {
let fn = _.isString(route) ? _.identity : find;
@ -86,25 +84,21 @@ export default React.createClass({
// Show projects.
projects() {
document.title = 'Burnchart: GitHub Burndown Chart as a Service';
process.nextTick(() => { actions.emit('projects.load'); });
process.nextTick(() => actions.emit('projects.load'));
return <ProjectsPage />;
},
// Show project milestones.
milestones(owner, name) {
document.title = `${owner}/${name}`;
process.nextTick(() => {
actions.emit('projects.load', { owner, name });
});
process.nextTick(() => actions.emit('projects.load', { owner, name }));
return <MilestonesPage owner={owner} name={name} />;
},
// Show a project milestone chart.
chart(owner, name, milestone) {
document.title = `${owner}/${name}/${milestone}`;
process.nextTick(() => {
actions.emit('projects.load', { owner, name, milestone });
});
process.nextTick(() => actions.emit('projects.load', { owner, name, milestone }));
return <ChartPage owner={owner} name={name} milestone={milestone} />;
},
@ -134,6 +128,8 @@ export default React.createClass({
return <div />;
} else {
blank = true;
// Clear any notifications.
process.nextTick(() => actions.emit('system.notify'));
return this.renderCurrentRoute();
}
}

View File

@ -1,4 +1,4 @@
import EventEmitter from '../core/EventEmitter.js';
import EventEmitter from '../lib/EventEmitter.js';
// Just a namespace for all actions.
export default new EventEmitter();

View File

@ -20,12 +20,12 @@ export default React.createClass({
this.setState({ 'val': evt.target.value });
},
// Add the project (via Enter keypress).
_onKeyUp(evt) {
if (evt.key == 'Enter') {
this._onAdd();
}
if (evt.key == 'Enter') this._onAdd();
},
// Add the project.
_onAdd() {
let [ owner, name ] = this.state.val.split('/');
actions.emit('projects.add', { owner, name });
@ -33,6 +33,7 @@ export default React.createClass({
App.navigate({ 'to': 'projects' });
},
// Blank input.
getInitialState() {
return { 'val': '' };
},
@ -76,6 +77,7 @@ export default React.createClass({
);
},
// Focus input field on mount.
componentDidMount() {
this.refs.el.focus();
}

View File

@ -1,10 +1,9 @@
import React from 'react';
import d3 from 'd3';
import d3Tip from 'd3-tip';
d3Tip(d3);
import Format from '../mixins/Format.js';
import format from '../modules/format.js';
import lines from '../modules/chart/lines.js';
import axes from '../modules/chart/axes.js';
@ -13,8 +12,6 @@ export default React.createClass({
displayName: 'Chart.jsx',
mixins: [ Format ],
render() {
let milestone = this.props.milestone;
@ -22,9 +19,9 @@ export default React.createClass({
<div>
<div id="title">
<div className="wrap">
<h2 className="title">{this._title(milestone.title)}</h2>
<span className="sub">{this._due(milestone.due_on)}</span>
<div className="description">{this._markdown(milestone.description)}</div>
<h2 className="title">{format.title(milestone.title)}</h2>
<span className="sub">{format.due(milestone.due_on)}</span>
<div className="description">{format.markdown(milestone.description)}</div>
</div>
</div>
<div id="content" className="wrap">
@ -35,8 +32,8 @@ export default React.createClass({
},
componentDidMount() {
let milestone = this.props.milestone,
issues = milestone.issues;
let milestone = this.props.milestone;
let issues = milestone.issues;
// Total number of points in the milestone.
let total = issues.open.size + issues.closed.size;

View File

@ -0,0 +1,17 @@
import React from 'react';
export default React.createClass({
displayName: 'Footer.jsx',
render() {
return (
<div id="footer">
<div className="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
</div>
</div>
);
}
});

View File

@ -20,6 +20,7 @@ export default React.createClass({
actions.emit('user.signout');
},
// Add example projects.
_onDemo() {
actions.emit('projects.demo');
},
@ -28,12 +29,9 @@ export default React.createClass({
// From app store.
let props = this.props.app;
// Switch loading icon with app icon.
let icon = [ 'fire', 'spinner' ][ +props.system.loading ];
// Sign-in/out.
let user;
if (props.user && 'uid' in props.user) {
if (props.user != null && 'uid' in props.user) {
user = (
<div className="right">
<a onClick={this._onSignOut}>
@ -51,24 +49,27 @@ export default React.createClass({
);
}
// Switch loading icon with app icon.
let icon = [ 'fire', 'spinner' ][ +props.system.loading ];
return (
<div>
<Notify {...props.system.notification} />
<div id="head">
{user}
<Link route={{ to: 'projects' }} id="icon">
<Link route={{ 'to': 'projects' }} id="icon">
<Icon name={icon} />
</Link>
<ul>
<li>
<Link route={{ to: 'addProject' }}>
<Link route={{ 'to': 'addProject' }}>
<Icon name="plus" /> Add a Project
</Link>
</li>
<li>
<Link route={{ to: 'demo' }}>
<Link route={{ 'to': 'demo' }}>
<Icon name="computer" /> See Examples
</Link>
</li>

View File

@ -9,6 +9,7 @@ export default React.createClass({
displayName: 'Hero.jsx',
// Add example projects.
_onDemo() {
actions.emit('projects.demo');
},
@ -19,10 +20,15 @@ export default React.createClass({
<div className="content">
<Icon name="direction" />
<h2>See your project progress</h2>
<p>Serious about a project deadline? Add your GitHub project and we'll tell you if it is running on time or not.</p>
<p>Serious about a project deadline? Add your GitHub project
and we'll tell you if it is running on time or not.</p>
<div className="cta">
<Link route={{ to: 'addProject' }} className="primary"><Icon name="plus" /> Add a Project</Link>
<Link route={{ to: 'demo' }} className="secondary"><Icon name="computer" /> See Examples</Link>
<Link route={{ to: 'addProject' }} className="primary">
<Icon name="plus" /> Add a Project
</Link>
<Link route={{ to: 'demo' }} className="secondary">
<Icon name="computer" /> See Examples
</Link>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import React from 'react';
import Format from '../mixins/Format.js';
import format from '../modules/format.js';
// Fontello icon hex codes.
let codes = {
@ -25,17 +25,15 @@ export default React.createClass({
displayName: 'Icon.jsx',
mixins: [ Format ],
render() {
let name = this.props.name;
if (name && name in codes) {
let code = this._hexToDec(codes[name]);
let code = format.hexToDec(codes[name]);
return (
<span
className={'icon ' + name}
dangerouslySetInnerHTML={{ '__html': '&#' + code + ';' }}
className={`icon ${name}`}
dangerouslySetInnerHTML={{ '__html': `&#${code};` }}
/>
);
}

View File

@ -6,6 +6,7 @@ export default React.createClass({
displayName: 'Link.jsx',
// Navigate to a route.
_navigate(link, evt) {
App.navigate(link);
evt.preventDefault();
@ -18,7 +19,7 @@ export default React.createClass({
return (
<a
{...this.props}
href={'#!' + link}
href={`#!${link}`}
onClick={this._navigate.bind(this, link)}
>
{this.props.children}

View File

@ -2,7 +2,7 @@ import React from 'react';
import _ from 'lodash';
import cls from 'classnames';
import Format from '../mixins/Format.js';
import format from '../modules/format.js';
import actions from '../actions/appActions.js';
@ -13,8 +13,7 @@ export default React.createClass({
displayName: 'Milestones.jsx',
mixins: [ Format ],
// Cycle through milestones sort order.
_onSort() {
actions.emit('projects.sort');
},
@ -36,7 +35,7 @@ export default React.createClass({
);
}).value();
// Now for the list of milestones.
// Now for the list of milestones, index sorted.
let list = [];
_.each(projects.index, ([ pI, mI ]) => {
let { owner, name, milestones } = projects.list[pI];
@ -48,12 +47,18 @@ export default React.createClass({
list.push(
<tr className={cls({ 'done': milestone.stats.isDone })} key={`${pI}-${mI}`}>
<td className="repo">
<Link route={{ 'to': 'milestones', 'params': { owner, name } }} className="project">
<Link
route={{ 'to': 'milestones', 'params': { owner, name } }}
className="project"
>
{owner}/{name}
</Link>
</td>
<td>
<Link route={{ 'to': 'chart', 'params': { owner, name, 'milestone': milestone.number } }} className="milestone">
<Link
route={{ 'to': 'chart', 'params': { owner, name, 'milestone': milestone.number } }}
className="milestone"
>
{milestone.title}
</Link>
</td>
@ -61,7 +66,7 @@ export default React.createClass({
<div className="progress">
<span className="percent">{Math.floor(milestone.stats.progress.points)}%</span>
<span className={cls('due', { 'red': milestone.stats.isOverdue })}>
{this._due(milestone.due_on)}
{format.due(milestone.due_on)}
</span>
<div className="outer bar">
<div
@ -79,6 +84,7 @@ export default React.createClass({
if (!errors.length && !list.length) return false;
if (project) {
// List of projects and their milestones.
return (
<div id="projects">
<div className="header">
@ -86,14 +92,13 @@ export default React.createClass({
<h2>Milestones</h2>
</div>
<table>
<tbody>
{list}
</tbody>
<tbody>{list}</tbody>
</table>
<div className="footer" />
</div>
);
} else {
// Project-specific milestones.
return (
<div id="projects">
<div className="header">

View File

@ -9,15 +9,20 @@ let Notify = React.createClass({
displayName: 'Notify.jsx',
// Close notification.
_onClose() {
actions.emit('system.notify');
},
getDefaultProps() {
return {
// No text.
'text': null,
// Grey style.
'type': '',
// Top notification.
'system': false,
// Just announcing.
'icon': 'megaphone'
};
},
@ -25,6 +30,7 @@ let Notify = React.createClass({
render() {
let { text, system, type, icon, ttl } = this.props;
// No text = no message.
if (!text) return false;
if (system) {
@ -53,6 +59,7 @@ export default React.createClass({
render() {
if (!this.props.id) return false; // TODO: fix ghost
// Top bar or center positioned?
let name = (this.props.system) ? 'animCenter' : 'animTop';
return (

View File

@ -1,5 +1,6 @@
import React from 'react';
// Inserts a space before rendering text.
export default React.createClass({
displayName: 'Space.jsx',

View File

@ -0,0 +1,35 @@
import React from 'react';
import actions from '../../actions/appActions.js';
import Page from '../../lib/PageMixin.js';
import Notify from '../Notify.jsx';
import Header from '../Header.jsx';
import Footer from '../Footer.jsx';
import AddProjectForm from '../AddProjectForm.jsx';
export default React.createClass({
displayName: 'AddProjectPage.jsx',
mixins: [ Page ],
render() {
return (
<div>
<Notify />
<Header {...this.state} />
<div id="page">
<div id="content" className="wrap">
<AddProjectForm user={this.state.app.user} />
</div>
</div>
<Footer />
</div>
);
}
});

View File

@ -1,11 +1,12 @@
import React from 'react';
import _ from 'lodash';
import Page from '../mixins/Page.js';
import Page from '../../lib/PageMixin.js';
import Notify from '../components/Notify.jsx';
import Header from '../components/Header.jsx';
import Chart from '../components/Chart.jsx';
import Notify from '../Notify.jsx';
import Header from '../Header.jsx';
import Footer from '../Footer.jsx';
import Chart from '../Chart.jsx';
export default React.createClass({
@ -32,9 +33,7 @@ export default React.createClass({
return false;
});
if (milestone) {
content = <Chart milestone={milestone} />;
}
if (milestone) content = <Chart milestone={milestone} />;
}
return (
@ -44,11 +43,7 @@ export default React.createClass({
<div id="page">{content}</div>
<div id="footer">
<div className="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
</div>
</div>
<Footer />
</div>
);
}

View File

@ -1,10 +1,11 @@
import React from 'react';
import Page from '../mixins/Page.js';
import Page from '../../lib/PageMixin.js';
import Notify from '../components/Notify.jsx';
import Header from '../components/Header.jsx';
import Milestones from '../components/Milestones.jsx';
import Notify from '../Notify.jsx';
import Header from '../Header.jsx';
import Footer from '../Footer.jsx';
import Milestones from '../Milestones.jsx';
export default React.createClass({
@ -33,11 +34,7 @@ export default React.createClass({
<div id="content" className="wrap">{content}</div>
</div>
<div id="footer">
<div className="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
</div>
</div>
<Footer />
</div>
);
}

View File

@ -1,7 +1,8 @@
import React from 'react';
import Page from '../mixins/Page.js';
import Page from '../../lib/PageMixin.js';
// TODO: implement
export default React.createClass({
displayName: 'NotFoundPage.jsx',

View File

@ -1,11 +1,12 @@
import React from 'react';
import Page from '../mixins/Page.js';
import Page from '../../lib/PageMixin.js';
import Notify from '../components/Notify.jsx';
import Header from '../components/Header.jsx';
import Milestones from '../components/Milestones.jsx';
import Hero from '../components/Hero.jsx';
import Notify from '../Notify.jsx';
import Header from '../Header.jsx';
import Footer from '../Footer.jsx';
import Milestones from '../Milestones.jsx';
import Hero from '../Hero.jsx';
export default React.createClass({
@ -33,11 +34,7 @@ export default React.createClass({
<div id="content" className="wrap">{content}</div>
</div>
<div id="footer">
<div className="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
</div>
</div>
<Footer />
</div>
);
}

View File

@ -11,7 +11,7 @@ export default class EventEmitter {
emit(event, obj, ctx) {
if (!event.length) return;
this.list.forEach(sub => {
this.list.forEach((sub) => {
if (sub.pattern.test(event)) {
sub.cb.call(ctx, obj, event);
}
@ -31,12 +31,12 @@ export default class EventEmitter {
// Assume we can have multiple.
off(path, cb) {
_.remove(this.list, sub => sub.pattern.test(path) && sub.cb === cb);
_.remove(this.list, (sub) => sub.pattern.test(path) && sub.cb === cb);
}
// Remove all listeners with this callback.
offAny(cb) {
_.remove(this.list, sub => sub.cb === cb);
_.remove(this.list, (sub) => sub.cb === cb);
}
}

View File

@ -1,13 +1,6 @@
import _ from 'lodash';
// TODO: the app store needs to go last because it loads user.
import projectsStore from '../stores/projectsStore.js';
import appStore from '../stores/appStore.js';
let stores = {
'app': appStore,
'projects': projectsStore
};
import stores from '../stores';
export default {

View File

@ -2,7 +2,7 @@ import _ from 'lodash';
import d3 from 'd3';
import moment from 'moment';
import config from '../../models/config.js';
import config from '../../../config.js';
export default {

View File

@ -6,36 +6,36 @@ export default {
// Time from now.
// TODO: Memoize.
_fromNow(jsonDate) {
fromNow(jsonDate) {
return moment(jsonDate, moment.ISO_8601).fromNow();
},
// When is a milestone due?
_due(jsonDate) {
due(jsonDate) {
if (!jsonDate) {
return '\u00a0'; // for React
} else {
return [ 'due', this._fromNow(jsonDate) ].join(' ');
return `due ${this.fromNow(jsonDate)}`;
}
},
// Markdown formatting.
// TODO: works?
_markdown(...args) {
markdown(...args) {
marked.apply(null, args);
},
// Format milestone title.
_title(text) {
title(text) {
if (text.toLowerCase().indexOf('milestone') > -1) {
return text;
} else {
return [ 'Milestone', text ].join(' ');
return `Milestone ${text}`;
}
},
// Hex to decimal.
_hexToDec(hex) {
hexToDec(hex) {
return parseInt(hex, 16);
}

View File

@ -1,7 +1,7 @@
import _ from 'lodash';
import async from 'async';
import config from '../../models/config.js';
import config from '../../../config.js';
import request from './request.js';
// Fetch issues for a milestone.

View File

@ -3,7 +3,7 @@ import superagent from 'superagent';
import actions from '../../actions/appActions.js';
import config from '../../models/config.js';
import config from '../../../config.js';
// Custom JSON parser.
superagent.parse = {
@ -111,7 +111,7 @@ let request = ({ protocol, host, path, query, headers }, cb) => {
// How do we respond to a response?
let response = (err, data, cb) => {
if (err) return cb(error(err));
if (err) return cb(error(data.body || err));
// 2xx?
if (data.statusType !== 2) return cb(error(data.body));
// All good.
@ -134,7 +134,7 @@ let headers = (token) => {
// Parse an error.
let error = (err) => {
let text, type;
switch (false) {
case !_.isString(err):
text = err;
@ -155,13 +155,6 @@ let error = (err) => {
text = err.toString();
}
}
// API rate limit exceeded? Flash a message to that effect.
// https://developer.github.com/v3/#rate-limiting
if (/API rate limit exceeded/.test(text)) {
type = 'warn';
actions.emit('system.notify', { type, text });
}
return text;
};

View File

@ -14,4 +14,4 @@ _.mixin({
return obj;
});
}
});
});

View File

@ -1,38 +0,0 @@
import React from 'react';
import actions from '../actions/appActions.js';
import Page from '../mixins/Page.js';
import Notify from '../components/Notify.jsx';
import Header from '../components/Header.jsx';
import AddProjectForm from '../components/AddProjectForm.jsx';
export default React.createClass({
displayName: 'AddProjectPage.jsx',
mixins: [ Page ],
render() {
return (
<div>
<Notify />
<Header {...this.state} />
<div id="page">
<div id="content" className="wrap">
<AddProjectForm user={this.state.app.user} />
</div>
</div>
<div id="footer">
<div className="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
</div>
</div>
</div>
);
}
});

View File

@ -1,11 +1,11 @@
import _ from 'lodash';
import Firebase from 'firebase';
import Store from '../core/Store.js';
import Store from '../lib/Store.js';
import actions from '../actions/appActions.js';
import config from '../models/config.js';
import config from '../../config.js';
// Setup a new client.
let client;
@ -23,14 +23,12 @@ class AppStore extends Store {
// Listen to all app actions.
actions.onAny((obj, event) => {
let fn = ('on.' + event).replace(/[.]+(\w|$)/g, (m, p) => {
return p.toUpperCase();
});
let fn = `on.${event}`.replace(/[.]+(\w|$)/g, (m, p) => p.toUpperCase());
// Run?
(fn in this) && this[fn](obj);
});
client = new Firebase("https://" + config.firebase + ".firebaseio.com");
client = new Firebase(`https://${config.firebase}.firebaseio.com`);
// When user is already authenticated.
client.onAuth((data={}) => actions.emit('user.ready', data));

8
src/js/stores/index.js Normal file
View File

@ -0,0 +1,8 @@
// The app store needs to go last because it loads user.
import projectsStore from './projectsStore.js';
import appStore from './appStore.js';
export default {
'app': appStore,
'projects': projectsStore
};

View File

@ -4,7 +4,7 @@ import opa from 'object-path';
import semver from 'semver';
import sortedIndex from 'sortedindex-compare';
import Store from '../core/Store.js';
import Store from '../lib/Store.js';
import actions from '../actions/appActions.js';
@ -16,7 +16,7 @@ class ProjectsStore extends Store {
// Initial payload.
constructor() {
// Init the projects.
// Init the projects from local storage.
let list = lscache.get('projects') || [];
super({
@ -32,17 +32,13 @@ class ProjectsStore extends Store {
// Listen to only projects actions.
actions.on('projects.*', (obj, event) => {
let fn = ('on.' + event).replace(/[.]+(\w|$)/g, (m, p) => {
return p.toUpperCase();
});
let fn = `on.${event}`.replace(/[.]+(\w|$)/g, (m, p) => p.toUpperCase());
// Run?
(fn in this) && this[fn](obj);
});
// Listen to when user is ready and save info on us.
actions.on('user.ready', (user) => {
this.set('user', user);
});
actions.on('user.ready', (user) => this.set('user', user));
// Persist projects in local storage (sans milestones and issues).
this.on('list.*', () => {
@ -304,7 +300,8 @@ class ProjectsStore extends Store {
return _.findIndex(this.get('list'), { owner, name });
}
// Save an error from loading milestones or issues
// Save an error from loading milestones or issues.
// TODO: clear these when we fetch all projects anew.
saveError(project, err, say=false) {
var idx;
if ((idx = this.findIndex(project)) > -1) {

View File

@ -1,6 +1,6 @@
import { assert } from 'chai';
import EventEmitter from '../src/js/core/EventEmitter.js';
import EventEmitter from '../src/js/lib/EventEmitter.js';
export default {
EventEmitter: {

View File

@ -1,6 +1,6 @@
import { assert } from 'chai';
import Store from '../src/js/core/Store.js';
import Store from '../src/js/lib/Store.js';
export default {
Store: {

View File

@ -5,7 +5,7 @@ import opa from 'object-path';
import request from '../src/js/modules/github/request.js';
import issues from '../src/js/modules/github/issues.js';
import config from '../src/js/models/config.js';
import config from '../src/config.js';
import json from './fixtures/issues.json';

View File

@ -4,7 +4,7 @@ import opa from 'object-path';
import { noCallThru } from 'proxyquire'
let proxy = noCallThru();
import config from '../src/js/models/config.js';
import config from '../src/config.js';
class Sa {
constructor() {