suggest user or owner repos; closes #47
This commit is contained in:
parent
e3953f6ce5
commit
bae5d69997
|
@ -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",
|
||||||
|
|
|
@ -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
3010
public/js/bundle.js
3010
public/js/bundle.js
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
import request from './request.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Fetch a milestone.
|
|
||||||
fetch: request.oneMilestone,
|
|
||||||
// Fetch all milestones.
|
|
||||||
fetchAll: request.allMilestones
|
|
||||||
};
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue