├── .gitignore
├── src
├── dispatchers
│ └── dispatcher.es6
├── lib
│ ├── actions.es6
│ ├── api.es6
│ └── store.es6
├── components
│ ├── markdown.jsx
│ ├── button.jsx
│ ├── issues-list.jsx
│ ├── dropdown-item.jsx
│ ├── repo-info.jsx
│ ├── notification.jsx
│ ├── input.jsx
│ ├── dropdown.jsx
│ ├── issue-item.jsx
│ ├── pagination.jsx
│ └── search-form.jsx
├── utils
│ └── fetch.es6
├── stores
│ ├── user-store.es6
│ ├── issues-store.es6
│ ├── repos-store.es6
│ └── pagination-store.es6
├── constants
│ └── app-constants.es6
├── actions
│ └── app-actions.es6
├── api
│ └── github-api.es6
└── app.jsx
├── index.html
├── .editorconfig
├── webpack.config.js
├── package.json
├── webpack-prod.config.js
├── README.md
└── styl
└── main.styl
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | fonts
3 | node_modules
4 |
--------------------------------------------------------------------------------
/src/dispatchers/dispatcher.es6:
--------------------------------------------------------------------------------
1 | import { Dispatcher } from 'flux';
2 |
3 | export default new Dispatcher();
4 |
--------------------------------------------------------------------------------
/src/lib/actions.es6:
--------------------------------------------------------------------------------
1 | import Dispatcher from '../dispatchers/dispatcher';
2 |
3 | let Actions = {
4 |
5 | _dispatch (action) {
6 |
7 | return new Promise((resolve, reject) => {
8 |
9 | action.promise = { resolve, reject };
10 |
11 | Dispatcher.dispatch(action);
12 | });
13 | }
14 | };
15 |
16 | export default Actions;
17 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | GitHub Issues
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/markdown.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import marked from 'marked';
4 |
5 | let Markdown = React.createClass({
6 |
7 | getDefaultProps() {
8 |
9 | return {
10 |
11 | md: ''
12 | };
13 | },
14 |
15 | render() {
16 |
17 | let html = { __html:marked(this.props.md) };
18 |
19 | return ;
20 | }
21 | });
22 |
23 | export default Markdown;
24 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 |
8 | [*]
9 |
10 | # Change these settings to your own preference
11 | indent_style = space
12 | indent_size = 4
13 |
14 | # We recommend you to keep these unchanged
15 | end_of_line = lf
16 | charset = utf-8
17 | trim_trailing_whitespace = true
18 | insert_final_newline = true
19 |
20 | [*.md]
21 | trim_trailing_whitespace = false
22 |
--------------------------------------------------------------------------------
/src/utils/fetch.es6:
--------------------------------------------------------------------------------
1 | let fetch = function (url) {
2 |
3 | return new Promise(function (resolve, reject) {
4 |
5 | let xhr = new XMLHttpRequest();
6 |
7 | xhr.open('GET', url);
8 | xhr.responseType = 'json';
9 | xhr.onload = () => {
10 |
11 | if (xhr.status === 200) {
12 |
13 | resolve(xhr.response);
14 | } else {
15 |
16 | reject(xhr.status);
17 | }
18 | };
19 |
20 | xhr.send();
21 | });
22 | };
23 |
24 | export default fetch;
25 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |
3 | watch: true,
4 | entry: [
5 | __dirname + '/src/app.jsx'
6 | ],
7 | module: {
8 | loaders: [
9 | { test: /\.(jsx|es6)$/, exclude: /node_modules/, loaders: ['6to5-loader?optional=coreAliasing'] },
10 | { test: /\.styl$/, loader: 'style-loader!css-loader!stylus-loader?paths=node_modules/' }
11 | ]
12 | },
13 | output: {
14 | path: __dirname + '/dist',
15 | filename: 'app.js'
16 | },
17 | resolve: {
18 | extensions: ['', '.js', '.jsx', '.es6', '.styl']
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/button.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react/addons';
2 |
3 | let Button = React.createClass({
4 |
5 | mixins: [React.addons.PureRenderMixin],
6 |
7 | getDefaultProps() {
8 |
9 | return {
10 |
11 | type: 'button',
12 | className: ''
13 | };
14 | },
15 |
16 | render() {
17 |
18 | return (
19 |
20 |
23 | );
24 | }
25 |
26 | });
27 |
28 | export default Button;
29 |
--------------------------------------------------------------------------------
/src/stores/user-store.es6:
--------------------------------------------------------------------------------
1 | import Store from '../lib/store';
2 | import Constants from '../constants/app-constants';
3 |
4 | let UserStore = new Store({
5 |
6 | _state: {
7 |
8 | userName: '',
9 | repoName: ''
10 | }
11 | });
12 |
13 | UserStore.bindAction(Constants.UPDATE_USER, action => {
14 |
15 | let id = UserStore.registerAction({
16 |
17 | [Constants.UPDATE_USER_SUCCESS]: action.promise.resolve,
18 | [Constants.UPDATE_USER_ERROR]: action.promise.reject
19 | });
20 |
21 | let nextState = action.payload.userData;
22 |
23 | UserStore.update(nextState, Constants.UPDATE_USER_SUCCESS, id);
24 | });
25 |
26 | export default UserStore;
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "github-issues",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "src/app.jsx",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "",
10 | "license": "MIT",
11 | "devDependencies": {
12 | "6to5-loader": "^2.0.0",
13 | "core-js": "^0.4.5",
14 | "css-loader": "^0.9.1",
15 | "eventemitter2": "^0.4.14",
16 | "file-loader": "^0.8.1",
17 | "flux": "^2.0.1",
18 | "marked": "^0.3.3",
19 | "react": "^0.12.2",
20 | "style-loader": "^0.8.3",
21 | "stylus": "^0.49.3",
22 | "stylus-loader": "^0.5.0",
23 | "webpack": "^1.4.15"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/issues-list.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import IssueItem from '../components/issue-item';
4 | import Pagination from '../components/pagination';
5 |
6 | let IssuesList = React.createClass({
7 |
8 | getDefaultProps() {
9 |
10 | return {
11 |
12 | issues: [],
13 | pagination: {}
14 | };
15 | },
16 |
17 | render() {
18 |
19 | let issuesList = this.props.issues.map((issue, index) => {
20 |
21 | return ;
22 | });
23 |
24 | return (
25 |
26 |
27 |
28 |
{issuesList}
29 |
30 | );
31 | }
32 |
33 | });
34 |
35 | export default IssuesList;
36 |
--------------------------------------------------------------------------------
/src/lib/api.es6:
--------------------------------------------------------------------------------
1 | import fetch from '../utils/fetch';
2 |
3 | import Dispatcher from '../dispatchers/dispatcher';
4 |
5 | let API = {
6 |
7 | _fetch (id, url, { SUCCESS, ERROR }) {
8 |
9 | return new Promise((resolve, reject) => {
10 |
11 | fetch(url).then((response) => {
12 |
13 | this._dispatch({
14 |
15 | id,
16 | actionType: SUCCESS,
17 | payload: { response }
18 | });
19 |
20 | resolve(id);
21 | })
22 | .catch((error) => {
23 |
24 | this._dispatch({
25 |
26 | id,
27 | actionType: ERROR,
28 | payload: { error }
29 | });
30 |
31 | reject(id);
32 | });
33 | });
34 | },
35 |
36 | _dispatch (action) {
37 |
38 | Dispatcher.dispatch(action);
39 | }
40 | };
41 |
42 | export default API;
43 |
--------------------------------------------------------------------------------
/src/constants/app-constants.es6:
--------------------------------------------------------------------------------
1 | const Constants = {
2 |
3 | API_ROOT: 'https://api.github.com/',
4 |
5 | CHANGE_EVENT: Symbol('change'),
6 |
7 | FETCH_ISSUES: Symbol('fetch-issues'),
8 | FETCH_ISSUES_SUCCESS: Symbol('fetch-issues-success'),
9 | FETCH_ISSUES_ERROR: Symbol('fetch-issues-error'),
10 |
11 | FETCH_REPOS: Symbol('fetch-repos'),
12 | FETCH_REPOS_SUCCESS: Symbol('fetch-repos-success'),
13 | FETCH_REPOS_ERROR: Symbol('fetch-repos-error'),
14 |
15 | UPDATE_USER: Symbol('update-user'),
16 | UPDATE_USER_SUCCESS: Symbol('update-user-success'),
17 | UPDATE_USER_ERROR: Symbol('update-user-error'),
18 |
19 | PAGINATE: Symbol('paginate'),
20 | PAGINATE_SUCCESS: Symbol('paginate-success'),
21 | PAGINATE_ERROR: Symbol('paginate-error'),
22 |
23 | SET_PAGE_SIZE: Symbol('set-page-size'),
24 | SET_PAGE_SIZE_SUCCESS: Symbol('set-page-size-success'),
25 | SET_PAGE_SIZE_ERROR: Symbol('set-page-size-error'),
26 |
27 | SET_REPO: Symbol('set-repo')
28 | };
29 |
30 | export default Constants;
31 |
--------------------------------------------------------------------------------
/src/stores/issues-store.es6:
--------------------------------------------------------------------------------
1 | import GitHubAPI from '../api/github-api';
2 |
3 | import Store from '../lib/store';
4 | import Constants from '../constants/app-constants';
5 |
6 | let IssuesStore = new Store({
7 |
8 | _state: {
9 |
10 | issues: []
11 | }
12 | });
13 |
14 | IssuesStore.bindAction(Constants.FETCH_ISSUES, action => {
15 |
16 | let id = IssuesStore.registerAction({
17 |
18 | [Constants.FETCH_ISSUES_SUCCESS]: action.promise.resolve,
19 | [Constants.FETCH_ISSUES_ERROR]: action.promise.reject
20 | });
21 |
22 | GitHubAPI.fetchIssues(action.payload, id);
23 | });
24 |
25 | IssuesStore.bindAction(Constants.FETCH_ISSUES_SUCCESS, action => {
26 |
27 | let nextState = { issues: action.payload.response };
28 |
29 | IssuesStore.update(nextState, Constants.FETCH_ISSUES_SUCCESS, action.id);
30 | });
31 |
32 | IssuesStore.bindAction(Constants.PAGINATE_SUCCESS, action => {
33 |
34 | let nextState = { issues: action.payload.response };
35 |
36 | IssuesStore.update(nextState);
37 | });
38 |
39 | export default IssuesStore;
40 |
--------------------------------------------------------------------------------
/webpack-prod.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 |
3 | module.exports = {
4 |
5 | watch: true,
6 | entry: [
7 | __dirname + '/src/app.jsx'
8 | ],
9 | module: {
10 | loaders: [
11 | { test: /\.(jsx|es6)$/, exclude: /node_modules/, loaders: ['6to5-loader?optional=coreAliasing'] },
12 | { test: /\.styl$/, loader: 'style-loader!css-loader!stylus-loader?paths=node_modules/' }
13 | ],
14 | noParse: [__dirname + '/node_modules/react/dist/react-with-addons.min.js']
15 | },
16 | output: {
17 | path: __dirname + '/dist',
18 | filename: 'app.prod.js'
19 | },
20 | resolve: {
21 | extensions: ['', '.js', '.jsx', '.es6', '.styl'],
22 | alias: {
23 | 'react$': __dirname + '/node_modules/react/dist/react-with-addons.min.js',
24 | 'react/addons$': __dirname + '/node_modules/react/dist/react-with-addons.min.js'
25 | }
26 | },
27 | plugins: [
28 | new webpack.optimize.DedupePlugin(),
29 | new webpack.optimize.UglifyJsPlugin()
30 | ]
31 | };
32 |
--------------------------------------------------------------------------------
/src/components/dropdown-item.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react/addons';
2 |
3 | let DropdownItem = React.createClass({
4 |
5 | mixins: [React.addons.PureRenderMixin],
6 |
7 | getDefaultProps() {
8 |
9 | return {
10 |
11 | className: 'item',
12 | focus: false,
13 | index: 0
14 | };
15 | },
16 |
17 | componentDidUpdate() {
18 |
19 | if (this.props.focus) { this.getDOMNode().focus(); }
20 | },
21 |
22 | _onKeyDown (event) {
23 |
24 | event.preventDefault();
25 |
26 | if (event.keyCode === 13 || event.type === 'touchstart') {
27 |
28 | return this.props.onItemSelect(this.props.index);
29 | }
30 |
31 | this.props.onKeyDown(event);
32 | },
33 |
34 | render() {
35 |
36 | return (
37 |
38 | {this.props.children}
42 | );
43 | }
44 |
45 | });
46 |
47 | export default DropdownItem;
48 |
--------------------------------------------------------------------------------
/src/components/repo-info.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react/addons';
2 |
3 | let RepoInfo = React.createClass({
4 |
5 | mixins: [React.addons.PureRenderMixin],
6 |
7 | getDefaultProps() {
8 |
9 | return {
10 |
11 | repo: {}
12 | };
13 | },
14 |
15 | render() {
16 |
17 | let repo = this.props.repo;
18 |
19 | let component = (
20 |
21 |
22 |
23 |
24 |
{repo.description}
25 |
26 |
27 | {repo.language}
28 | {repo.stargazers_count}
29 | {repo.forks_count}
30 |
31 |
32 |
33 | );
34 |
35 | component = !Object.keys(repo).length ? null : component;
36 |
37 | return component;
38 | }
39 | });
40 |
41 | export default RepoInfo;
42 |
--------------------------------------------------------------------------------
/src/stores/repos-store.es6:
--------------------------------------------------------------------------------
1 | import GitHubAPI from '../api/github-api';
2 |
3 | import Store from '../lib/store';
4 | import Constants from '../constants/app-constants';
5 |
6 | let ReposStore = new Store({
7 |
8 | _state: {
9 |
10 | repos: [],
11 | selectedRepo: {}
12 | }
13 | });
14 |
15 | ReposStore.bindAction(Constants.FETCH_REPOS, action => {
16 |
17 | let id = ReposStore.registerAction({
18 |
19 | [Constants.FETCH_REPOS_SUCCESS]: action.promise.resolve,
20 | [Constants.FETCH_REPOS_ERROR]: action.promise.reject
21 | });
22 |
23 | GitHubAPI.fetchRepos(action.payload.userName, id);
24 | });
25 |
26 | ReposStore.bindAction(Constants.FETCH_REPOS_SUCCESS, action => {
27 |
28 | let nextState = { repos: action.payload.response };
29 |
30 | ReposStore.update(nextState, Constants.FETCH_REPOS_SUCCESS, action.id);
31 | });
32 |
33 | ReposStore.bindAction(Constants.SET_REPO, action => {
34 |
35 | let selectedRepo = ReposStore.getState().repos
36 | .filter(repo => action.payload.repoName === repo.name);
37 |
38 | let nextState = selectedRepo.length === 1 ?
39 | { selectedRepo: selectedRepo[0] } :
40 | {};
41 |
42 | ReposStore.update(nextState);
43 | });
44 |
45 | export default ReposStore;
46 |
--------------------------------------------------------------------------------
/src/stores/pagination-store.es6:
--------------------------------------------------------------------------------
1 | import GitHubAPI from '../api/github-api';
2 |
3 | import Store from '../lib/store';
4 | import Constants from '../constants/app-constants';
5 |
6 | let PaginationStore = new Store({
7 |
8 | _state: {
9 |
10 | currPage: 1,
11 | perPage: 10,
12 | disabled: true
13 | }
14 | });
15 |
16 | PaginationStore.bindAction(Constants.PAGINATE, action => {
17 |
18 | let id = PaginationStore.registerAction({
19 |
20 | [Constants.PAGINATE_SUCCESS]: action.promise.resolve,
21 | [Constants.PAGINATE_ERROR]: action.promise.reject
22 | });
23 |
24 | GitHubAPI.paginate(action.payload, id)
25 | .then(() => {
26 |
27 | let nextState = action.payload.paginationData;
28 |
29 | PaginationStore.update(nextState, Constants.PAGINATE_SUCCESS, id);
30 | });
31 | });
32 |
33 | PaginationStore.bindAction(Constants.SET_PAGE_SIZE, action => {
34 |
35 | let id = PaginationStore.registerAction({
36 |
37 | [Constants.SET_PAGE_SIZE_SUCCESS]: action.promise.resolve,
38 | [Constants.SET_PAGE_SIZE_ERROR]: action.promise.reject
39 | });
40 |
41 | let nextState = action.payload;
42 |
43 | PaginationStore.update(nextState, Constants.SET_PAGE_SIZE_SUCCESS, id);
44 | });
45 |
46 | export default PaginationStore;
47 |
--------------------------------------------------------------------------------
/src/components/notification.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Dispatcher from '../dispatchers/dispatcher';
4 | import Constants from '../constants/app-constants';
5 |
6 | let Notification = React.createClass({
7 |
8 | getInitialState() {
9 |
10 | return {
11 |
12 | show: false,
13 | message: ''
14 | };
15 | },
16 |
17 | componentWillReceiveProps ({ status }) {
18 |
19 | if (status) {
20 |
21 | let message = '';
22 |
23 | switch (status) {
24 |
25 | case Constants.FETCH_REPOS_SUCCESS:
26 |
27 | message = 'Repos fetched successfully!';
28 | break;
29 |
30 | case Constants.FETCH_ISSUES_SUCCESS:
31 |
32 | message = 'Issues fetched successfully!';
33 | break;
34 | }
35 |
36 | this.setState({ show: true, message }, () => {
37 |
38 | setTimeout(() => this.setState({ show: false }), 2000);
39 | });
40 | }
41 | },
42 |
43 | render() {
44 |
45 | let notify = this.state.show ?
46 | {this.state.message}
: null;
47 |
48 | return {notify}
;
49 | }
50 |
51 | });
52 |
53 | export default Notification;
54 |
--------------------------------------------------------------------------------
/src/actions/app-actions.es6:
--------------------------------------------------------------------------------
1 | import Constants from '../constants/app-constants';
2 |
3 | import Actions from '../lib/actions';
4 |
5 | let AppActions = {
6 |
7 | fetchRepos (userName) {
8 |
9 | return this._dispatch({
10 |
11 | actionType: Constants.FETCH_REPOS,
12 | payload: { userName }
13 | });
14 | },
15 |
16 | updateUser (userData) {
17 |
18 | return this._dispatch({
19 |
20 | actionType: Constants.UPDATE_USER,
21 | payload: { userData }
22 | });
23 | },
24 |
25 | submitForm (userData, paginationData) {
26 |
27 | return this._dispatch({
28 |
29 | actionType: Constants.FETCH_ISSUES,
30 | payload: { userData, paginationData }
31 | });
32 | },
33 |
34 | paginate (userData, paginationData) {
35 |
36 | return this._dispatch({
37 |
38 | actionType: Constants.PAGINATE,
39 | payload: { userData, paginationData }
40 | });
41 | },
42 |
43 | setPageSize (perPage) {
44 |
45 | return this._dispatch({
46 |
47 | actionType: Constants.SET_PAGE_SIZE,
48 | payload: { perPage }
49 | });
50 | },
51 |
52 | setRepo (repoName) {
53 |
54 | return this._dispatch({
55 |
56 | actionType: Constants.SET_REPO,
57 | payload: { repoName }
58 | });
59 | }
60 | };
61 |
62 | Object.assign(AppActions, Actions);
63 |
64 | export default AppActions;
65 |
--------------------------------------------------------------------------------
/src/components/input.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | let Input = React.createClass({
4 |
5 | getDefaultProps() {
6 |
7 | return {
8 |
9 | wrapperClassName: 'field',
10 | labelClassName: 'label',
11 | type: 'text',
12 | min: '',
13 | value: '',
14 | placeholder: '',
15 | required: false,
16 | disabled: false
17 | };
18 | },
19 |
20 | getValue() {
21 |
22 | return this.refs.input.getDOMNode().value;
23 | },
24 |
25 | render() {
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
44 |
45 | {this.props.children}
46 |
47 |
48 | );
49 | }
50 |
51 | });
52 |
53 | export default Input;
54 |
--------------------------------------------------------------------------------
/src/api/github-api.es6:
--------------------------------------------------------------------------------
1 | import API from '../lib/api';
2 |
3 | import Constants from '../constants/app-constants';
4 |
5 | let GitHubAPI = {
6 |
7 | fetchIssues (payload, id) {
8 |
9 | let url = this._getIssuesURL(payload);
10 |
11 | return this._fetch.call(this, id, url, {
12 |
13 | SUCCESS: Constants.FETCH_ISSUES_SUCCESS,
14 | ERROR: Constants.FETCH_ISSUES_ERROR
15 | });
16 | },
17 |
18 | paginate (payload, id) {
19 |
20 | let url = this._getIssuesURL(payload);
21 |
22 | return this._fetch.call(this, id, url, {
23 |
24 | SUCCESS: Constants.PAGINATE_SUCCESS,
25 | ERROR: Constants.PAGINATE_ERROR
26 | });
27 | },
28 |
29 | fetchRepos (userName, id) {
30 |
31 | let url = Constants.API_ROOT + 'users/' + userName + '/repos';
32 | url += '?per_page=9999';
33 |
34 | return this._fetch.call(this, id, url, {
35 |
36 | SUCCESS: Constants.FETCH_REPOS_SUCCESS,
37 | ERROR: Constants.FETCH_REPOS_ERROR
38 | });
39 | },
40 |
41 | _getIssuesURL ({ userData, paginationData }) {
42 |
43 | let { userName, repoName } = userData,
44 | { currPage, perPage } = paginationData;
45 |
46 | let url = Constants.API_ROOT + 'repos/' + userName + '/' + repoName + '/issues';
47 | url += '?page=' + currPage + '&per_page=' + perPage;
48 |
49 | return url;
50 | }
51 | };
52 |
53 | Object.assign(GitHubAPI, API);
54 |
55 | export default GitHubAPI;
56 |
--------------------------------------------------------------------------------
/src/app.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ReposStore from './stores/repos-store';
4 | import IssuesStore from './stores/issues-store';
5 |
6 | import SearchForm from './components/search-form';
7 | import IssuesList from './components/issues-list';
8 | import Notification from './components/notification';
9 | import RepoInfo from './components/repo-info';
10 |
11 | require('../styl/main.styl');
12 |
13 | React.initializeTouchEvents(true);
14 |
15 | let App = React.createClass({
16 |
17 | getInitialState() {
18 |
19 | return {
20 |
21 | status: ''
22 | };
23 | },
24 |
25 | componentDidMount() {
26 |
27 | ReposStore.addChangeListener(this._onChange);
28 | IssuesStore.addChangeListener(this._onChange);
29 | },
30 |
31 | componentWillUnmount() {
32 |
33 | ReposStore.removeChangeListener(this._onChange);
34 | IssuesStore.removeChangeListener(this._onChange);
35 | },
36 |
37 | _onChange (status) {
38 |
39 | this.setState({ status });
40 | },
41 |
42 | render() {
43 |
44 | let repo = ReposStore.getState().selectedRepo;
45 | let issues = IssuesStore.getState().issues;
46 |
47 | return (
48 |
49 |
50 |
51 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | });
64 |
65 | React.render(, document.body);
66 |
--------------------------------------------------------------------------------
/src/lib/store.es6:
--------------------------------------------------------------------------------
1 | import EventEmitter from 'eventemitter2';
2 |
3 | import Dispatcher from '../dispatchers/dispatcher';
4 | import Constants from '../constants/app-constants';
5 |
6 | class Store extends EventEmitter {
7 |
8 | constructor (defs) {
9 |
10 | this._state = {};
11 | this._status = null;
12 | this._actions = {};
13 | this._callbacks = {};
14 |
15 | this.dispatchToken = Dispatcher.register(this.handleAction.bind(this));
16 |
17 | Object.assign(this, defs);
18 | }
19 |
20 | update (nextState, type, id) {
21 |
22 | Object.assign(this._state, nextState);
23 |
24 | if (id && type) {
25 |
26 | this._actions[id][type]();
27 | delete this._actions[id];
28 | }
29 |
30 | this._status = type;
31 |
32 | this.emitChange();
33 | }
34 |
35 | getState() {
36 |
37 | return this._state;
38 | }
39 |
40 | emitChange() {
41 |
42 | this.emit(Constants.CHANGE_EVENT, this._status);
43 | }
44 |
45 | addChangeListener (callback) {
46 |
47 | this.on(Constants.CHANGE_EVENT, callback);
48 | }
49 |
50 | removeChangeListener (callback) {
51 |
52 | this.removeListener(Constants.CHANGE_EVENT, callback);
53 | }
54 |
55 | registerAction (promise) {
56 |
57 | let id = Symbol();
58 |
59 | this._actions[id] = promise;
60 |
61 | return id;
62 | }
63 |
64 | bindAction (type, callback) {
65 |
66 | this._callbacks[type] = this._callbacks[type] || [];
67 |
68 | this._callbacks[type].push(callback);
69 | }
70 |
71 | handleAction (action) {
72 |
73 | let callbacks = this._callbacks[action.actionType];
74 |
75 | callbacks && callbacks.map(callback => callback(action));
76 | }
77 | };
78 |
79 | export default Store;
80 |
--------------------------------------------------------------------------------
/src/components/dropdown.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import DropdownItem from './dropdown-item';
4 |
5 | let Dropdown = React.createClass({
6 |
7 | getDefaultProps() {
8 |
9 | return {
10 |
11 | className: '',
12 | repos: [],
13 | enter: false
14 | };
15 | },
16 |
17 | getInitialState() {
18 |
19 | return {
20 |
21 | activeItem: 0
22 | };
23 | },
24 |
25 | componentWillReceiveProps (nextProps) {
26 |
27 | if (nextProps.enter) {
28 |
29 | this.setState({ activeItem: 1 });
30 | }
31 | },
32 |
33 | _onItemKeyDown (event) {
34 |
35 | event.preventDefault();
36 |
37 | switch (event.keyCode) {
38 |
39 | case 40:
40 |
41 | this.state.activeItem < this.props.repos.length &&
42 | this.setState({ activeItem: this.state.activeItem + 1 });
43 |
44 | break;
45 |
46 | case 38:
47 |
48 | this.state.activeItem &&
49 | this.setState({ activeItem: this.state.activeItem - 1 }, () => {
50 |
51 | !this.state.activeItem && this.props.out();
52 | });
53 |
54 | break;
55 | }
56 | },
57 |
58 | render() {
59 |
60 | let list = this.props.repos.map((repo, index) => {
61 |
62 | return {repo};
67 | });
68 |
69 | return (
70 |
71 |
76 | );
77 | }
78 |
79 | });
80 |
81 | export default Dropdown;
82 |
--------------------------------------------------------------------------------
/src/components/issue-item.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Markdown from './markdown';
4 |
5 | let IssueItem = React.createClass({
6 |
7 | _dictionary: {
8 |
9 | months: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'June', 'July', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec']
10 | },
11 |
12 | getDefaultProps() {
13 |
14 | return {
15 |
16 | issue: {}
17 | };
18 | },
19 |
20 | getInitialState() {
21 |
22 | return {
23 |
24 | showIssue: false
25 | };
26 | },
27 |
28 | componentWillReceiveProps (nextProps) {
29 |
30 | nextProps.issue !== this.props.issue && this.state.showIssue &&
31 | this.setState({ showIssue: false });
32 | },
33 |
34 | _getDate (timestamp) {
35 |
36 | let date = new Date(timestamp);
37 | let prettyDate = '';
38 |
39 | prettyDate += this._dictionary.months[date.getMonth()];
40 | prettyDate += ' ' + date.getDate();
41 | prettyDate += ', ';
42 | prettyDate += date.getFullYear();
43 | prettyDate += ', ' + date.toLocaleTimeString();
44 |
45 | return prettyDate;
46 | },
47 |
48 | _toggle (event) {
49 |
50 | if (event.type === 'click' || event.keyCode === 13) {
51 |
52 | event.preventDefault();
53 |
54 | this.setState({ showIssue: !this.state.showIssue });
55 | }
56 | },
57 |
58 | render() {
59 |
60 | let date = this._getDate(this.props.issue.created_at);
61 |
62 | let issue =
;
63 |
64 | return (
65 |
66 |
67 |
68 |
69 |
70 |
75 |
76 |
{this.props.issue.title}
82 |
83 |
84 |
85 |
86 |
87 | {this.state.showIssue ? issue : null}
88 |
89 |
90 | );
91 | }
92 |
93 | });
94 |
95 | export default IssueItem;
96 |
--------------------------------------------------------------------------------
/src/components/pagination.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react/addons';
2 |
3 | import AppActions from '../actions/app-actions';
4 |
5 | import IssuesStore from '../stores/issues-store';
6 | import PaginationStore from '../stores/pagination-store';
7 | import UserStore from '../stores/user-store';
8 | import ReposStore from '../stores/repos-store';
9 |
10 | import Input from './input';
11 | import Button from './button';
12 |
13 | let Pagination = React.createClass({
14 |
15 | mixins: [React.addons.PureRenderMixin],
16 |
17 | getDefaultProps() {
18 |
19 | return {
20 |
21 | minPage: 1,
22 | minPerPage: 1
23 | }
24 | },
25 |
26 | getInitialState() {
27 |
28 | return PaginationStore.getState();
29 | },
30 |
31 | componentDidMount() {
32 |
33 | IssuesStore.addChangeListener(this._onUpdate);
34 | },
35 |
36 | componentWillUnmount() {
37 |
38 | IssuesStore.removeChangeListener(this._onUpdate);
39 | },
40 |
41 | _onUpdate() {
42 |
43 | this.state.disabled && this.setState({ disabled: false });
44 | },
45 |
46 | _onChange() {
47 |
48 | this._updatePage({
49 |
50 | currPage: Number(this.refs.currPage.getValue()),
51 | perPage: Number(this.refs.perPage.getValue())
52 | });
53 | },
54 |
55 | _incPage() {
56 |
57 | if (this.state.disabled) { return; }
58 |
59 | this._updatePage({ currPage: this.state.currPage + 1 });
60 | },
61 |
62 | _decPage() {
63 |
64 | if (this.state.disabled || this.state.currPage === this.props.minPage) { return; }
65 |
66 | this._updatePage({ currPage: this.state.currPage - 1 });
67 | },
68 |
69 | _updatePage (nextState) {
70 |
71 | this.setState(nextState, () => {
72 |
73 | AppActions.setPageSize(this.state.perPage)
74 | .then(() => {
75 |
76 | if (Object.keys(ReposStore.getState().selectedRepo).length) {
77 |
78 | AppActions.paginate(UserStore.getState(), this.state);
79 | }
80 | });
81 | });
82 | },
83 |
84 | render() {
85 |
86 | return (
87 |
88 |
89 |
90 |
96 |
97 |
98 |
99 |
106 |
107 |
108 |
109 |
110 | );
111 | }
112 |
113 | });
114 |
115 | export default Pagination;
116 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GitHub Issues
2 | Sample React application built with Flux for learning purpose
3 |
4 | `npm install -g webpack && npm install && webpack`
5 |
6 | ## Flux Action
7 |
8 | [`src/lib/actions.es6`](https://github.com/roman01la/github-issues/blob/master/src/lib/actions.es6)
9 |
10 | `Actions` module is a proto object with a single method `._dispatch`. Application actions should be defined within an object extended by proto object.
11 |
12 | ```
13 | let ItemsActions = {};
14 |
15 | Object.assign(ItemsActions, Actions);
16 | ```
17 |
18 | Action producer can be notified about the status of the particular action. `._dispatch` returns a promise which can be optionally obtained by receiver store and handled accordingly.
19 |
20 | ```
21 | ItemsActions.fetch = url => {
22 |
23 | return this._dispatch({
24 |
25 | actionType: Constants.FETCH_ITEMS,
26 | payload: { url }
27 | });
28 | };
29 |
30 | /* Show loader here */
31 |
32 | ItemsActions.fetch(url)
33 | .then(/* Hide loader */)
34 | .catch(/* Show error msg */);
35 | ```
36 |
37 | ## Flux Store
38 |
39 | [`src/lib/store.es6`](https://github.com/roman01la/github-issues/blob/master/src/lib/store.es6)
40 |
41 | `Store` module is a class which handles data, actions, updates and notifications.
42 |
43 | `_state` prop is where all the data is stored within the store, it's optional when creating a new store, but defining store's structure still can help.
44 |
45 | ```
46 | let ItemsStore = new Store({
47 |
48 | _state: {
49 |
50 | items: []
51 | }
52 | });
53 | ```
54 |
55 | Listen for changes from within of the component and use `.getState()` method to retrieve recent data from the store.
56 | ```
57 | componentDidMount() {
58 |
59 | ItemsStore.addChangeListener(this._onChange);
60 | },
61 |
62 | componentWillUnmount() {
63 |
64 | ItemsStore.removeChangeListener(this._onChange);
65 | },
66 |
67 | _onChange() {
68 |
69 | this.setState(ItemsStore.getState());
70 | }
71 | ```
72 |
73 | Use `.bindAction` method to listen for particular actions and do things like calling API inside of the callback fn, which accepts dispatched action.
74 |
75 | ```
76 | ItemsStore.bindAction(Constants.FETCH_ITEMS, action => {
77 |
78 | API.fetchItems(action.payload);
79 | });
80 | ```
81 | To notify back action producer you should register incoming action inside of a callback fn with `.registerAction` method, which accepts an object where keys are action type constants for handling success and error, and values are injected into the action promise's `resolve` and `reject` fns. This will return a unique `id`, which then should be passed as an argument to an `API` module.
82 |
83 | ```
84 | ItemsStore.bindAction(Constants.FETCH_ITEMS, action => {
85 |
86 | let id = ItemsStore.registerAction({
87 |
88 | [Constants.FETCH_ITEMS_SUCCESS]: action.promise.resolve,
89 | [Constants.FETCH_ITEMS_ERROR]: action.promise.reject
90 | });
91 |
92 | API.fetchItems(action.payload, id);
93 | });
94 | ```
95 |
96 | When done, `API` module will dispatch an action with API response and injected `id`, if provided. Store can update itself when listening for `API` actions, automatically emit `CHANGE_EVENT` event and optionally notify action producer.
97 |
98 | ```
99 | ItemsStore.bindAction(Constants.FETCH_ITEMS_SUCCESS, action => {
100 |
101 | let nextState = { items: action.payload.response };
102 |
103 | /* Update store */
104 | ItemsStore.update(nextState);
105 |
106 | /* OR */
107 |
108 | /* Update store and notify action producer */
109 | ItemsStore.update(nextState, Constants.FETCH_ITEMS_SUCCESS, action.id);
110 | });
111 | ```
112 |
113 | Additionally `CHANGE_EVENT` will be emitted along with provided store's `status`, and so components which are listening for changes can distinguish between succeed or failed changes and respond accordingly.
114 |
115 | ```
116 | ItemsStore.update(nextState, Constants.FETCH_ITEMS_SUCCESS);
117 |
118 | _onChange (status) {
119 |
120 | if (status === Constants.FETCH_ITEMS_SUCCESS) {
121 |
122 | this.setState(ItemsStore.getState());
123 | }
124 | }
125 | ```
126 |
127 | Doing everything explicitly might be confusing and looks like an overhead, but it's still optional functionality and you might don't want to use it everywhere.
128 |
129 | ## Flux API
130 |
131 | [`sr/lib/api.es6`](https://github.com/roman01la/github-issues/blob/master/src/lib/api.es6)
132 |
133 | `API` module is a proto object. Application API calls should be defined within an object extended by proto object.
134 |
135 | ```
136 | let ItemsAPI = {};
137 |
138 | Object.assign(ItemsAPI, Actions);
139 | ```
140 |
141 | `._fetch` method accepts `id`, `url` and action type constants for dispatching success or error actions.
142 |
143 | ```
144 | ItemsAPI.fetchItems = (payload, id) {
145 |
146 | return this._fetch.call(this, id, payload.url, {
147 |
148 | SUCCESS: Constants.FETCH_ITEMS_SUCCESS,
149 | ERROR: Constants.FETCH_ITEMS_ERROR
150 | });
151 | };
152 | ```
153 |
154 | `._fetch` returns a promise which can be used to immediately update store.
155 |
156 | ```
157 | ItemsStore.update(nextState)
158 | .then(/* ... */)
159 | .catch(/* ... */);
160 | ```
161 |
162 | ## Flux Dispatcher
163 | [Facebook's Flux `Dispatcher`](https://github.com/facebook/flux/blob/master/src/Dispatcher.js)
164 |
--------------------------------------------------------------------------------
/styl/main.styl:
--------------------------------------------------------------------------------
1 | /* Normalize */
2 |
3 | html {
4 | box-sizing border-box
5 | }
6 | html * {
7 | box-sizing inherit
8 | }
9 | body {
10 | margin 0
11 | font normal 16px sans-serif
12 | }
13 |
14 | /* Global */
15 |
16 | input {
17 | padding 4px
18 | height 26px
19 | border-radius 2px
20 | border 1px solid #ccc
21 | }
22 | label {
23 | font-size .8rem
24 | }
25 |
26 | button {
27 | display block
28 | padding 13px
29 | border none
30 | border-radius 2px
31 | background #479BE2
32 | font-size 1rem
33 | color #fafafa
34 | line-height 0
35 | margin 0
36 | }
37 | a {
38 | color #479BE2
39 | text-decoration none
40 |
41 | &:hover {
42 | text-decoration underline
43 | }
44 | }
45 | h3 {
46 | font-weight normal
47 | margin .5rem 0
48 | }
49 | pre {
50 | background #eee
51 | padding 10px
52 | border-radius 2px
53 | }
54 | code {
55 | background #eee
56 | padding 0 4px
57 | border-radius 2px
58 | }
59 | pre code {
60 | word-wrap break-word
61 | padding 0
62 | }
63 |
64 | /* Application */
65 |
66 | .app {
67 | padding 10px
68 | margin 0 auto
69 | max-width 768px
70 | }
71 |
72 | /* Top section */
73 |
74 | .top {
75 | display flex
76 |
77 | .search-form,
78 | .repo-info {
79 | flex 1
80 | }
81 | .stats {
82 | margin 20px 0 0
83 |
84 | span {
85 | margin 0 10px
86 |
87 | &:first-child {
88 | margin 0 10px 0 0
89 | }
90 | &:last-child {
91 | margin 0 0 0 10px
92 | }
93 | }
94 | .fa::before {
95 | margin 0 5px 0 0
96 | }
97 | }
98 | }
99 |
100 | .repo-name {
101 | margin-bottom 0
102 | }
103 |
104 | /* Search form */
105 |
106 | .search-form {
107 | max-width 135px
108 | margin 0 20px 0 0
109 | }
110 |
111 | .search-form .field {
112 | margin 10px 0
113 | position relative
114 |
115 | label,
116 | input {
117 | display block
118 | }
119 |
120 | .fa-spinner {
121 | position absolute
122 | bottom 5px
123 | right 10px
124 | transform rotate(0deg)
125 | animation spin 1000ms linear infinite
126 | }
127 | }
128 |
129 | @keyframes spin {
130 | to {
131 | transform rotate(360deg)
132 | }
133 | }
134 |
135 | /* Dropdown */
136 |
137 | .dropdown {
138 | font-size .7rem
139 | position relative
140 |
141 | .list {
142 | margin 0
143 | padding 0
144 | list-style none
145 | position absolute
146 | top 5px
147 | background #ffffff
148 | }
149 | .item {
150 | padding 2px
151 | border-bottom 1px solid #242424
152 |
153 | &:focus {
154 | background #479BE2
155 | color #fafafa
156 | }
157 | }
158 | }
159 |
160 | /* Issues list */
161 |
162 | .issue {
163 | font-size .8rem
164 | display flex
165 | display -webkit-flex
166 | flex-direction column
167 | -webkit-flex-direction column
168 |
169 | .issue-info {
170 | display flex
171 | display -webkit-flex
172 | justify-content space-between
173 | -webkit-justify-content space-between
174 | border-bottom 1px solid #242424
175 | padding 10px 0 5px
176 | }
177 | .title {
178 | align-self flex-start
179 | -webkit-align-self flex-start
180 | flex 1
181 | -webkit-flex 1
182 | margin 0 20px
183 |
184 | &:hover {
185 | cursor pointer
186 | }
187 | }
188 | .time {
189 | font-size .7rem
190 | }
191 |
192 | .issue-body {
193 | padding 10px 0
194 | line-height 1.2rem
195 | border-bottom 1px dashed #242424
196 | margin 0 0 20px
197 |
198 | img {
199 | width 100%
200 | }
201 | }
202 | }
203 |
204 | /* Pagination form */
205 |
206 | .pagination {
207 | text-align center
208 | margin 30px 0
209 |
210 | .prev,
211 | .next,
212 | .field {
213 | display inline-block
214 | vertical-align middle
215 | }
216 | input[type="number"] {
217 | width 50px
218 | text-align center
219 |
220 | &:disabled {
221 | background #ccc
222 | }
223 | }
224 | .field {
225 | margin 0 20px
226 |
227 | label {
228 | margin 0 10px 0 0
229 | }
230 | }
231 | }
232 |
233 | /* Notification */
234 |
235 | .notification {
236 | position fixed
237 | top 0
238 | right 0
239 |
240 | .message {
241 | background #fafafa
242 | color #242424
243 | border 1px solid #242424
244 | padding 10px
245 | }
246 | }
247 |
248 | @media only screen and (max-width: 440px) {
249 |
250 | input {
251 | height 30px
252 | }
253 |
254 | .top {
255 | flex-direction column
256 | }
257 | .search-form {
258 | margin 0
259 | max-width none
260 |
261 | input[type="text"] {
262 | width 100%
263 | }
264 | }
265 | .dropdown .item {
266 | padding 6px 2px
267 | }
268 | .pagination {
269 | text-align right
270 |
271 | .field:first-child {
272 | margin 0 20px 10px
273 | }
274 | }
275 | .btn.prev {
276 | margin 0 0 10px
277 | }
278 | .issue-info {
279 | flex-direction column
280 | -webkit-flex-direction column
281 |
282 | .num,
283 | .title,
284 | .time {
285 | flex 1
286 | -webkit-flex 1
287 | }
288 | .num {
289 | max-width 50px
290 | }
291 | .title {
292 | margin 6px 0
293 | padding 4px 0
294 | }
295 | }
296 | .notification {
297 | width 100%
298 | }
299 | }
300 |
301 |
--------------------------------------------------------------------------------
/src/components/search-form.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import _core from 'core-js/library';
4 |
5 | import AppActions from '../actions/app-actions';
6 |
7 | import ReposStore from '../stores/repos-store';
8 | import UserStore from '../stores/user-store';
9 | import PaginationStore from '../stores/pagination-store';
10 |
11 | import Dropdown from './dropdown';
12 | import Button from './button';
13 | import Input from './input';
14 |
15 | let SearchForm = React.createClass({
16 |
17 | _prev: {
18 |
19 | repoName: ''
20 | },
21 |
22 | getInitialState() {
23 |
24 | return {
25 |
26 | user: {
27 |
28 | userName: '',
29 | repoName: ''
30 | },
31 |
32 | matchedRepos: [],
33 | showDropdown: false,
34 | enterDropdown: false,
35 | repoSelected: false,
36 | loading: false
37 | };
38 | },
39 |
40 | componentDidUpdate (prevProps, { user: { userName, repoName } }) {
41 |
42 | let user = this.state.user;
43 |
44 | if (userName !== user.userName ||
45 | repoName !== user.repoName) {
46 |
47 | AppActions.updateUser(user);
48 | }
49 | },
50 |
51 | _updateUser() {
52 |
53 | return new Promise((resolve, reject) => {
54 |
55 | this.setState({ user: {
56 |
57 | userName: this.refs.userName.getValue(),
58 | repoName: this.refs.repoName.getValue()
59 | } }, resolve);
60 | });
61 | },
62 |
63 | _matchRepos() {
64 |
65 | let repoName = this.state.user.repoName;
66 |
67 | return repoName ?
68 |
69 | ReposStore.getState().repos
70 | .map(repo => repo.name)
71 | .filter(name => _core.String.startsWith(name, repoName)) :
72 |
73 | [];
74 | },
75 |
76 | _onNameChange() {
77 |
78 | this._updateUser();
79 | },
80 |
81 | _onRepoChange() {
82 |
83 | this._updateUser()
84 | .then(() => {
85 |
86 | let nextState = { matchedRepos: this._matchRepos() };
87 |
88 | !this.state.showDropdown &&
89 | (nextState.showDropdown = true);
90 |
91 | this.setState(nextState);
92 | });
93 | },
94 |
95 | _onRepoFocus() {
96 |
97 | let userName = this.state.user.userName;
98 |
99 | if (userName !== this._prev.repoName) {
100 |
101 | this.setState({ loading: true });
102 |
103 | this._prev.repoName = userName;
104 | AppActions.fetchRepos(userName)
105 | .then(() => this.setState({
106 |
107 | showDropdown: true,
108 | matchedRepos: this._matchRepos(),
109 | loading: false
110 | }));
111 |
112 | } else {
113 |
114 | !this.state.showDropdown && this.setState({ showDropdown: true });
115 | }
116 |
117 | if (this.state.repoSelected && this.state.showDropdown) {
118 |
119 | this.setState({
120 |
121 | showDropdown: false,
122 | repoSelected: false
123 | });
124 | }
125 | },
126 |
127 | _onRepoBlur() {
128 |
129 | !this.state.enterDropdown && this.setState({ showDropdown: false });
130 | },
131 |
132 | _onRepoKeyDown (event) {
133 |
134 | if (event.keyCode === 40) {
135 |
136 | event.preventDefault();
137 |
138 | this.setState({ enterDropdown: true });
139 | }
140 | },
141 |
142 | _focusToRepoName() {
143 |
144 | this.setState({ enterDropdown: false}, () => {
145 |
146 | this.refs.repoName.refs.input.getDOMNode().focus();
147 | });
148 | },
149 |
150 | _onItemSelect (index) {
151 |
152 | let repoName = this.state.matchedRepos[index];
153 |
154 | this.refs.repoName.refs.input.getDOMNode().value = repoName;
155 |
156 | this._updateUser()
157 | .then(() => {
158 |
159 | this.setState({ repoSelected: true }, () => {
160 |
161 | this._focusToRepoName();
162 |
163 | this._submitForm();
164 | });
165 | });
166 | },
167 |
168 | _onSubmit (event) {
169 |
170 | event.preventDefault();
171 |
172 | this._submitForm();
173 | },
174 |
175 | _submitForm() {
176 |
177 | return AppActions.submitForm(UserStore.getState(), PaginationStore.getState())
178 | .then(() => AppActions.setRepo(this.state.user.repoName));
179 | },
180 |
181 | render() {
182 |
183 | let loader = ;
184 |
185 | let dropdown = (
186 |
187 |
192 | );
193 |
194 | dropdown = this.state.matchedRepos.length && this.state.showDropdown ?
195 | dropdown : null;
196 |
197 | return (
198 |
199 |
224 | );
225 | }
226 | });
227 |
228 | export default SearchForm;
229 |
--------------------------------------------------------------------------------