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

View File

@ -840,14 +840,14 @@ ul li {
box-sizing: border-box;
padding: 10px;
width: 100%;
border: 1px solid #dde1ed;
border-right: 0;
-webkit-border-radius: 2px 0 0 2px;
-webkit-background-clip: padding-box;
-moz-border-radius: 2px 0 0 2px;
-moz-background-clip: padding;
border-radius: 2px 0 0 2px;
background-clip: padding-box;
border: 1px solid #dde1ed;
border-right: 0;
-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);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
@ -867,6 +867,26 @@ ul li {
background: #C1041C;
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 {
border: 1px solid #cdcecf;
-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 Autosuggest from 'react-autosuggest';
import App from '../App.jsx';
@ -16,13 +17,23 @@ export default React.createClass({
actions.emit('user.signin');
},
_onChange(evt) {
this.setState({ 'val': evt.target.value });
_onChange(evt, { newValue }) {
this.setState({ 'val': newValue });
},
// Add the project (via Enter keypress).
_onKeyUp(evt) {
if (evt.key == 'Enter') this._onAdd();
// Get a list of repo suggestions.
_onGetList({ value }) {
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.
@ -40,6 +51,7 @@ export default React.createClass({
render() {
let user;
if (!(this.props.user != null && 'uid' in this.props.user)) {
user = (
<span><S />If you'd like to add a private GitHub repo,
@ -60,8 +72,23 @@ export default React.createClass({
<tbody>
<tr>
<td>
<input type="text" ref="el" placeholder="user/repo" autoComplete="off"
onChange={this._onChange} value={this.state.val} onKeyUp={this._onKeyUp} />
<Autosuggest
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><a onClick={this._onAdd}>Add</a></td>
</tr>
@ -79,7 +106,7 @@ export default React.createClass({
// Focus input field on mount.
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="content" className="wrap">
<AddProjectForm user={this.state.app.user} />
<AddProjectForm
user={this.state.app.user}
suggestions={this.state.projects.suggestions}
/>
</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);
},
// 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.
allMilestones: (user, { owner, name }, cb) => {
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 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';
class ProjectsStore extends Store {
@ -53,6 +53,11 @@ class ProjectsStore extends Store {
// Run the sort again.
this.sort();
});
// Debounce.
if (process.browser) { // easier to test
this.onProjectsSearch = _.debounce(this.onProjectsSearch, 500);
}
}
// 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.
comparator() {
let { list, sortBy } = this.get();
@ -193,7 +242,7 @@ class ProjectsStore extends Store {
// Fetch milestones and issues for a project.
getProject(user, p) {
// 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.
if (err) return this.saveError(p, err);
// Now add in the issues.
@ -212,7 +261,7 @@ class ProjectsStore extends Store {
// Fetch a single milestone.
getMilestone(user, p, m, say) {
// Fetch the single milestone.
milestones.fetch(user, {
request.oneMilestone(user, {
'owner': p.owner,
'name': p.name,
'milestone': m
@ -361,6 +410,14 @@ class ProjectsStore extends Store {
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();

View File

@ -334,9 +334,9 @@ ul {
box-sizing: border-box;
padding: 10px;
width: 100%;
.border-radius(2px 0 0 2px);
border: 1px solid #dde1ed;
border-right: 0;
.border-radius(2px 0 0 2px);
.box-shadow(inset 0 1px 2px rgba(0,0,0,0.2));
}
@ -350,6 +350,31 @@ ul {
background: @strong_color;
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 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 {
'projects - initializes empty': (done) => {
@ -203,6 +210,37 @@ export default {
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();
}
};