suggest user or owner repos; closes #47

This commit is contained in:
Radek Stepan 2016-01-27 16:43:03 +01:00
parent e3953f6ce5
commit bae5d69997
12 changed files with 2948 additions and 327 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "burnchart", "name": "burnchart",
"version": "3.0.1", "version": "3.0.2",
"description": "GitHub Burndown Chart as a Service", "description": "GitHub Burndown Chart as a Service",
"author": "Radek Stepan <dev@radekstepan.com> (http://radekstepan.com)", "author": "Radek Stepan <dev@radekstepan.com> (http://radekstepan.com)",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@ -45,6 +45,7 @@
"proxyquire": "^1.7.3", "proxyquire": "^1.7.3",
"react": "^0.14.6", "react": "^0.14.6",
"react-addons-css-transition-group": "^0.14.6", "react-addons-css-transition-group": "^0.14.6",
"react-autosuggest": "^3.3.5",
"react-mini-router": "^2.0.0", "react-mini-router": "^2.0.0",
"semver": "^5.1.0", "semver": "^5.1.0",
"sortedindex-compare": "0.0.1", "sortedindex-compare": "0.0.1",

View File

@ -840,14 +840,14 @@ ul li {
box-sizing: border-box; box-sizing: border-box;
padding: 10px; padding: 10px;
width: 100%; width: 100%;
border: 1px solid #dde1ed;
border-right: 0;
-webkit-border-radius: 2px 0 0 2px; -webkit-border-radius: 2px 0 0 2px;
-webkit-background-clip: padding-box; -webkit-background-clip: padding-box;
-moz-border-radius: 2px 0 0 2px; -moz-border-radius: 2px 0 0 2px;
-moz-background-clip: padding; -moz-background-clip: padding;
border-radius: 2px 0 0 2px; border-radius: 2px 0 0 2px;
background-clip: padding-box; background-clip: padding-box;
border: 1px solid #dde1ed;
border-right: 0;
-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
-moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
@ -867,6 +867,26 @@ ul li {
background: #C1041C; background: #C1041C;
color: #fff; color: #fff;
} }
#page #content #add .form .suggest {
position: relative;
}
#page #content #add .form .suggest .list {
position: absolute;
top: 41px;
border: 1px solid #dde1ed;
min-width: 100%;
max-height: 200px;
overflow-y: auto;
}
#page #content #add .form .suggest .list .item {
background: rgba(255, 255, 255, 0.9);
padding: 10px;
display: block;
cursor: pointer;
}
#page #content #add .form .suggest .list .item.focused {
background: #EFEFEF;
}
#page #content #projects { #page #content #projects {
border: 1px solid #cdcecf; border: 1px solid #cdcecf;
-webkit-border-radius: 2px; -webkit-border-radius: 2px;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import Autosuggest from 'react-autosuggest';
import App from '../App.jsx'; import App from '../App.jsx';
@ -16,13 +17,23 @@ export default React.createClass({
actions.emit('user.signin'); actions.emit('user.signin');
}, },
_onChange(evt) { _onChange(evt, { newValue }) {
this.setState({ 'val': evt.target.value }); this.setState({ 'val': newValue });
}, },
// Add the project (via Enter keypress). // Get a list of repo suggestions.
_onKeyUp(evt) { _onGetList({ value }) {
if (evt.key == 'Enter') this._onAdd(); actions.emit('projects.search', value);
},
// What should be the value of the suggestion.
_getListValue(value) {
return value;
},
// How do we render the repo?
_renderListValue(value) {
return value;
}, },
// Add the project. // Add the project.
@ -40,6 +51,7 @@ export default React.createClass({
render() { render() {
let user; let user;
if (!(this.props.user != null && 'uid' in this.props.user)) { if (!(this.props.user != null && 'uid' in this.props.user)) {
user = ( user = (
<span><S />If you'd like to add a private GitHub repo, <span><S />If you'd like to add a private GitHub repo,
@ -60,8 +72,23 @@ export default React.createClass({
<tbody> <tbody>
<tr> <tr>
<td> <td>
<input type="text" ref="el" placeholder="user/repo" autoComplete="off" <Autosuggest
onChange={this._onChange} value={this.state.val} onKeyUp={this._onKeyUp} /> suggestions={this.props.suggestions || []}
getSuggestionValue={this._getListValue}
onSuggestionsUpdateRequested={this._onGetList}
renderSuggestion={this._renderListValue}
theme={{
'container': 'suggest',
'suggestionsContainer': 'list',
'suggestion': 'item',
'suggestionFocused': 'item focused'
}}
inputProps={{
'placeholder': 'user/repo',
'value': this.state.val,
'onChange': this._onChange
}}
/>
</td> </td>
<td><a onClick={this._onAdd}>Add</a></td> <td><a onClick={this._onAdd}>Add</a></td>
</tr> </tr>
@ -79,7 +106,7 @@ export default React.createClass({
// Focus input field on mount. // Focus input field on mount.
componentDidMount() { componentDidMount() {
this.refs.el.focus(); if ('el' in this.refs) this.refs.el.focus();
} }
}); });

View File

@ -23,7 +23,10 @@ export default React.createClass({
<div id="page"> <div id="page">
<div id="content" className="wrap"> <div id="content" className="wrap">
<AddProjectForm user={this.state.app.user} /> <AddProjectForm
user={this.state.app.user}
suggestions={this.state.projects.suggestions}
/>
</div> </div>
</div> </div>

View File

@ -1,8 +0,0 @@
import request from './request.js';
export default {
// Fetch a milestone.
fetch: request.oneMilestone,
// Fetch all milestones.
fetchAll: request.allMilestones
};

View File

@ -38,6 +38,23 @@ export default {
request(data, cb); request(data, cb);
}, },
// Get repos user has access to or are public to owner.
repos: (user, ...args) => {
if (args.length = 2) {
var [ owner, cb ] = args;
} else { // assumes 1
var [ cb ] = args;
}
let token = (user && user.github != null) ? user.github.accessToken : null;
let data = _.defaults({
'path': owner ? `/users/${owner}/repos` : '/user/repos',
'headers': headers(token)
}, defaults.github);
request(data, cb);
},
// Get all open milestones. // Get all open milestones.
allMilestones: (user, { owner, name }, cb) => { allMilestones: (user, { owner, name }, cb) => {
let token = (user && user.github != null) ? user.github.accessToken : null; let token = (user && user.github != null) ? user.github.accessToken : null;

View File

@ -9,7 +9,7 @@ import Store from '../lib/Store.js';
import actions from '../actions/appActions.js'; import actions from '../actions/appActions.js';
import stats from '../modules/stats.js'; import stats from '../modules/stats.js';
import milestones from '../modules/github/milestones.js'; import request from '../modules/github/request.js';
import issues from '../modules/github/issues.js'; import issues from '../modules/github/issues.js';
class ProjectsStore extends Store { class ProjectsStore extends Store {
@ -53,6 +53,11 @@ class ProjectsStore extends Store {
// Run the sort again. // Run the sort again.
this.sort(); this.sort();
}); });
// Debounce.
if (process.browser) { // easier to test
this.onProjectsSearch = _.debounce(this.onProjectsSearch, 500);
}
} }
// Fetch milestone(s) and issues for a project(s). // Fetch milestone(s) and issues for a project(s).
@ -118,6 +123,50 @@ class ProjectsStore extends Store {
}); });
} }
// Search for projects.
onProjectsSearch(text) {
if (!text || !text.length) return;
// Wait for the user to get resolved.
this.get('user', this.cb((user) => { // async
// Can we get the owner (and name) from the text?
if (/\//.test(text)) {
var [ owner, name ] = text.split('/');
} else {
text = new RegExp(`^${text}`, 'i');
}
// No owner and no user means nothing to go by.
if (!owner && !user) return;
// Make the request.
request.repos(user, owner, this.cb((err, res) => {
if (err) return; // ignore errors
let list = _(res)
.filter((repo) => {
// Remove repos with no issues.
if (!repo.has_issues) return;
// Remove repos we have already.
if (this.has(repo.owner.login, repo.name)) return;
// Match on owner or name.
if (owner) {
if (!new RegExp(`^${owner}`, 'i').test(repo.owner.login)) return;
if (!name || new RegExp(`^${name}`, 'i').test(repo.name)) return true;
} else {
return text.test(repo.owner.login) || text.test(repo.name);
}
})
.map(({ full_name }) => full_name)
.value();
this.set('suggestions', list);
}));
}));
}
// Return a sort order comparator. // Return a sort order comparator.
comparator() { comparator() {
let { list, sortBy } = this.get(); let { list, sortBy } = this.get();
@ -193,7 +242,7 @@ class ProjectsStore extends Store {
// Fetch milestones and issues for a project. // Fetch milestones and issues for a project.
getProject(user, p) { getProject(user, p) {
// Fetch their milestones. // Fetch their milestones.
milestones.fetchAll(user, p, this.cb((err, milestones) => { // async request.allMilestones(user, p, this.cb((err, milestones) => { // async
// Save the error if project does not exist. // Save the error if project does not exist.
if (err) return this.saveError(p, err); if (err) return this.saveError(p, err);
// Now add in the issues. // Now add in the issues.
@ -212,7 +261,7 @@ class ProjectsStore extends Store {
// Fetch a single milestone. // Fetch a single milestone.
getMilestone(user, p, m, say) { getMilestone(user, p, m, say) {
// Fetch the single milestone. // Fetch the single milestone.
milestones.fetch(user, { request.oneMilestone(user, {
'owner': p.owner, 'owner': p.owner,
'name': p.name, 'name': p.name,
'milestone': m 'milestone': m
@ -361,6 +410,14 @@ class ProjectsStore extends Store {
this.set('index', index); this.set('index', index);
} }
// Do we have this project? Case-insensitive.
has(o, n) {
o = o.toUpperCase() ; n = n.toUpperCase();
return !!_.find(this.get('list'), ({ owner, name }) => {
return o == owner.toUpperCase() && n == name.toUpperCase();
});
}
} }
export default new ProjectsStore(); export default new ProjectsStore();

View File

@ -334,9 +334,9 @@ ul {
box-sizing: border-box; box-sizing: border-box;
padding: 10px; padding: 10px;
width: 100%; width: 100%;
.border-radius(2px 0 0 2px);
border: 1px solid #dde1ed; border: 1px solid #dde1ed;
border-right: 0; border-right: 0;
.border-radius(2px 0 0 2px);
.box-shadow(inset 0 1px 2px rgba(0,0,0,0.2)); .box-shadow(inset 0 1px 2px rgba(0,0,0,0.2));
} }
@ -350,6 +350,31 @@ ul {
background: @strong_color; background: @strong_color;
color: #fff; color: #fff;
} }
// Autosuggestion component.
.suggest {
position: relative;
.list {
position: absolute;
top: 41px;
border: 1px solid #dde1ed;
min-width: 100%;
max-height: 200px;
overflow-y: auto;
.item {
background: rgba(255,255,255,0.9);
padding: 10px;
display: block;
cursor: pointer;
&.focused {
background: #EFEFEF;
}
}
}
}
} }
} }

View File

@ -1,6 +1,13 @@
import { assert } from 'chai'; import { assert } from 'chai';
import path from 'path';
import { noCallThru } from 'proxyquire'
let proxy = noCallThru();
import projects from '../src/js/stores/projectsStore.js'; let request = {};
// Proxy the request module.
let lib = path.resolve(__dirname, '../src/js/stores/projectsStore.js');
let projects = proxy(lib, { '../modules/github/request.js': request }).default;
export default { export default {
'projects - initializes empty': (done) => { 'projects - initializes empty': (done) => {
@ -203,6 +210,37 @@ export default {
assert.deepEqual(projects.get('index'), [[0, 2], [0, 1], [0, 0]]); assert.deepEqual(projects.get('index'), [[0, 2], [0, 1], [0, 0]]);
done();
},
'projects - search': (done) => {
projects.set({ 'list': [
{ 'owner': 'radek', 'name': 'A' }
], 'index': [], 'sortBy': 'name', 'user': null });
// Skip search.
request.repos = (user, owner, cb) => assert(false);
projects.onProjectsSearch();
// Search on text.
request.repos = (user, owner, cb) => assert(owner == undefined);
projects.onProjectsSearch('radek');
// Search on owner.
request.repos = (user, owner, cb) => assert(owner == 'radek');
projects.onProjectsSearch('radek/project');
request.repos = (user, owner, cb) => {
cb(null, [
{ 'has_issues': true, 'owner': { 'login': 'Radek' }, 'name': 'A', 'full_name': 'Radek/A' }, // exists
{ 'has_issues': true, 'owner': { 'login': 'radek' }, 'name': 'aA', 'full_name': 'radek/aA' }, // ok
{ 'has_issues': true, 'owner': { 'login': 'a' }, 'name': 'A', 'full_name': 'a/A' }, // wrong owner
{ 'has_issues': false, 'owner': { 'login': 'radek' }, 'name': 'aaa', 'full_name': 'radek/aaa' } // no issues
]);
};
projects.onProjectsSearch('radek/a');
assert.deepEqual(projects.get('suggestions'), [ 'radek/aA' ]);
done(); done();
} }
}; };