suggest user or owner repos; closes #47
This commit is contained in:
parent
e3953f6ce5
commit
bae5d69997
|
@ -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",
|
||||
|
|
|
@ -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
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 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();
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
// 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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue