├── .gitignore
├── README.md
├── docs
├── edit.png
├── filter-milestone.gif
├── query.png
├── submit-pull.gif
├── ticket.png
├── timeline.png
└── upload.gif
├── index.js
├── package.json
├── proxy.js
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── App.css
├── App.js
├── App.test.js
├── actions.js
├── components
│ ├── Attachment.js
│ ├── AttachmentUpload.css
│ ├── AttachmentUpload.js
│ ├── Avatar.css
│ ├── Avatar.js
│ ├── Board.css
│ ├── Board.js
│ ├── Button.css
│ ├── Button.js
│ ├── CodeBlock.js
│ ├── Comment.css
│ ├── Comment.js
│ ├── CommentContent.css
│ ├── CommentContent.js
│ ├── CommentEditor.css
│ ├── CommentEditor.js
│ ├── CommentHeader.css
│ ├── CommentHeader.js
│ ├── CommentMeta.css
│ ├── CommentMeta.js
│ ├── DropList.css
│ ├── DropList.js
│ ├── DropSelect.css
│ ├── DropSelect.js
│ ├── Dropdown.css
│ ├── Dropdown.js
│ ├── Error.js
│ ├── Footer.css
│ ├── Footer.js
│ ├── FormattedText.css
│ ├── FormattedText.js
│ ├── Header.css
│ ├── Header.js
│ ├── ListTable.css
│ ├── ListTable.js
│ ├── ListTableItem.css
│ ├── ListTableItem.js
│ ├── Loading.css
│ ├── Loading.js
│ ├── Login.css
│ ├── Login.js
│ ├── PullRequests.css
│ ├── PullRequests.js
│ ├── Query.css
│ ├── Query.js
│ ├── QueryHeader.css
│ ├── QueryHeader.js
│ ├── SlackMention.js
│ ├── Spinner.css
│ ├── Spinner.js
│ ├── Tag.css
│ ├── Tag.js
│ ├── Ticket.css
│ ├── Ticket.js
│ ├── TicketChanges.css
│ ├── TicketChanges.js
│ ├── TicketList.js
│ ├── TicketListItem.css
│ ├── TicketListItem.js
│ ├── TicketState.css
│ ├── TicketState.js
│ ├── TicketStatus.css
│ ├── TicketStatus.js
│ ├── TicketUpdate.css
│ ├── TicketUpdate.js
│ ├── Time.js
│ ├── Timeline.css
│ ├── Timeline.js
│ ├── TimelineEvent.css
│ ├── TimelineEvent.js
│ └── UserLink.js
├── containers
│ ├── Attachment.js
│ ├── AttachmentUpload.js
│ ├── Board.js
│ ├── Query.js
│ ├── Summary.css
│ ├── Summary.js
│ ├── Ticket.js
│ ├── TicketTimeline.js
│ └── selectors
│ │ ├── LabelSelect.js
│ │ └── MilestoneSelect.js
├── index.css
├── index.js
├── lib
│ ├── text-formatter.js
│ ├── text-parser.js
│ ├── trac.js
│ └── workflow.js
├── logo.svg
├── reducers
│ ├── components.js
│ ├── index.js
│ ├── prs.js
│ ├── query.js
│ ├── tickets.js
│ └── user.js
├── registerServiceWorker.js
└── slack.svg
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /proxy/vendor
6 |
7 | # testing
8 | /coverage
9 |
10 | # production
11 | /build
12 |
13 | # misc
14 | .DS_Store
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Not Trac
2 |
3 | This project isn't Trac.
4 |
5 |
6 |
7 | ## Disclaimer
8 |
9 | This connects to WordPress.org's Trac instance via XML-RPC, and hence requires your username and password. You shouldn't trust my code, and you should carefully audit all code that handles your username and password before entering it into this application.
10 |
11 | This especially applies if you're a committer. Your WordPress.org password controls your commit access to 28% of the web, be extremely cautious with it.
12 |
13 | **THERE IS ZERO SUPPORT PROVIDED FOR THIS INTERFACE**. This is something I made for my own use. If it's useful to you, great; if not, also great.
14 |
15 | ## Features
16 |
17 | Not Trac is a React app built from the ground up for me. I hate waiting around for slow things, and I want lots of information at a glance. You might find it useful too.
18 |
19 | ### Query, then filter and sort
20 |
21 | View the list of tickets and their status, and browse with fast pagination.
22 |
23 |
24 |
25 | Filter by component, label, and milestone, and re-sort in a snap.
26 |
27 |
28 |
29 |
30 | ### See important changes at a glance
31 |
32 | The ticket header and summary give you the rundown on tickets at first glance.
33 |
34 |
35 |
36 | The ticket timeline view highlights important events.
37 |
38 |
39 |
40 |
41 | ### Comment with instant previews
42 |
43 | Leave a comment just like you would on Trac, and preview it instantly (no round-trip to the server).
44 |
45 |
46 |
47 |
48 | ### Upload attachments, or use pull requests
49 |
50 | Upload attachments just like you would on Trac.
51 |
52 |
53 |
54 | First-class pull request support is available, freeing up your time to work on the important things.
55 |
56 |
57 |
58 |
59 | ## Running Locally
60 |
61 | ```sh
62 | # Clone
63 | git clone https://github.com/rmccue/not-trac.git
64 | cd not-trac
65 |
66 | # Install all dependencies:
67 | npm install
68 |
69 | # Run the following two simultaneously (maybe in separate terminal sessions):
70 | npm run start
71 | ```
72 |
73 | ## License
74 |
75 | Uh... ISC I guess?
76 |
--------------------------------------------------------------------------------
/docs/edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/edit.png
--------------------------------------------------------------------------------
/docs/filter-milestone.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/filter-milestone.gif
--------------------------------------------------------------------------------
/docs/query.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/query.png
--------------------------------------------------------------------------------
/docs/submit-pull.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/submit-pull.gif
--------------------------------------------------------------------------------
/docs/ticket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/ticket.png
--------------------------------------------------------------------------------
/docs/timeline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/timeline.png
--------------------------------------------------------------------------------
/docs/upload.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/docs/upload.gif
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const express = require( 'express' );
2 | const proxy = require( './proxy' );
3 | const basicAuth = require( 'express-basic-auth' );
4 | const bodyParser = require('body-parser');
5 | const app = express();
6 |
7 | const PORT = parseInt( process.env.PORT || '3090', 10 );
8 |
9 | const corsMiddleware = ( req, res, next ) => {
10 | const origin = req.get( 'Origin' );
11 | if ( origin ) {
12 | res.set( 'Access-Control-Allow-Origin', origin.replace( /\r|\n/, '' ) );
13 | }
14 |
15 | res.set( 'Access-Control-Allow-Headers', 'Authorization, Content-Type' );
16 | res.set( 'Access-Control-Allow-Methods', 'POST, OPTIONS' );
17 | res.set( 'Access-Control-Allow-Credentials', 'true' );
18 | res.set( 'Vary', 'Origin' );
19 |
20 | next();
21 | };
22 |
23 | app.post(
24 | '/proxy',
25 | basicAuth( {
26 | authorizer: () => true,
27 | unauthorizedResponse: () => ( { message: 'Username and password are required' } ),
28 | } ),
29 | bodyParser.json(),
30 | corsMiddleware,
31 | proxy
32 | );
33 |
34 | app.options(
35 | '/proxy',
36 | corsMiddleware,
37 | ( req, res ) => {
38 | res.status( 200 );
39 | res.send( "Let's go.\n" );
40 | }
41 | );
42 |
43 | app.use( express.static( __dirname + '/build' ) );
44 | app.get( '/*', (req, res) => {
45 | res.sendFile( __dirname + '/build/index.html' );
46 | } );
47 |
48 | app.use( ( err, req, res, next ) => {
49 | if ( err instanceof proxy.HttpError ) {
50 | const data = Object.assign( {}, err.data, { error: true } );
51 |
52 | res.status( err.code );
53 | res.set( 'Content-Type', 'application/json' );
54 | res.send( JSON.stringify( data ) );
55 | return next();
56 | }
57 |
58 | next( err );
59 | } );
60 |
61 | app.listen( PORT, () => console.log( `Trac proxy listening on port ${ PORT }` ) );
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "less-terrible-trac",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "now": "^8.5.4",
7 | "react-scripts": "1.0.10"
8 | },
9 | "dependencies": {
10 | "base64-js": "^1.2.1",
11 | "body-parser": "^1.18.2",
12 | "bytes": "^2.5.0",
13 | "express": "^4.16.2",
14 | "express-basic-auth": "^1.1.3",
15 | "moment": "^2.18.1",
16 | "npm-run-all": "^4.1.2",
17 | "prismjs": "^1.6.0",
18 | "prop-types": "^15.5.10",
19 | "query-string": "^5.0.0",
20 | "react": "^15.6.1",
21 | "react-document-title": "^2.0.3",
22 | "react-dom": "^15.6.1",
23 | "react-redux": "^5.0.5",
24 | "react-router-dom": "^4.1.2",
25 | "react-router-modal": "^1.1.13",
26 | "redux": "^3.7.2",
27 | "redux-thunk": "^2.2.0",
28 | "simple-text-parser": "^1.0.0",
29 | "string-hash": "^1.1.3",
30 | "xmlrpc": "^1.3.2"
31 | },
32 | "scripts": {
33 | "start": "run-p start-proxy start-js",
34 | "start-js": "react-scripts start",
35 | "start-proxy": "node index.js",
36 | "now-start": "PORT=80 node index.js",
37 | "build": "react-scripts build",
38 | "test": "react-scripts test --env=jsdom",
39 | "eject": "react-scripts eject",
40 | "deploy": "now"
41 | },
42 | "proxy": "http://localhost:3090",
43 | "now": {
44 | "alias": "not-trac.rmccue.io"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/proxy.js:
--------------------------------------------------------------------------------
1 | const xmlrpc = require( 'xmlrpc' );
2 |
3 | const HttpError = function ( data, code = 500 ) {
4 | this.data = data;
5 | this.code = code;
6 | };
7 |
8 | const replacer = ( key, value ) => {
9 | if ( Buffer.isBuffer( value ) ) {
10 | return value.toString();
11 | } else if ( typeof value === 'object' && value.type === 'Buffer' ) {
12 | const buffer = Buffer.from( value.data );
13 | return buffer.toString();
14 | }
15 |
16 | return value;
17 | };
18 |
19 | module.exports = ( req, res ) => {
20 | const data = req.body;
21 |
22 | const missing = [ 'method', 'parameters' ].filter( key => ! ( key in req.body ) );
23 | if ( missing.length ) {
24 | throw new HttpError(
25 | {
26 | message: 'Missing required parameter.',
27 | param: missing.join( ',' ),
28 | },
29 | 400
30 | );
31 | }
32 |
33 | const client = xmlrpc.createSecureClient( {
34 | url: 'https://core.trac.wordpress.org/login/xmlrpc',
35 | basic_auth: {
36 | user: req.auth.user,
37 | pass: req.auth.password,
38 | }
39 | } );
40 |
41 | const types = data.types || {};
42 | let params;
43 | try {
44 | params = data.parameters.map( ( parameter, idx ) => {
45 | const type = types[ idx ];
46 | if ( ! type ) {
47 | return parameter;
48 | }
49 |
50 | switch ( type ) {
51 | case 'base64':
52 | return Buffer.from( parameter );
53 |
54 | default:
55 | throw new HttpError( {
56 | message: `Invalid type ${ type } for parameter ${ parameter }`,
57 | param: parameter,
58 | } );
59 | }
60 | } );
61 | } catch ( e ) {
62 | throw new HttpError( {
63 | message: e.message,
64 | }, 400 );
65 | }
66 | client.methodCall( data.method, data.parameters, ( err, value ) => {
67 | if ( err ) {
68 | throw new HttpError( {
69 | message: `Received an error from Trac`,
70 | data: err,
71 | } );
72 | }
73 | res.set( 'Content-Type', 'application/json' );
74 | res.send( JSON.stringify( value, replacer ) );
75 | } );
76 | };
77 |
78 | module.exports.HttpError = HttpError;
79 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rmccue/not-trac/dd6cff956e2f3edf1594f9b4a0cebd832dd7ba03/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 |
27 |
28 | You need to enable JavaScript to run this app.
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | }
3 | .App .App-modal-backdrop {
4 | background-color: rgba(50, 55, 60, 0.4);
5 | }
6 | .App .App-modal {
7 | border: none;
8 | box-shadow: 0 0 1px #0073aa;
9 | }
10 |
11 | .App .Header .Dropdown-content {
12 | color: #32373c;
13 | }
14 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import qs from 'query-string';
2 | import React from 'react';
3 | import { connect } from 'react-redux';
4 | import { Link, Redirect, BrowserRouter as Router, Route, Switch } from 'react-router-dom';
5 | import { ModalContainer } from 'react-router-modal';
6 |
7 | import { reset_user_credentials, set_user_credentials } from './actions';
8 | import Avatar from './components/Avatar';
9 | import DropList from './components/DropList';
10 | import Footer from './components/Footer';
11 | import Header from './components/Header';
12 | import Login from './components/Login';
13 |
14 | import Attachment from './containers/Attachment';
15 | import Board from './containers/Board';
16 | import Query from './containers/Query';
17 | import Summary from './containers/Summary';
18 | import Ticket from './containers/Ticket';
19 |
20 | import './App.css';
21 | import 'react-router-modal/css/react-router-modal.css';
22 |
23 | class App extends React.Component {
24 | constructor( props ) {
25 | super( props );
26 |
27 | this.state = {
28 | user: null,
29 | };
30 | }
31 |
32 | onLogin( user, remember ) {
33 | this.props.dispatch( set_user_credentials( user ) );
34 |
35 | if ( remember ) {
36 | localStorage.setItem( 'trac-auth', JSON.stringify( user ) );
37 | }
38 | }
39 |
40 | onLogOut() {
41 | localStorage.removeItem( 'trac-auth' );
42 |
43 | this.props.dispatch( reset_user_credentials() );
44 | }
45 |
46 | render() {
47 | const { dispatch, user } = this.props;
48 |
49 | if ( ! user.username ) {
50 | return
51 |
52 |
55 |
56 | this.onLogin( user, remember ) }
58 | />
59 |
60 |
61 |
62 | ;
63 | }
64 |
65 | return
66 |
67 |
71 | { user ?
72 |
73 |
74 | Components
75 |
76 |
77 |
80 |
81 | @{ user.username }
82 |
83 | }
84 | >
85 |
86 | View profile
89 |
90 |
91 | this.onLogOut() }
93 | type="button"
94 | >Log out
95 |
96 |
97 |
98 |
99 | : null }
100 |
101 |
102 |
103 |
108 | (
112 |
115 | )}
116 | />
117 | (
121 |
124 | )}
125 | />
126 | {
130 | console.log( qs.parse( location.search ) );
131 | return Testing!
132 | }}
133 | />
134 | (
138 |
146 | )}
147 | />
148 | }
152 | />
153 | (
157 |
161 | )}
162 | />
163 | (
164 |
165 |
404
166 |
No route matches this URL.
167 |
168 | )} />
169 |
170 |
171 |
172 |
176 |
177 | ;
178 | }
179 | }
180 |
181 | export default connect(
182 | ({ user }) => ({ user })
183 | )( App );
184 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render( , div);
8 | });
9 |
--------------------------------------------------------------------------------
/src/actions.js:
--------------------------------------------------------------------------------
1 | export const PUSH_ATTACHMENT = 'PUSH_ATTACHMENT';
2 | export const PUSH_TICKET_CHANGE = 'PUSH_TICKET_CHANGE';
3 | export const RECEIVE_PRS = 'RECEIVE_PRS';
4 | export const REQUEST_PRS = 'REQUEST_PRS';
5 | export const RESET_USER_CREDENTIALS = 'RESET_USER_CREDENTIALS';
6 | export const SET_COMPONENTS = 'SET_COMPONENTS';
7 | export const SET_QUERY_PARAMS = 'SET_QUERY_PARAMS';
8 | export const SET_QUERY_RESULTS = 'SET_QUERY_RESULTS';
9 | export const SET_TICKET_ATTACHMENTS = 'SET_TICKET_ATTACHMENTS';
10 | export const SET_TICKET_CHANGES = 'SET_TICKET_CHANGES';
11 | export const SET_TICKET_DATA = 'SET_TICKET_DATA';
12 | export const SET_USER_CREDENTIALS = 'SET_USER_CREDENTIALS';
13 |
14 | export function push_attachment( id, attachment ) {
15 | return { type: PUSH_ATTACHMENT, id, attachment };
16 | }
17 |
18 | export function push_ticket_change( id, change ) {
19 | return { type: PUSH_TICKET_CHANGE, id, change };
20 | }
21 |
22 | export function reset_user_credentials() {
23 | return { type: RESET_USER_CREDENTIALS };
24 | }
25 |
26 | export function set_components( components ) {
27 | return { type: SET_COMPONENTS, components };
28 | }
29 |
30 | export function set_query_params( params ) {
31 | return { type: SET_QUERY_PARAMS, params };
32 | }
33 |
34 | export function set_query_results( results ) {
35 | return { type: SET_QUERY_RESULTS, results };
36 | }
37 |
38 | export function set_ticket_attachments( id, attachments ) {
39 | return { type: SET_TICKET_ATTACHMENTS, id, attachments };
40 | }
41 |
42 | export function set_ticket_changes( id, changes ) {
43 | return { type: SET_TICKET_CHANGES, id, changes };
44 | }
45 |
46 | export function set_ticket_data( id, data ) {
47 | return { type: SET_TICKET_DATA, id, data };
48 | }
49 |
50 | export function set_user_credentials( user ) {
51 | return { type: SET_USER_CREDENTIALS, user };
52 | }
53 |
54 | export function update_prs() {
55 | return (dispatch, getStore) => {
56 | dispatch( { type: REQUEST_PRS } );
57 |
58 | fetch( 'https://api.github.com/repos/WordPress/wordpress-develop/pulls?state=all' )
59 | .then( resp => resp.json() )
60 | .then( data => {
61 | dispatch({
62 | type: RECEIVE_PRS,
63 | prs: data,
64 | });
65 | });
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/Attachment.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import CodeBlock from '../components/CodeBlock';
5 | import Loading from './Loading';
6 |
7 | const guessLanguage = filename => {
8 | const parts = filename.split( '.' );
9 | const ext = parts.pop();
10 |
11 | switch ( ext ) {
12 | case 'diff':
13 | case 'patch':
14 | return 'diff';
15 |
16 | case 'php':
17 | return 'php';
18 |
19 | default:
20 | return 'diff';
21 | }
22 | };
23 |
24 | export default class Attachment extends React.Component {
25 | render() {
26 | const { data, id, isLoading, ticket } = this.props;
27 |
28 | const lang = guessLanguage( id );
29 |
30 | return
31 |
32 |
33 |
34 | #{ ticket }
35 |
36 | { ': ' }
37 | { id }
38 |
39 |
40 |
41 | { isLoading ?
42 |
43 | :
44 |
{ data }
45 | }
46 |
;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/AttachmentUpload.css:
--------------------------------------------------------------------------------
1 | .AttachmentUpload.dropping {
2 | border: 2px dashed #b4b9be;
3 | border-radius: 3px;
4 | height: 4rem;
5 | position: relative;
6 | }
7 | .AttachmentUpload.dropping:before {
8 | position: absolute;
9 | left: 0;
10 | right: 0;
11 | bottom: 0;
12 | top: 0;
13 |
14 | display: flex;
15 | justify-content: center;
16 | align-items: center;
17 |
18 | content: "Drop files here";
19 | font-size: 1rem;
20 | color: #b4b9be;
21 | }
22 | .AttachmentUpload.dropping > .buttons {
23 | display: none;
24 | }
25 | .AttachmentUpload > .buttons {
26 | display: flex;
27 | align-items: center;
28 | margin-top: -2px;
29 | }
30 | .AttachmentUpload .Button {
31 | margin-right: 0.5em;
32 | }
33 | .AttachmentUpload-uploader > input[type=file] {
34 | display: none;
35 | }
36 |
37 | .AttachmentUpload-description {
38 | padding: 7px;
39 | width: calc( 100% - 7px * 2 );
40 | font: inherit;
41 | border: 1px solid #bfe7f3;
42 | }
43 |
44 | .AttachmentUpload-progress > span {
45 | margin-left: 1rem;
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/AttachmentUpload.js:
--------------------------------------------------------------------------------
1 | import base64 from 'base64-js';
2 | import bytes from 'bytes';
3 | import React from 'react';
4 | import { Modal } from 'react-router-modal';
5 |
6 | import Button from './Button';
7 | import PullRequests from './PullRequests';
8 | import Spinner from './Spinner';
9 |
10 | import './AttachmentUpload.css';
11 |
12 | const INITIAL_STATE = {
13 | description: '',
14 | dropping: false,
15 | file: null,
16 | licenseAgree: false,
17 | progress: 0,
18 | showingPullSelector: false,
19 | uploading: false,
20 | uploadMessage: '',
21 | };
22 |
23 | export default class AttachmentUpload extends React.PureComponent {
24 | constructor( props ) {
25 | super( props );
26 |
27 | this.state = { ...INITIAL_STATE };
28 | }
29 |
30 | onUpload() {
31 | const { file } = this.state;
32 |
33 | const reader = new FileReader();
34 |
35 | reader.onprogress = e => {
36 | // e is an ProgressEvent.
37 | if ( ! e.lengthComputable ) {
38 | return;
39 | }
40 |
41 | const progress = Math.round( ( e.loaded / e.total ) * 100 );
42 | this.setState({ progress });
43 | };
44 |
45 | reader.onabort = function(e) {
46 | this.setState({
47 | uploading: false,
48 | });
49 | alert('File read cancelled');
50 | };
51 |
52 | reader.onload = e => {
53 | this.setState({
54 | progress: 100,
55 | });
56 |
57 | const { description } = this.state;
58 | const bufferView = new Uint8Array( reader.result );
59 | const data = base64.fromByteArray( bufferView );
60 |
61 | this.props.onUpload({
62 | data,
63 | description,
64 | filename: file.name,
65 | });
66 |
67 | // Reset state.
68 | this.setState({ ...INITIAL_STATE });
69 | };
70 |
71 | // Read in the file as a binary string.
72 | reader.readAsArrayBuffer( file );
73 |
74 | this.setState({
75 | progress: 0,
76 | uploading: true,
77 | uploadMessage: 'Reading file…',
78 | });
79 | }
80 |
81 | onSelectPull( pull, file ) {
82 | const description = `${ pull.title } (From ${ pull.html_url })`;
83 |
84 | this.setState({
85 | description,
86 | file,
87 | });
88 | }
89 |
90 | onDragOver( e ) {
91 | e.preventDefault();
92 |
93 | // Explicitly show this is a copy.
94 | e.dataTransfer.dropEffect = 'copy';
95 |
96 | this.setState({ dropping: true });
97 | }
98 |
99 | onDragLeave( e ) {
100 | e.preventDefault();
101 |
102 | this.setState({ dropping: false });
103 | }
104 |
105 | onDrop( e ) {
106 | e.preventDefault();
107 |
108 | // If there's no files, ignore it.
109 | if ( ! e.dataTransfer.files.length ) {
110 | this.setState({ dropping: false });
111 | return;
112 | }
113 |
114 | this.setState({
115 | file: e.dataTransfer.files[0],
116 | dropping: false
117 | });
118 | }
119 |
120 | render() {
121 | const { prs, ticket } = this.props;
122 | const { file, licenseAgree } = this.state;
123 |
124 | if ( ! file ) {
125 | return this.onDragOver( e ) }
128 | onDragLeave={ e => this.onDragLeave( e ) }
129 | onDrop={ e => this.onDrop( e ) }
130 | >
131 |
132 |
133 | this.setState({ file: e.target.files[0] }) }
136 | />
137 | Upload an Attachment
138 |
139 | { prs ?
140 | this.setState({ showingPullSelector: true }) }
142 | >
143 | Attach a Pull Request
144 |
145 | : null }
146 | (Or drop files here.)
147 |
148 |
149 | { prs && this.state.showingPullSelector ?
150 |
151 | this.setState({ showingPullSelector: false }) }
156 | onReload={ this.props.onLoadPulls }
157 | onSelect={ ( pull, file ) => this.onSelectPull( pull, file ) }
158 | />
159 |
160 | : null }
161 |
;
162 | }
163 |
164 | return
165 |
Uploading { file.name }
({ bytes( file.size ) }):
166 | { this.state.uploading ?
167 |
168 |
172 |
173 |
174 |
175 |
176 | { this.state.uploadMessage }
177 |
178 |
179 | :
180 |
181 |
182 | this.setState({ description: e.target.value }) }
188 | />
189 |
190 |
191 |
192 | this.setState({ licenseAgree: e.target.checked }) }
196 | />
197 |
198 | I agree to license the attached file under the GNU
199 | General Public License v2 (or later).
200 |
201 |
202 |
203 | this.onUpload() }
206 | primary
207 | >Upload to Trac
208 | this.setState({ ...INITIAL_STATE }) }
210 | >Cancel
211 |
212 |
213 | }
214 |
;
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/src/components/Avatar.css:
--------------------------------------------------------------------------------
1 | .Avatar {
2 | border-radius: 3px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/Avatar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './Avatar.css';
4 |
5 | export default ({ user, size = 48 }) => (
6 |
20 | );
21 |
--------------------------------------------------------------------------------
/src/components/Board.css:
--------------------------------------------------------------------------------
1 | .Board {
2 | width: 100vw;
3 | margin-left: calc(50% - 50vw);
4 | margin-right: calc(50% - 50vw);
5 | }
6 |
7 | .Board-columns {
8 | display: flex;
9 | align-items: flex-start;
10 | overflow-x: scroll;
11 | height: 85vh;
12 |
13 | font-size: 0.9em;
14 | padding: 1em 1vw;
15 |
16 | background: #cbcdce;
17 | }
18 |
19 | .Board-columns:after {
20 | content: "";
21 | display: block;
22 | height: 100%;
23 | width: 50px;
24 | }
25 |
26 | .Board-column {
27 | width: 28vw;
28 | margin-right: 0.75vw;
29 | flex-shrink: 0;
30 | flex-grow: 0;
31 | position: relative;
32 | overflow-y: scroll;
33 | max-height: 100%;
34 | padding: 0;
35 | background: rgba( 255, 255, 255, 0.4 );
36 | border-radius: 3px;
37 | }
38 |
39 | /* Convince Firefox to show right margin */
40 | .Board-columns::after {
41 | display: block;
42 | content: " ";
43 | flex-shrink: 0;
44 | flex-grow: 0;
45 | height: 1px;
46 | width: 1px;
47 | }
48 |
49 | .Board-column > .Header {
50 | font-size: 0.75em;
51 | position: sticky;
52 | top: 0;
53 | }
54 |
55 | .Board .ListTableItem {
56 | background: #fff;
57 | border: none;
58 | margin: 0.4em 0.5em;
59 | border-radius: 3px;
60 | box-shadow: 1px 2px 2px rgba( 0, 0, 0, 0.1 );
61 | }
62 | .Board .ListTableItem:hover {
63 | box-shadow: 1px 2px 2px rgba( 0, 0, 0, 0.2 )
64 | }
65 |
66 | .Board .TicketListItem-detail-title-block {
67 | flex-direction: column;
68 | }
69 | .Board .TicketListItem-detail-title-block .col-main-title a {
70 | display: block;
71 | }
72 | .Board .TicketListItem-detail-tags {
73 | display: none;
74 | margin-left: 0;
75 | }
76 | .Board.with-labels .TicketListItem-detail-tags {
77 | display: block;
78 | }
79 | .Board .TicketListItem-detail-opened {
80 | display: none;
81 | }
82 | .Board .TicketListItem-detail-milestone {
83 | display: block;
84 | margin-left: -20px;
85 | margin-top: 0.5em;
86 | }
87 | .Board .TicketListItem-detail-patch {
88 | display: none;
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/Board.js:
--------------------------------------------------------------------------------
1 | import qs from 'query-string';
2 | import React from 'react';
3 | import { Link } from 'react-router-dom';
4 |
5 | import Button from './Button';
6 | import Header from './Header';
7 | import Loading from './Loading';
8 | import QueryHeader from './QueryHeader';
9 | import Tag from './Tag';
10 | import TicketList from './TicketList';
11 |
12 | import './Board.css';
13 |
14 | const COLUMNS = {
15 | 'icebox': {
16 | title: 'Icebox',
17 | },
18 | 'backlog': {
19 | title: 'Backlog',
20 | },
21 | 'needs-patch': {
22 | title: 'Needs Patch',
23 | keywords: [],
24 | },
25 | 'assigned': {
26 | title: 'Assigned (No Patch)',
27 | },
28 | 'has-patch': {
29 | title: 'Has Patch',
30 | keywords: [ 'has-patch' ],
31 | },
32 | 'commit': {
33 | title: 'Commit',
34 | keywords: [ 'has-patch', 'commit' ],
35 | },
36 | };
37 |
38 | const getColumn = ticket => {
39 | const keywords = ticket.attributes.keywords.split( ' ' ).map( k => k.trim() );
40 |
41 | if ( ticket.attributes.milestone === 'Future Release' ) {
42 | return 'icebox';
43 | }
44 |
45 | if ( keywords.indexOf( 'has-patch' ) >= 0 ) {
46 | if ( keywords.indexOf( 'commit' ) >= 0 ) {
47 | return 'commit';
48 | }
49 |
50 | return 'has-patch';
51 | }
52 |
53 | if ( ticket.attributes.owner ) {
54 | return 'assigned';
55 | }
56 |
57 | if ( ticket.attributes.status === 'accepted' || keywords.indexOf( 'needs-patch' ) >= 0 ) {
58 | return 'needs-patch';
59 | }
60 |
61 | return 'backlog';
62 | };
63 |
64 | const columnise = tickets => {
65 | const data = {};
66 | Object.keys( COLUMNS ).forEach( key => {
67 | data[ key ] = {
68 | ...COLUMNS[ key ],
69 | tickets: [],
70 | }
71 | });
72 | tickets.forEach( ticket => {
73 | const column = getColumn( ticket );
74 | data[ column ].tickets.push( ticket );
75 | });
76 | return data;
77 | };
78 |
79 | export default class Board extends React.PureComponent {
80 | constructor( props ) {
81 | super( props );
82 |
83 | this.state = {
84 | showingLabels: true,
85 | };
86 |
87 | this.milestoneComponent = ({ className, name }) => {
88 | const nextParams = {
89 | ...this.props.params,
90 | milestone: name,
91 | };
92 | const search = '?' + qs.stringify( nextParams );
93 | return
94 |
95 | { name }
96 | ;
97 | };
98 | this.labelComponent = ({ name }) => {
99 | const nextParams = {
100 | ...this.props.params,
101 | keywords: '~' + name,
102 | };
103 | const search = '?' + qs.stringify( nextParams );
104 | return
105 |
106 | ;
107 | };
108 | }
109 |
110 | render() {
111 | const { loading, params, tickets, onUpdateQuery } = this.props;
112 | const { showingLabels } = this.state;
113 |
114 | const data = columnise( tickets );
115 |
116 | const queryLink = {
117 | pathname: '/query',
118 | search: '?' + qs.stringify( params ),
119 | };
120 |
121 | return
122 |
126 |
127 |
128 | Switch to list view
129 |
130 | this.setState({ showingLabels: ! showingLabels }) }>
131 | { showingLabels ? 'Hide labels' : 'Show labels' }
132 |
133 |
134 | { loading ? (
135 |
136 | ) : (
137 |
138 | { Object.keys( data ).map( name => {
139 | const column = data[ name ];
140 | return
141 |
142 | { column.tickets.length > 0 ?
143 |
148 | :
149 |
No items.
150 | }
151 |
;
152 | } ) }
153 |
154 | ) }
155 |
;
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/src/components/Button.css:
--------------------------------------------------------------------------------
1 | .Button {
2 | display: inline-block;
3 | font-family: inherit;
4 | font-size: 13px;
5 | line-height: 26px;
6 | height: 28px;
7 | margin: 0;
8 | padding: 0 10px 1px;
9 | cursor: pointer;
10 | border-width: 1px;
11 | border-style: solid;
12 | border-radius: 3px;
13 | white-space: nowrap;
14 |
15 | color: #555;
16 | border-color: #cccccc;
17 | background: #f7f7f7;
18 | -webkit-box-shadow: 0 1px 0 #cccccc;
19 | box-shadow: 0 1px 0 #cccccc;
20 | vertical-align: top;
21 | }
22 |
23 | .Button:hover, .Button:focus {
24 | background: #fafafa;
25 | border-color: #999;
26 | color: #23282d;
27 | }
28 |
29 | .Button:focus {
30 | border-color: #5b9dd9;
31 | box-shadow: 0 0 3px rgba( 0, 115, 170, .8 );
32 | }
33 |
34 | .Button:active {
35 | background: #eee;
36 | border-color: #999;
37 | box-shadow: inset 0 2px 5px -3px rgba( 0, 0, 0, 0.5 );
38 | transform: translateY(1px);
39 | }
40 |
41 | .Button:disabled {
42 | color: #a0a5aa;
43 | border-color: #ddd;
44 | background: #f7f7f7;
45 | box-shadow: none;
46 | text-shadow: 0 1px 0 #fff;
47 | cursor: default;
48 | transform: none;
49 | }
50 |
51 |
52 | .Button.primary {
53 | background: #0085ba;
54 | border-color: #0073aa #006799 #006799;
55 | box-shadow: 0 1px 0 #006799;
56 | color: #fff;
57 | text-decoration: none;
58 | text-shadow: 0 -1px 1px #006799,
59 | 1px 0 1px #006799,
60 | 0 1px 1px #006799,
61 | -1px 0 1px #006799;
62 | }
63 |
64 | .Button.primary:hover {
65 | background: #008ec2;
66 | border-color: #006799;
67 | color: #fff;
68 | }
69 |
70 | .Button.primary:focus {
71 | box-shadow: 0 1px 0 #0073aa,
72 | 0 0 2px 1px #33b3db;
73 | }
74 |
75 | .Button.primary:active {
76 | background: #0073aa;
77 | border-color: #006799;
78 | box-shadow: inset 0 2px 0 #006799;
79 | vertical-align: top;
80 | }
81 |
82 | .Button.primary:disabled {
83 | color: #66c6e4;
84 | background: #008ec2;
85 | border-color: #007cb2;
86 | box-shadow: none;
87 | text-shadow: 0 -1px 0 rgba( 0, 0, 0, 0.1 );
88 | cursor: default;
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/Button.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import './Button.css';
5 |
6 | const Button = ({ children, disabled, fake, primary, submit, onClick }) => {
7 | const className = primary ? "Button primary" : "Button";
8 |
9 | if ( fake ) {
10 | return ;
15 | }
16 |
17 | return ;
24 | }
25 |
26 | Button.propTypes = {
27 | disabled: PropTypes.bool,
28 | fake: PropTypes.bool,
29 | primary: PropTypes.bool,
30 | submit: PropTypes.bool,
31 | onClick: PropTypes.func,
32 | };
33 |
34 | Button.defaultProps = {
35 | disabled: false,
36 | fake: false,
37 | primary: false,
38 | submit: false,
39 | };
40 |
41 | export default Button;
42 |
--------------------------------------------------------------------------------
/src/components/CodeBlock.js:
--------------------------------------------------------------------------------
1 | import Prism from 'prismjs';
2 | import PropTypes from 'prop-types';
3 | import React from 'react';
4 |
5 | // Load extra Prism languages.
6 | import 'prismjs/components/prism-diff';
7 | import 'prismjs/components/prism-json';
8 | import 'prismjs/components/prism-php';
9 |
10 | // Load Prism theme.
11 | import 'prismjs/themes/prism.css';
12 |
13 | export default class CodeBlock extends React.PureComponent {
14 | render() {
15 | const { children, lang } = this.props;
16 |
17 | const language = Prism.languages[ lang ] || Prism.languages.clike;
18 | const highlighted = Prism.highlight( children, language );
19 |
20 | return
21 |
25 | ;
26 | }
27 | }
28 |
29 | CodeBlock.propTypes = {
30 | children: PropTypes.string.isRequired,
31 | lang: PropTypes.string,
32 | };
33 |
34 | CodeBlock.defaultProps = {
35 | lang: 'clike',
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/Comment.css:
--------------------------------------------------------------------------------
1 | .Comment {
2 | display: flex;
3 | background: #fff;
4 | }
5 |
6 | .Comment-col-avatar {
7 | padding-right: 1rem;
8 | }
9 |
10 | .Comment-col-main {
11 | border: 1px solid #bfe7f3;
12 | border-radius: 3px;
13 | flex-grow: 1;
14 | overflow: hidden;
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Comment.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import Avatar from './Avatar';
5 |
6 | import './Comment.css';
7 |
8 | export default class Comment extends React.PureComponent {
9 | render() {
10 | const { author, className, children } = this.props;
11 |
12 | return
13 |
16 |
17 | { children }
18 |
19 |
;
20 | }
21 | }
22 |
23 | Comment.propTypes = {
24 | author: PropTypes.string.isRequired,
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/CommentContent.css:
--------------------------------------------------------------------------------
1 | .CommentContent {
2 | padding: 14px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/CommentContent.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import FormattedText from './FormattedText';
4 |
5 | import './CommentContent.css';
6 |
7 | const CommentContent = ({ text, ticket }) => (
8 |
9 |
13 |
14 | );
15 |
16 | CommentContent.propTypes = {
17 | text: PropTypes.string.isRequired,
18 | ticket: PropTypes.number.isRequired,
19 | };
20 |
21 | export default CommentContent;
22 |
--------------------------------------------------------------------------------
/src/components/CommentEditor.css:
--------------------------------------------------------------------------------
1 | .CommentEditor .CommentHeader {
2 | border-bottom: 1px solid #bfe7f3;
3 | }
4 |
5 | .CommentEditor-tabs {
6 | list-style: none;
7 | padding: 0;
8 | margin: 0;
9 | display: flex;
10 | }
11 |
12 | .CommentEditor-tabs input {
13 | display: none;
14 | }
15 |
16 | .CommentEditor-tabs label {
17 | cursor: pointer;
18 | }
19 |
20 | .CommentEditor-tabs label span {
21 | display: inline-block;
22 | padding: 0.4rem 0.7rem 0.7rem;
23 | margin: -0.4rem 0.5rem -0.7rem 0;
24 |
25 | padding-bottom: calc( 0.7rem + 2px );
26 | margin-top: calc( -0.4rem - 1px );
27 | margin-bottom: calc( -0.7rem - 2px );
28 |
29 | border: 1px solid transparent;
30 | border-bottom-style: none;
31 | border-radius: 3px 3px 0 0;
32 | }
33 |
34 | .CommentEditor-tabs label input:checked + span {
35 | background: #fff;
36 | color: #555d66;
37 | border-color: #bfe7f3;
38 | }
39 |
40 | .CommentEditor-toolbar {
41 | display: flex;
42 | list-style: none;
43 | padding: 0;
44 | margin: 0;
45 | }
46 |
47 | .CommentEditor-toolbar button {
48 | background: transparent;
49 | border: none;
50 | font: inherit;
51 | cursor: pointer;
52 | padding: 0.25rem 0.25rem;
53 | margin: -0.25rem 0;
54 | }
55 |
56 | .CommentEditor-toolbar button:hover,
57 | .CommentEditor-toolbar button:active {
58 | color: #00a0d2;
59 | }
60 |
61 | .CommentEditor-toolbar .separator {
62 | margin-left: 0.5rem;
63 | }
64 |
65 | .CommentEditor-editor {
66 | margin: 7px;
67 | padding: 7px;
68 | width: calc( 100% - 7px * 2 );
69 | font: inherit;
70 | border: 1px solid #bfe7f3;
71 |
72 | min-height: 5rem;
73 | max-height: 30rem;
74 | resize: vertical;
75 | }
76 | .CommentEditor-editor:focus {
77 | box-shadow: 0 0 3px #00a0d2;
78 | }
79 |
80 | .CommentEditor-submit {
81 | padding: 0 7px 7px;
82 | display: flex;
83 | justify-content: space-between;
84 | align-items: center;
85 | }
86 |
87 | .CommentEditor-submit-buttons .Button {
88 | margin-left: 0.5rem;
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/CommentEditor.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Button from './Button';
4 | import Comment from './Comment';
5 | import CommentContent from './CommentContent';
6 | import CommentHeader from './CommentHeader';
7 |
8 | import './CommentEditor.css';
9 |
10 | const apply = ( selection, start, end ) => {
11 | return selection.length ? start + selection + end : start;
12 | };
13 |
14 | const BUTTONS = {
15 | bold: {
16 | icon: 'editor-bold',
17 | title: 'Add bold text',
18 | apply: text => apply( text, "'''", "'''" ),
19 | },
20 | italic: {
21 | icon: 'editor-italic',
22 | title: 'Add italic text',
23 | apply: text => apply( text, "''", "''" ),
24 | },
25 | sep1: { separator: true },
26 | quote: {
27 | icon: 'editor-quote',
28 | title: 'Add blockquote',
29 | apply: text => apply( text, '>', '\n' ),
30 | },
31 | code: {
32 | icon: 'editor-code',
33 | title: 'Add code',
34 | apply: text => text.indexOf( '\n' ) > 0 ? apply( text, '```\n', '\n```\n' ) : apply( text, '`', '`' ),
35 | },
36 | };
37 |
38 | export default class CommentEditor extends React.PureComponent {
39 | constructor( props ) {
40 | super( props );
41 |
42 | this.state = {
43 | content: '',
44 | height: null,
45 | mode: 'edit',
46 | };
47 | this.textarea = null;
48 | }
49 |
50 | componentDidUpdate() {
51 | if ( ! this.textarea ) {
52 | return;
53 | }
54 |
55 | // Recalculate height of the textarea, and grow to match content.
56 | const height = this.textarea.offsetHeight;
57 | const desired = this.textarea.scrollHeight;
58 |
59 | if ( desired > height ) {
60 | this.setState({ height: desired });
61 | }
62 | }
63 |
64 | onSubmit( e ) {
65 | e.preventDefault();
66 |
67 | this.props.onSubmit( this.state.content );
68 | }
69 |
70 | onButton( e, apply ) {
71 | e.preventDefault();
72 |
73 | const { selectionStart, selectionEnd } = this.textarea;
74 | const content = this.state.content;
75 |
76 | const nextParts = [
77 | content.substring( 0, selectionStart ),
78 | apply( content.substring( selectionStart, selectionEnd ) ),
79 | content.substring( selectionEnd )
80 | ];
81 |
82 | this.setState({ content: nextParts.join( '' ) });
83 | }
84 |
85 | render() {
86 | const { ticket, user } = this.props;
87 | const { content, height, mode } = this.state;
88 |
89 | return
90 |
167 | ;
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/src/components/CommentHeader.css:
--------------------------------------------------------------------------------
1 | .CommentHeader {
2 | display: flex;
3 | justify-content: space-between;
4 | background: #e5f5fa;
5 | color: #82878c;
6 | padding: 0.7rem 14px;
7 | }
8 |
9 | .CommentHeader p {
10 | margin: 0;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/CommentHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './CommentHeader.css';
4 |
5 | export default ({ children, className }) => (
6 |
7 | { children }
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/src/components/CommentMeta.css:
--------------------------------------------------------------------------------
1 | .CommentMeta .UserLink {
2 | color: #23282d;
3 | font-weight: bold;
4 | }
5 |
6 | .CommentMeta-author-label {
7 | display: inline-block;
8 | font-size: 0.9em;
9 | border: 1px solid #bfe7f3;
10 | background: rgba( 255, 255, 255, 0.1 );
11 | padding: 0.15em 0.4em;
12 | margin-top: -0.15em;
13 | margin-right: 0.6em;
14 | }
--------------------------------------------------------------------------------
/src/components/CommentMeta.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import CommentHeader from './CommentHeader';
5 | import Time from './Time';
6 | import UserLink from './UserLink';
7 |
8 | import './CommentMeta.css';
9 |
10 | // Hardcoded in Trac.
11 | const CONTRIBUTOR_LABELS = {
12 | matt: 'Project Lead',
13 | markjaquith: 'Lead Developer',
14 | nacin: 'Lead Developer',
15 | azaozz: 'Lead Developer',
16 | dd32: 'Lead Developer',
17 | helen: 'Lead Developer',
18 | SergeyBiryukov: 'Core Committer',
19 | johnbillion: 'Core Committer',
20 | DrewAPicture: 'Core Committer',
21 | pento: 'Core Committer',
22 | boonebgorges: 'Core Committer',
23 | jeremyfelt: 'Core Committer',
24 | jorbin: 'Core Committer',
25 | nbachiyski: 'Core Committer',
26 | wonderboymusic: 'Core Committer',
27 | westonruter: 'Core Committer',
28 | iseulde: 'Core Committer',
29 | afercia: 'Core Committer',
30 | swissspidy: 'Core Committer',
31 | rachelbaker: 'Core Committer',
32 | mikeschroder: 'Core Committer',
33 | joemcgill: 'Core Committer',
34 | ocean90: 'Core Committer',
35 | aaroncampbell: 'Core Committer',
36 | kovshenin: 'Core Committer',
37 | obenland: 'Core Committer',
38 | rmccue: 'Core Committer',
39 | michaelarestad: 'Core Committer',
40 | joehoyle: 'Core Committer',
41 | melchoyce: 'Core Committer',
42 | ericlewis: 'Core Committer',
43 | peterwilsoncc: 'Core Committer',
44 | jnylen0: 'Core Committer',
45 | adamsilverstein: 'Core Committer',
46 | flixos90: 'Core Committer',
47 | matveb: 'Core Committer',
48 | joen: 'Core Committer',
49 | kadamwhite: 'Core Committer',
50 | iandunn: 'Core Committer',
51 | iammattthomas: 'Core Committer',
52 | lancewillett: 'Themes Committer',
53 | iandstewart: 'Themes Committer',
54 | karmatosed: 'Themes Committer',
55 | davidakennedy: 'Themes Committer',
56 | ryan: 'Lead Tester',
57 | designsimply: 'Lead Tester',
58 | westi: 'Lead Developer',
59 | koop: 'Core Committer',
60 | duck_: 'Core Committer'
61 | };
62 |
63 | export default class CommentMeta extends React.PureComponent {
64 | render() {
65 | const { author, datetime, edits, number, pending, ticket } = this.props;
66 |
67 | return
68 |
69 |
70 | { ' commented ' }
71 |
72 | { edits.length > 0 ?
73 | • edited
74 | : null }
75 |
76 |
77 | { author in CONTRIBUTOR_LABELS ?
78 | { CONTRIBUTOR_LABELS[ author ] }
79 | : null }
80 | { pending ?
81 | Saving…
82 | :
83 |
84 | #{ number }
85 | •
86 |
87 |
88 |
89 |
90 | }
91 |
92 | ;
93 | }
94 | }
95 | CommentMeta.propTypes = {
96 | author: PropTypes.string.isRequired,
97 | datetime: PropTypes.string.isRequired,
98 | edits: PropTypes.array,
99 | number: PropTypes.number.isRequired,
100 | ticket: PropTypes.number.isRequired,
101 | };
102 | CommentMeta.defaultProps = {
103 | edits: [],
104 | pending: false,
105 | };
106 |
--------------------------------------------------------------------------------
/src/components/DropList.css:
--------------------------------------------------------------------------------
1 | .DropList .Dropdown-list > li {
2 | border-bottom: 1px solid #bfe7f3;
3 | margin: 0 -0.5em;
4 | }
5 |
6 | .DropList .Dropdown-list > li:first-of-type {
7 | margin-top: -0.5em;
8 | }
9 | .DropList .Dropdown-list > li:last-of-type {
10 | margin-bottom: -0.5em;
11 | border-bottom: none;
12 | }
13 |
14 | .DropList .Dropdown-list > li:hover {
15 | background: #e5f5fa;
16 | }
17 |
18 | .DropList .Dropdown-list > li button,
19 | .DropList .Dropdown-list > li a {
20 | background: transparent;
21 | color: inherit;
22 | font: inherit;
23 | border: none;
24 | text-align: left;
25 |
26 | display: block;
27 | padding: 0;
28 | margin: 0;
29 | width: 100%;
30 | height: 100%;
31 |
32 | padding: 0.5em;
33 | cursor: pointer;
34 | }
35 |
36 | .DropList .Dropdown-list > li.selected {
37 | font-weight: bold;
38 | }
39 |
40 | .DropList .Dropdown-list > li.selected .dashicons {
41 | margin-left: -1.5em;
42 | margin-right: 0.5em;
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/DropList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Dropdown from './Dropdown';
4 |
5 | import './DropList.css';
6 |
7 | export default function DropList( props ) {
8 | return ;
12 | }
13 |
14 | DropList.defaultProps = {
15 | className: '',
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/DropSelect.css:
--------------------------------------------------------------------------------
1 | .DropSelect-item {
2 | border-bottom: 1px solid #bfe7f3;
3 | margin: 0 -0.5em;
4 | }
5 |
6 | .DropSelect-item:first-of-type {
7 | margin-top: -0.5em;
8 | }
9 | .DropSelect-item:last-of-type {
10 | margin-bottom: -0.5em;
11 | border-bottom: none;
12 | }
13 |
14 | .DropSelect-item:hover {
15 | background: #e5f5fa;
16 | }
17 |
18 | .DropSelect-item button {
19 | background: transparent;
20 | color: inherit;
21 | font: inherit;
22 | border: none;
23 | text-align: left;
24 |
25 | display: block;
26 | padding: 0;
27 | margin: 0;
28 | width: 100%;
29 | height: 100%;
30 |
31 | padding: 0.5em 0.5em 0.5em 2.5em;
32 | cursor: pointer;
33 | }
34 |
35 | .DropSelect-item.selected {
36 | font-weight: bold;
37 | }
38 |
39 | .DropSelect-item.selected .dashicons {
40 | margin-left: -1.5em;
41 | margin-right: 0.5em;
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/DropSelect.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import Dropdown from './Dropdown';
5 | import Header from './Header';
6 | import Spinner from './Spinner';
7 |
8 | import './DropSelect.css';
9 |
10 | export default class DropSelect extends React.PureComponent {
11 | constructor( props ) {
12 | super( props );
13 |
14 | this.state = {
15 | didLoad: false,
16 | };
17 | }
18 |
19 | onToggle( expanded ) {
20 | if ( expanded && ! this.state.didLoad && this.props.onLoad ) {
21 | this.props.onLoad();
22 | }
23 | }
24 |
25 | render() {
26 | const { items, label, loading, loadingText, title } = this.props;
27 |
28 | // Cast selected to array.
29 | const selected = typeof this.props.selected === "string" ? [ this.props.selected ] : this.props.selected;
30 |
31 | // Cast items to expected.
32 | const fullItems = items.map( value => {
33 | // Turn strings into full objects.
34 | if ( typeof value !== 'object' ) {
35 | return {
36 | title: value,
37 | value: value,
38 | id: value,
39 | selected: selected.indexOf( value ) >= 0,
40 | };
41 | }
42 |
43 | const item = { ...value };
44 |
45 | if ( ! ( 'id' in item ) ) {
46 | item.id = item.value;
47 | }
48 | item.selected = selected.indexOf( item.id ) >= 0;
49 |
50 | return item;
51 | });
52 |
53 | // Move selected items to the top, if we can.
54 | let orderedItems = [];
55 | if ( selected.length > 0 ) {
56 | const unselected = fullItems.filter( item => {
57 | if ( item.selected ) {
58 | // Remove from this list, and manually add.
59 | orderedItems.push( item );
60 | return false;
61 | }
62 |
63 | return true;
64 | });
65 | orderedItems = [ ...orderedItems, ...unselected ];
66 | } else {
67 | orderedItems = fullItems;
68 | }
69 |
70 | const header = title ? : null;
71 | return this.onToggle( expanded ) }
75 | >
76 | { loading ?
77 |
78 |
79 | { loadingText }
80 |
81 | :
82 | orderedItems.map( item => {
83 | const className = item.selected ? "DropSelect-item selected" : "DropSelect-item";
84 |
85 | return
86 | this.props.onDeselect( item.value )
89 | :
90 | () => this.props.onSelect( item.value )
91 | }
92 | type="button"
93 | >
94 | { item.selected ?
95 |
96 | : null }
97 | { item.title }
98 |
99 | ;
100 | })
101 | }
102 | ;
103 | }
104 | }
105 |
106 | DropSelect.propTypes = {
107 | label: PropTypes.oneOfType([
108 | PropTypes.string,
109 | PropTypes.element,
110 | ]).isRequired,
111 | loading: PropTypes.bool,
112 | items: PropTypes.list,
113 | selected: PropTypes.oneOfType([
114 | PropTypes.arrayOf( PropTypes.string ),
115 | PropTypes.string,
116 | ]),
117 | title: PropTypes.string,
118 | onLoad: PropTypes.func,
119 | onSelect: PropTypes.func.isRequired,
120 | onDeselect: PropTypes.func,
121 | };
122 | DropSelect.defaultProps = {
123 | loading: false,
124 | selected: [],
125 | };
126 |
--------------------------------------------------------------------------------
/src/components/Dropdown.css:
--------------------------------------------------------------------------------
1 | .Dropdown {
2 | position: relative;
3 | }
4 |
5 | .Dropdown-trigger {
6 | background: transparent;
7 | color: inherit;
8 | font: inherit;
9 | border: none;
10 | padding: 0;
11 | cursor: pointer;
12 | text-shadow: inherit;
13 | }
14 |
15 | .Dropdown-content {
16 | display: none;
17 |
18 | position: absolute;
19 | right: 0;
20 |
21 | margin: 0.2rem 0 0;
22 | padding: 0;
23 | width: 15rem;
24 | min-height: 5rem;
25 | max-height: 50vh;
26 | overflow: scroll;
27 |
28 | background: #fff;
29 | border: 1px solid #0073aa;
30 | box-shadow: 0 2px 3px rgba(50, 55, 60, 0.2);
31 | font-size: 0.8rem;
32 | border-radius: 3px;
33 | }
34 |
35 | .Dropdown.expanded .Dropdown-content {
36 | display: block;
37 | }
38 |
39 | .Dropdown-content > .Header {
40 | font-size: 0.6em;
41 | padding: 0.5em 1em;
42 | }
43 |
44 | .Dropdown-list {
45 | list-style: none;
46 | margin: 0;
47 | padding: 0.5em 0.5em;
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/Dropdown.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import './Dropdown.css';
5 |
6 | export default class Dropdown extends React.PureComponent {
7 | constructor( props ) {
8 | super( props );
9 |
10 | this.state = {
11 | expanded: false,
12 | };
13 | this.documentClickListener = e => this.onDocumentClick( e );
14 | }
15 |
16 | onDocumentClick( e ) {
17 | // Ignore events inside the dropdown.
18 | if ( ! this.root || this.root.contains( e.target ) ) {
19 | return;
20 | }
21 |
22 | // Remove handler.
23 | document.removeEventListener( 'click', this.documentClickListener );
24 |
25 | this.setState({ expanded: false });
26 | this.props.onToggle( false );
27 | }
28 |
29 | onToggle( e ) {
30 | e.preventDefault();
31 |
32 | const { expanded } = this.state;
33 | if ( ! expanded ) {
34 | // Hide on the next click anywhere else.
35 | document.addEventListener( 'click', this.documentClickListener );
36 | } else if ( this.documentClickListener ) {
37 | // Remove handler.
38 | document.removeEventListener( 'click', this.documentClickListener );
39 | }
40 |
41 | this.setState({ expanded: ! expanded });
42 | this.props.onToggle( ! expanded );
43 | }
44 |
45 | render() {
46 | const { children, header, label } = this.props;
47 | const { expanded } = this.state;
48 |
49 | const className = expanded ? 'Dropdown expanded' : 'Dropdown';
50 |
51 | return this.root = ref }>
52 |
this.onToggle( e ) }
55 | type="button"
56 | >
57 | { label }
58 |
59 |
60 |
61 | { header }
62 |
this.setState({ expanded: false }) }>
63 | { children }
64 |
65 |
66 |
;
67 | }
68 | }
69 |
70 | Dropdown.propTypes = {
71 | label: PropTypes.string.isRequired,
72 | };
73 |
74 | Dropdown.defaultProps = {
75 | className: '',
76 | onToggle: () => {},
77 | };
78 |
--------------------------------------------------------------------------------
/src/components/Error.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DocumentTitle from 'react-document-title';
3 |
4 | export default ({ children, error, title }) =>
5 |
6 |
{ title }
7 | { children }
8 |
9 |
10 | Error details
11 |
12 | Type
13 | { error.name }
14 | Message
15 | { error.message }
16 | Trace
17 | { error.stack }
18 |
19 |
20 |
21 | ;
22 |
--------------------------------------------------------------------------------
/src/components/Footer.css:
--------------------------------------------------------------------------------
1 | .Footer {
2 | background-color: #0073aa;
3 | color: #bfe7f3;
4 | padding: 20px;
5 | margin-top: 3rem;
6 | }
7 |
8 | .Footer h4 {
9 | color: #fff;
10 | }
11 |
12 | .Footer a {
13 | color: #fff;
14 | text-decoration: underline;
15 | }
16 |
17 | .Footer > .wrapper {
18 | display: flex;
19 | justify-content: space-between;
20 | }
21 |
22 | .Footer > .wrapper > div {
23 | width: 30%;
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './Footer.css';
4 |
5 | export default () =>
6 |
7 |
8 |
Not Trac
9 |
Made just for you with ♥ by Ryan McCue
10 |
Security
11 |
Accessing Trac requires your username/password. I promise it is
12 | never stored anywhere, but you still shouldn't trust me
13 | (especially if you have commit access).
14 |
Run this
15 | locally instead.
16 |
17 |
18 |
Why?
19 |
Trac is kinda painful to work with day-to-day. I made this to
20 | make my day a little better.
21 |
Hopefully, it makes your day a little better too.
22 |
23 |
29 |
30 |
;
31 |
--------------------------------------------------------------------------------
/src/components/FormattedText.css:
--------------------------------------------------------------------------------
1 | .FormattedText li {
2 | margin-bottom: 0.5em;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/FormattedText.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import parse from '../lib/text-parser';
5 | import format from '../lib/text-formatter';
6 |
7 | import './FormattedText.css';
8 |
9 | export default class FormattedText extends React.PureComponent {
10 | render() {
11 | const { context, text } = this.props;
12 |
13 | return
14 | { format( parse( text ), context ) }
15 |
;
16 | }
17 | }
18 |
19 | FormattedText.propTypes = {
20 | context: PropTypes.object,
21 | text: PropTypes.string.isRequired,
22 | };
23 |
24 | FormattedText.defaultProps = {
25 | context: {},
26 | };
27 |
--------------------------------------------------------------------------------
/src/components/Header.css:
--------------------------------------------------------------------------------
1 | .Header {
2 | background-color: #0073aa;
3 | color: #fff;
4 | padding: 1.25em;
5 | }
6 |
7 | .Header .wrapper {
8 | display: flex;
9 | justify-content: space-between;
10 | align-items: baseline;
11 | }
12 |
13 | .Header h1 {
14 | font-size: 1.5em;
15 | font-weight: 400;
16 | line-height: 1;
17 | font-family: 'Open Sans', sans-serif;
18 | }
19 |
20 | .Header nav > ul {
21 | display: flex;
22 | padding: 0;
23 | margin: 0;
24 | align-items: center;
25 | }
26 |
27 | .Header nav > ul > li {
28 | list-style: none;
29 | padding: 0;
30 | margin: 0 25px 0 0;
31 | }
32 |
33 | .Header nav > ul > li:last-of-type {
34 | margin-right: 0;
35 | }
36 |
37 | .Header .Avatar {
38 | vertical-align: middle;
39 | margin-right: 0.5rem;
40 | }
41 |
42 | .Header a {
43 | color: inherit;
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './Header.css';
4 |
5 | export default class Header extends React.PureComponent {
6 | render() {
7 | const { children, title } = this.props;
8 |
9 | return
10 |
11 |
{ title }
12 |
13 |
14 | { children }
15 |
16 |
17 |
;
18 | }
19 | }
20 | Header.defaultProps = {
21 | user: null,
22 | };
23 |
--------------------------------------------------------------------------------
/src/components/ListTable.css:
--------------------------------------------------------------------------------
1 | .ListTable {
2 | list-style: none;
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/ListTable.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './ListTable.css';
4 |
5 | export default ({ children }) => ;
6 |
--------------------------------------------------------------------------------
/src/components/ListTableItem.css:
--------------------------------------------------------------------------------
1 | .ListTableItem {
2 | display: flex;
3 | border: 1px solid #bfe7f3;
4 | border-bottom-style: none;
5 | padding: 0.7em;
6 | }
7 |
8 | .ListTableItem:hover {
9 | background: #e5f5fa;
10 | }
11 |
12 | .ListTableItem:last-of-type {
13 | border-bottom-style: solid;
14 | }
15 |
16 | .ListTableItem > .col-main {
17 | flex-grow: 1;
18 | line-height: 1.6;
19 | }
20 |
21 | .ListTableItem .col-main-title {
22 | font-weight: bold;
23 | margin: 0;
24 | }
25 | .ListTableItem .col-main-title:hover {
26 | color: #00a0d2;
27 | }
28 | .ListTableItem .col-main-title a {
29 | color: inherit;
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/ListTableItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './ListTableItem.css';
4 |
5 | export default ({ children, className }) => (
6 |
10 | );
11 |
--------------------------------------------------------------------------------
/src/components/Loading.css:
--------------------------------------------------------------------------------
1 | .Loading {
2 | display: flex;
3 | width: 100%;
4 | height: 6rem;
5 |
6 | justify-content: center;
7 | align-items: center;
8 | }
9 | .Loading-text {
10 | font-size: 2rem;
11 | }
12 |
13 | .Loading .Spinner {
14 | font-size: 40px;
15 | height: 40px;
16 | width: 40px;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DocumentTitle from 'react-document-title';
3 |
4 | import Spinner from './Spinner';
5 |
6 | import './Loading.css';
7 |
8 | export default () => (
9 |
10 |
11 |
12 |
13 | Loading…
14 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/src/components/Login.css:
--------------------------------------------------------------------------------
1 | .Login {
2 | max-width: 30rem;
3 | }
4 | .Login form {
5 | border: 1px solid #0073aa;
6 | padding: 1rem;
7 | display: block;
8 | width: 20rem;
9 | }
10 | .Login form > div {
11 | display: flex;
12 | margin-bottom: 0.5em;
13 | }
14 | .Login form label {
15 | width: 40%;
16 | font-weight: bold;
17 | }
--------------------------------------------------------------------------------
/src/components/Login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './Login.css';
4 |
5 | export default class Login extends React.PureComponent {
6 | constructor( props ) {
7 | super( props );
8 |
9 | this.state = {
10 | username: '',
11 | password: '',
12 | remember: false,
13 | };
14 | }
15 |
16 | onSubmit( e ) {
17 | const { password, remember, username } = this.state;
18 |
19 | e.preventDefault();
20 |
21 | this.props.onSubmit( { username, password }, remember );
22 | }
23 |
24 | render() {
25 | return
26 |
Log In to WordPress.org
27 |
Unfortunately, Trac only provides access to authenticated users.
28 | To access Trac, I need your username and password. This is never
29 | stored or sent anywhere, and is only ever sent to the Trac API.
30 |
31 |
Due to browser restrictions, Trac requests are passed via a small
32 | proxy server. If this makes you nervous (and it should), you
33 | should run this locally instead.
34 |
35 |
this.onSubmit( e ) }>
36 |
37 | Username
38 | this.setState({ username: e.target.value }) }
42 | />
43 |
44 |
45 | Password
46 | this.setState({ password: e.target.value }) }
49 | />
50 |
51 |
52 | Remember Me
53 | this.setState( { remember: e.target.checked } ) }
57 | />
58 |
59 |
60 | Log In
61 |
62 |
;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/PullRequests.css:
--------------------------------------------------------------------------------
1 | .PullRequests {
2 | width: 80vw;
3 | height: 80vh;
4 | max-width: 960px;
5 | }
6 | .PullRequests-content {
7 | padding: 1rem;
8 | }
9 | .PullRequests-state {
10 | font-size: 0.9em;
11 | padding-top: 3px;
12 | }
13 |
14 | .PullRequests .col-main-title .dashicons {
15 | font-size: 1em;
16 | height: 1em;
17 | width: 1em;
18 | vertical-align: baseline;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/PullRequests.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Button from './Button';
4 | import Header from './Header';
5 | import ListTable from './ListTable';
6 | import ListTableItem from './ListTableItem';
7 | import Loading from './Loading';
8 | import Spinner from './Spinner';
9 | import TicketState from './TicketState';
10 | import Time from './Time';
11 |
12 | import './PullRequests.css';
13 |
14 | const PullRequestItem = ({ item, loading, onSelect }) => {
15 | return
16 |
17 |
20 |
21 |
38 |
39 | { loading ?
40 |
41 |
42 | { ' Loading' }
43 | …
44 |
45 | :
46 | Select
47 | }
48 |
49 | ;
50 | };
51 |
52 | export default class PullRequests extends React.PureComponent {
53 | constructor( props ) {
54 | super( props );
55 |
56 | this.state = {
57 | loading: null,
58 | };
59 | }
60 |
61 | onSelect( pull ) {
62 | const { id } = this.props;
63 |
64 | // Start the loader.
65 | this.setState({ loading: pull.id });
66 |
67 | // Build diff URL manually to avoid CORS problems.
68 | const diff_url = `https://api.github.com/repos/WordPress/wordpress-develop/pulls/${ pull.number }`;
69 | const headers = {
70 | Accept: 'application/vnd.github.v3.diff',
71 | };
72 | fetch( diff_url, { headers } )
73 | .then( resp => resp.blob() )
74 | .then( file => {
75 | // Extend file with File-like properties.
76 | file.name = `${ id }.diff`;
77 |
78 | this.props.onSelect( pull, file );
79 | });
80 | }
81 |
82 | render() {
83 | const { id, items, state } = this.props;
84 | const { loading } = this.state;
85 |
86 | return
87 |
101 |
102 | { state === 'loading' ?
103 |
104 | :
105 |
106 | { items.map( item =>
107 | this.onSelect( item ) }
112 | />
113 | ) }
114 |
115 | }
116 |
117 |
;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/components/Query.css:
--------------------------------------------------------------------------------
1 | .Query > .QueryHeader {
2 | border-radius: 3px 3px 0 0;
3 | }
4 |
5 | .Query-empty {
6 | padding: 0.7em;
7 | border: 1px solid #e5f5fa;
8 | }
9 |
10 | .Query-footer {
11 | margin-top: 1rem;
12 | margin-bottom: 3rem;
13 | text-align: center;
14 | }
15 | .Query-footer button, .Query-page-num {
16 | background: transparent;
17 | border: 1px solid #bfe7f3;
18 | border-right-style: none;
19 | font-size: 1em;
20 | font-weight: bold;
21 | line-height: 1;
22 | padding: 1em;
23 | }
24 | .Query-footer button {
25 | color: #00a0d2;
26 | cursor: pointer;
27 | cursor: hand;
28 | }
29 | .Query-footer button:disabled {
30 | background: #e5f5fa;
31 | color: #bfe7f3;
32 | }
33 | .Query-footer button:first-child {
34 | border-radius: 3px 0 0 3px;
35 | }
36 | .Query-footer button:last-child {
37 | border-radius: 0 3px 3px 0;
38 | border-right-style: solid;
39 | }
40 | .Query-page-num {
41 | background: #00a0d2;
42 | color: #fff;
43 | display: inline-block;
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/Query.js:
--------------------------------------------------------------------------------
1 | import qs from 'query-string';
2 | import React from 'react';
3 | import { Link } from 'react-router-dom';
4 |
5 | import Loading from './Loading';
6 | import QueryHeader from './QueryHeader';
7 | import Tag from './Tag';
8 | import TicketList from './TicketList';
9 |
10 | import './Query.css';
11 |
12 | export default class Query extends React.PureComponent {
13 | constructor( props ) {
14 | super( props );
15 |
16 | this.milestoneComponent = ({ className, name }) => {
17 | const nextParams = {
18 | ...this.props.params,
19 | milestone: name,
20 | };
21 | const search = '?' + qs.stringify( nextParams );
22 | return
23 |
24 | { name }
25 | ;
26 | };
27 | this.labelComponent = ({ name }) => {
28 | const nextParams = {
29 | ...this.props.params,
30 | keywords: '~' + name,
31 | };
32 | const search = '?' + qs.stringify( nextParams );
33 | return
34 |
35 | ;
36 | };
37 | }
38 |
39 | render() {
40 | const { loading, params, tickets, onNext, onPrevious, onUpdateQuery } = this.props;
41 |
42 | const page = params.page ? parseInt( params.page, 10 ) : 1;
43 |
44 | const boardLink = {
45 | pathname: '/board',
46 | search: '?' + qs.stringify( params ),
47 | };
48 |
49 | return
50 |
Query
51 |
55 |
56 |
57 | Switch to card view
58 |
59 |
60 |
61 | { loading ? (
62 |
63 | ) : (
64 | tickets.length === 0 ? (
65 | page > 1 ? (
66 |
67 | No results, you might be out of pages.
68 | onUpdateQuery({ page: 1 }) }
70 | type="button"
71 | >Try page 1?
72 | ) : (
73 |
No results for your query.
74 | )
75 | ) : (
76 |
81 | )
82 | ) }
83 |
84 |
85 | onPrevious() }
88 | type="button"
89 | >
90 | Previous
91 |
92 | { page }
93 | onNext() }
96 | type="button"
97 | >
98 | Next
99 |
100 |
101 |
102 |
103 | { ' ' }
104 | Unfortunately, Trac doesn't support pagination, so I can't tell you how many pages are left!
105 |
106 |
107 |
;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/QueryHeader.css:
--------------------------------------------------------------------------------
1 | .QueryHeader {
2 | background: #e5f5fa;
3 | color: #32373c;
4 | border: 1px solid #bfe7f3;
5 | display: flex;
6 | padding: 0.7em;
7 |
8 | position: sticky;
9 | top: 0;
10 | z-index: 1;
11 | }
12 |
13 | .QueryHeader a {
14 | color: inherit;
15 | }
16 |
17 | .QueryHeader-actions {
18 | flex-grow: 1;
19 | }
20 | .QueryHeader-actions > * {
21 | margin-right: 2em;
22 | }
23 |
24 | .QueryHeader-actions .dashicons {
25 | margin-right: 0.4em;
26 | }
27 |
28 | .QueryHeader-actions .Button {
29 | margin-top: -4px;
30 | margin-bottom: -4px;
31 | }
32 |
33 | .QueryHeader > nav > ul {
34 | list-style: none;
35 | padding: 0;
36 | margin: 0;
37 | display: flex;
38 | }
39 |
40 | .QueryHeader-filter > li {
41 | margin-left: 2em;
42 | }
43 |
44 | .QueryHeader-drop-arrow {
45 | font-size: 0.6em;
46 | vertical-align: middle;
47 | opacity: 0.7;
48 | }
--------------------------------------------------------------------------------
/src/components/QueryHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import DropSelect from './DropSelect';
4 | import LabelSelect from '../containers/selectors/LabelSelect';
5 | import MilestoneSelect from '../containers/selectors/MilestoneSelect';
6 |
7 | import './QueryHeader.css';
8 |
9 | const SORT_OPTIONS = [
10 | {
11 | id: 'new',
12 | title: 'Newest',
13 | value: {
14 | order: 'time',
15 | desc: '1',
16 | },
17 | },
18 | {
19 | id: 'old',
20 | title: 'Oldest',
21 | value: {
22 | order: 'time',
23 | desc: '0',
24 | },
25 | },
26 | {
27 | id: 'new-update',
28 | title: 'Most recently updated',
29 | value: {
30 | order: 'changetime',
31 | desc: '1',
32 | },
33 | },
34 | {
35 | id: 'old-update',
36 | title: 'Least recently updated',
37 | value: {
38 | order: 'changetime',
39 | desc: '0',
40 | },
41 | },
42 | ];
43 |
44 | const Label = ({ text }) => { text } ▼ ;
45 |
46 | export default class QueryHeader extends React.PureComponent {
47 | render() {
48 | const { children, params, onUpdateQuery } = this.props;
49 |
50 | const currentOrder = params.order || 'time';
51 | const currentOrderDesc = params.desc || '1';
52 |
53 | const matchedSort = SORT_OPTIONS.find( opt => {
54 | return currentOrder === opt.value.order && currentOrderDesc === opt.value.desc;
55 | });
56 | const currentSort = matchedSort ? matchedSort.id : '';
57 |
58 | return
59 |
60 | { children }
61 |
62 |
63 |
64 |
65 | }
67 | selected={ params.keywords }
68 | onSelect={ value => onUpdateQuery( value ) }
69 | />
70 |
71 |
72 |
73 | }
75 | selected={ params.milestone }
76 | onSelect={ milestone => onUpdateQuery({ milestone }) }
77 | />
78 |
79 |
80 |
81 | }
83 | items={ SORT_OPTIONS }
84 | selected={ currentSort }
85 | onSelect={ value => onUpdateQuery( value ) }
86 | />
87 |
88 |
89 |
90 |
;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/SlackMention.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Avatar from './Avatar'
4 | import FormattedText from './FormattedText';
5 | import TimelineEvent from './TimelineEvent';
6 | import slack_svg_url from '../slack.svg';
7 |
8 | export default class SlackMention extends React.Component {
9 | render() {
10 | const { text } = this.props;
11 | const icon =
18 | return
19 |
20 | ;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Spinner.css:
--------------------------------------------------------------------------------
1 | .Spinner {
2 | animation: Spinner-spin infinite 2s linear;
3 | margin-right: 0.5rem;
4 | }
5 |
6 | @keyframes Spinner-spin {
7 | from { transform: rotate(0deg); }
8 | to { transform: rotate(360deg); }
9 | }
--------------------------------------------------------------------------------
/src/components/Spinner.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './Spinner.css';
4 |
5 | export default () => (
6 |
9 | );
10 |
--------------------------------------------------------------------------------
/src/components/Tag.css:
--------------------------------------------------------------------------------
1 | .Tag {
2 | font-size: 0.8em;
3 | display: inline-block;
4 | padding: 0.15em 0.4em;
5 | margin-top: -0.15em;
6 | margin-right: 0.15em;
7 | color: #fff;
8 | border-radius: 2px;
9 | }
10 |
11 | .Tag.color-red {
12 | background: #dc3232;
13 | }
14 | .Tag.color-orange {
15 | background: #f56e28;
16 | }
17 | .Tag.color-yellow {
18 | background: #ffb900;
19 | color: #32373c;
20 | }
21 | .Tag.color-green {
22 | background: #46b450;
23 | }
24 | .Tag.color-blue {
25 | background: #00a0d2;
26 | }
27 | .Tag.color-purple {
28 | background: #826eb4;
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/Tag.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import stringHash from 'string-hash';
3 |
4 | import './Tag.css';
5 |
6 | window.hasher = stringHash;
7 |
8 | const COLORS = [
9 | 'red',
10 | 'orange',
11 | 'yellow',
12 | 'green',
13 | 'blue',
14 | 'purple',
15 | ];
16 |
17 | const getClassName = name => {
18 | const hash = stringHash( name );
19 | const color = COLORS[ hash % COLORS.length ];
20 |
21 | return `Tag color-${ color }`;
22 | }
23 |
24 | export default ({ icon, name }) => { icon || null }{ name } ;
25 |
--------------------------------------------------------------------------------
/src/components/Ticket.css:
--------------------------------------------------------------------------------
1 | .Ticket-main {
2 | display: flex;
3 | }
4 | .Ticket-timeline {
5 | flex-grow: 1;
6 | width: 80%;
7 | }
8 |
9 | .Ticket-header {
10 | margin-bottom: 1rem;
11 | padding-bottom: 1rem;
12 | border-bottom: 1px solid #e5f5fa;
13 | }
14 |
15 | .Ticket-main > .TicketStatus {
16 | width: 18%;
17 | margin-left: 2%;
18 | flex-shrink: 0;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Ticket.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Comment from './Comment';
4 | import CommentContent from './CommentContent';
5 | import CommentMeta from './CommentMeta';
6 | import Loading from './Loading';
7 | import TicketChanges from './TicketChanges';
8 | import TicketState from './TicketState';
9 | import TicketStatus from './TicketStatus';
10 | import TicketUpdate from './TicketUpdate';
11 | import Time from './Time';
12 |
13 | import './Ticket.css';
14 |
15 | export default class Ticket extends React.PureComponent {
16 | render() {
17 | const { id, time_created, time_changed, attributes, changes } = this.props;
18 |
19 | const commentCount = changes ? changes.filter( change => change[2] === 'comment' ).length : 0;
20 |
21 | const summary = attributes ? attributes.summary : 'Loading...';
22 |
23 | return
24 |
25 |
#{ id }: { summary }
26 | { attributes ?
27 |
28 |
29 | { attributes.reporter } opened this issue
30 | • { commentCount } comments
31 | •
32 |
33 |
34 | Open on Trac
35 |
36 |
37 | : null }
38 |
39 |
40 |
41 | { attributes ?
42 |
43 |
50 |
54 |
55 | : }
56 |
57 | { changes ?
58 |
63 | : }
64 |
65 | this.props.onComment( text ) }
69 | />
70 |
71 |
72 |
73 |
;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/TicketChanges.css:
--------------------------------------------------------------------------------
1 | .TicketChanges-attachment {
2 | margin-top: -0.5em;
3 | }
4 |
5 | .TicketChanges-attachment-desc {
6 | font-weight: bold;
7 | margin-right: 0.5rem;
8 | color: #32373c;
9 | }
10 |
11 | .TicketChanges-attachment a:hover .TicketChanges-attachment-desc {
12 | color: inherit;
13 | }
14 |
15 | .TicketChanges-attachment-uploading {
16 | margin-left: 1rem;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/TicketChanges.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import Comment from './Comment';
5 | import CommentContent from './CommentContent';
6 | import CommentMeta from './CommentMeta';
7 | import SlackMention from './SlackMention';
8 | import Spinner from './Spinner';
9 | import Tag from './Tag';
10 | import TicketState from './TicketState';
11 | import Time from './Time';
12 | import Timeline from './Timeline';
13 | import TimelineEvent from './TimelineEvent';
14 | import UserLink from './UserLink';
15 |
16 | import './TicketChanges.css';
17 |
18 | const parseChanges = changes => {
19 | let pending = [];
20 |
21 | return changes.reduce( ( next, current ) => {
22 | const [ datetime, author, field, oldval, newval, permanent ] = current;
23 | if ( field.indexOf( '_comment' ) === 0 ) {
24 | pending.push({
25 | author,
26 | number: field.substring( '_comment'.length ),
27 | text: oldval,
28 | edit_ts: newval,
29 | });
30 | return next;
31 | }
32 | const change = { datetime, author, field, oldval, newval, permanent };
33 | if ( field === 'comment' && pending.length > 0 ) {
34 | change.edits = pending;
35 | pending = [];
36 | }
37 |
38 | next.push( change );
39 | return next;
40 | }, [] );
41 | };
42 |
43 | export default class TicketChanges extends React.PureComponent {
44 | getChange( change ) {
45 | const { attachments, ticket } = this.props;
46 | const { datetime, author, field, oldval, newval, permanent } = change;
47 |
48 | const key = datetime + field;
49 | switch ( field ) {
50 | case 'comment':
51 | if ( ! permanent || newval.length <= 0 ) {
52 | return null;
53 | }
54 |
55 | if ( author === 'slackbot' || author === 'ircbot' ) {
56 | return ;
57 | }
58 |
59 | // Replies have an ID like `11.12`, so only take the last part.
60 | const number = oldval === '??' ? 0 : parseInt( oldval.split( '.' ).pop(), 10 );
61 | const pending = oldval === '??';
62 |
63 | return
64 |
65 |
73 |
77 |
78 | ;
79 |
80 | case 'attachment': {
81 | const patch = newval;
82 | const icon = ;
83 | let description = attachments && patch in attachments && attachments[ patch ].description;
84 | let pullLink = null;
85 | if ( description && description.indexOf( 'https://github.com/WordPress/wordpress-develop/pull/' ) >= 0 ) {
86 | pullLink = description.match( /(https:\/\/github.com\/WordPress\/wordpress-develop\/pull\/\d+)/i )[ 1 ];
87 | description = description.replace(
88 | /\(From (https:\/\/github.com\/WordPress\/wordpress-develop\/pull\/\d+)\)/,
89 | ''
90 | );
91 | }
92 | return
93 |
94 |
95 | { ' uploaded a patch ' }
96 |
97 |
98 |
99 |
100 | { description ?
101 |
102 | { description }
103 |
104 | : null }
105 | { patch }
106 |
107 | { ( attachments && patch in attachments && attachments[ patch ].isUploading ) ?
108 |
109 |
110 | Uploading to Trac…
111 |
112 | : null}
113 | { pullLink ?
114 |
115 |
116 | Open pull request
117 | { ' ' }
118 |
119 |
120 |
121 | : null }
122 |
123 | ;
124 | }
125 |
126 | case 'focuses':
127 | case 'keywords': {
128 | const icon = field === 'focuses' ?
129 | :
130 | ;
131 |
132 | const [ oldTags, newTags ] = [ oldval, newval ].map( set => {
133 | return set
134 | .trim()
135 | .split( ' ' )
136 | .map( t => t.trim() )
137 | .filter( t => t.length > 0 );
138 | });
139 |
140 | const added = newTags.filter( tag => oldTags.indexOf( tag ) === -1 );
141 | const removed = oldTags.filter( tag => newTags.indexOf( tag ) === -1 );
142 |
143 | let addText = null;
144 | let removeText = null;
145 | let tagger = tags => tags.map( tag => );
146 |
147 | if ( added.length > 0 ) {
148 | addText = added { tagger( added ) } ;
149 | }
150 | if ( removed.length > 0 ) {
151 | removeText = removed { tagger( removed ) } ;
152 | }
153 |
154 | return
155 |
156 | { ' ' }
157 | { ( addText && removeText ) ?
158 | { addText } and { removeText }
159 | : ( addText || removeText ) }
160 | { ' ' + field + ' ' }
161 |
162 |
163 | }
164 |
165 | case 'owner': {
166 | const icon = ;
167 |
168 | let text;
169 | switch ( true ) {
170 | case ! oldval && newval === author:
171 | text = self-assigned this ;
172 | break;
173 |
174 | case oldval === author && ! newval:
175 | text = removed their assignment ;
176 | break;
177 |
178 | case ! newval && oldval:
179 | text = was unassigned by ;
180 | break;
181 |
182 | case newval && ! oldval:
183 | text = was assigned by ;
184 | break;
185 |
186 | default:
187 | text = assigned and unassigned ;
188 | break;
189 | }
190 |
191 | return
192 | { text }
193 | ;
194 | }
195 |
196 | case 'resolution': {
197 | if ( ! newval ) {
198 | // Handled by status.
199 | return null;
200 | }
201 |
202 | let icon = ;
203 |
204 | return
211 | closed this as
212 | { ' ' }
213 | { newval }
214 | { ' ' }
215 |
216 | ;
217 | }
218 |
219 | case 'status': {
220 | if ( newval === 'closed' ) {
221 | // Handled by resolution.
222 | return null;
223 | }
224 |
225 | let icon = ;
226 |
227 | return
233 |
234 | { ' changed status from ' }
235 |
236 | { ' to ' }
237 |
238 | { ' ' }
239 |
240 | ;
241 | }
242 |
243 | default: {
244 | if ( field === '_comment0' ) {
245 | console.log( change );
246 | }
247 |
248 | let action;
249 | if ( ! oldval && newval ) {
250 | action = added { newval } { field } ;
251 | } else if ( ! newval && oldval ) {
252 | action = removed { oldval } { field } ;
253 | } else {
254 | action = changed { field } from { oldval } to { newval } ;
255 | }
256 |
257 | let icon;
258 | switch ( field ) {
259 | case 'milestone':
260 | icon = ;
261 | break;
262 |
263 | default:
264 | icon = ;
265 | break;
266 | }
267 |
268 | return
269 | { action }
270 | ;
271 | }
272 | }
273 | }
274 |
275 | render() {
276 | const { changes } = this.props;
277 |
278 | const elements = parseChanges( changes )
279 | .map( change => this.getChange( change ) )
280 | .filter( change => !!change );
281 |
282 | return
283 | { elements }
284 |
285 | }
286 | }
287 |
--------------------------------------------------------------------------------
/src/components/TicketList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ListTable from './ListTable';
4 | import TicketListItem from './TicketListItem';
5 |
6 | export default class TicketList extends React.PureComponent {
7 | render() {
8 | const { tickets, ...params } = this.props;
9 |
10 | return
11 | { tickets.map( ticket => ) }
16 |
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/components/TicketListItem.css:
--------------------------------------------------------------------------------
1 | .TicketListItem > .col-type {
2 | width: 30px;
3 | flex-grow: 0;
4 | flex-shrink: 0;
5 | color: #f9a87e;
6 | }
7 |
8 | .TicketListItem > .col-type.type-bug {
9 | color: #ea8484;
10 | }
11 | .TicketListItem > .col-type.type-enhancement {
12 | color: #90d296;
13 | }
14 |
15 | .TicketListItem > .col-main small {
16 | font-size: 0.85em;
17 | color: #72777C;
18 | }
19 |
20 | .TicketListItem-detail-title-block {
21 | display: flex;
22 | }
23 |
24 | .TicketListItem-detail-tags {
25 | margin-left: 0.5em;
26 | flex-shrink: 0;
27 | }
28 |
29 | .TicketListItem-detail-milestone {
30 | color: inherit;
31 | }
32 | a.TicketListItem-detail-milestone:hover {
33 | color: #00a0d2;
34 | }
35 |
36 | .TicketListItem > .col-extra {
37 | display: flex;
38 | font-size: 0.8em;
39 | }
40 |
41 | .TicketListItem > .col-extra > * {
42 | display: flex;
43 | width: 20px;
44 | margin-left: 0.5em;
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/TicketListItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import Avatar from './Avatar';
5 | import ListTableItem from './ListTableItem';
6 | import Tag from './Tag';
7 | import Time from './Time';
8 | import { getKeywords, getTicketType } from '../lib/workflow';
9 |
10 | import './TicketListItem.css';
11 |
12 | export default class TicketListItem extends React.PureComponent {
13 | render() {
14 | const { ticket } = this.props;
15 | const Milestone = this.props.milestoneComponent;
16 | const Label = this.props.labelComponent;
17 |
18 | const keywords = ticket.attributes.keywords;
19 | const hasPatch = keywords.indexOf( 'has-patch' ) >= 0;
20 | const needsTesting = keywords.indexOf( 'needs-testing' ) >= 0;
21 | let milestone = ticket.attributes.milestone;
22 | if ( milestone === 'Awaiting Review' ) {
23 | milestone = null;
24 | }
25 |
26 | const type = getTicketType( ticket );
27 |
28 | return
29 |
30 | { type === 'bug' ?
31 |
32 | : type === 'enhancement' ?
33 |
34 | :
35 |
36 | }
37 |
38 |
39 |
40 |
41 |
42 | { ticket.attributes.summary }
43 |
44 |
45 | { ticket.attributes.keywords ?
46 |
47 | { getKeywords( ticket ).map( tag => ) }
48 |
49 | : null }
50 |
51 |
52 |
53 | #{ ticket.id }
54 | { ' opened ' }
55 |
56 | { ' by ' }
57 | @{ ticket.attributes.reporter }
58 |
59 | { milestone ?
60 |
64 | : null }
65 |
66 |
67 |
68 |
69 | { ticket.attributes.owner ?
70 |
74 | : null }
75 |
76 |
77 | { hasPatch ?
78 |
79 | : needsTesting ?
80 |
81 | :
82 |
83 | }
84 |
85 |
86 | ;
87 | }
88 | }
89 |
90 | TicketListItem.defaultProps = {
91 | milestoneComponent: ({ className, name }) => {
92 | return
93 |
94 | { name }
95 | ;
96 | },
97 | labelComponent: Tag,
98 | };
99 |
--------------------------------------------------------------------------------
/src/components/TicketState.css:
--------------------------------------------------------------------------------
1 | .TicketState {
2 | border-radius: 2px;
3 | background: #82878c;
4 | color: #fff;
5 | padding: 0.15em 0.4em;
6 | margin-right: 0.5em;
7 | }
8 | .TicketState.state-open {
9 | background: #46b450;
10 | }
11 | .TicketState.state-closed {
12 | background: #dc3232;
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/TicketState.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './TicketState.css';
4 |
5 | const TicketState = ({ state }) => {
6 | switch ( state ) {
7 | case 'accepted':
8 | case 'assigned':
9 | case 'new':
10 | case 'reopened':
11 | case 'reviewing':
12 | return { state } ;
13 |
14 | case 'closed':
15 | return { state } ;
16 |
17 | default:
18 | return { state } ;
19 | }
20 | };
21 |
22 | export default TicketState;
23 |
--------------------------------------------------------------------------------
/src/components/TicketStatus.css:
--------------------------------------------------------------------------------
1 | .TicketStatus {
2 | color: #72777C;
3 | font-size: 0.9em;
4 | }
5 |
6 | .TicketStatus > div {
7 | padding-bottom: 1rem;
8 | margin-bottom: 1em;
9 | border-bottom: 1px solid #e5f5fa;
10 | }
11 |
12 | .TicketStatus > div > h4 {
13 | margin-top: 0;
14 | margin-bottom: 0.5em;
15 | }
16 |
17 | .TicketStatus .Tag {
18 | font-size: 1em;
19 | }
20 |
21 | .TicketStatus-keywords .Tag {
22 | display: block;
23 | margin-bottom: 0.5em;
24 | }
25 | .TicketStatus-components-list > * {
26 | display: flex;
27 | margin-bottom: 0.5em;
28 | }
29 | .TicketStatus-components-list .dashicons {
30 | width: 1.7rem;
31 | }
32 | .TicketStatus-components-list a {
33 | display: flex;
34 | flex-grow: 1;
35 | }
36 | .TicketStatus-components-list .Tag {
37 | display: inline-block;
38 | flex-grow: 1;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/TicketStatus.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import Tag from './Tag';
5 |
6 | import './TicketStatus.css';
7 |
8 | export default class TicketStatus extends React.PureComponent {
9 | render() {
10 | const { attributes } = this.props;
11 |
12 | if ( ! attributes ) {
13 | return ;
14 | }
15 |
16 | const focuses = attributes.focuses ? attributes.focuses.split( ',' ).map( f => f.trim() ) : [];
17 |
18 | return
19 |
20 |
Owner
21 |
{ attributes.owner || 'No owner' }
22 |
23 |
24 |
Keywords
25 |
{ attributes.keywords ?
26 | attributes.keywords.split( ' ' ).map( tag => )
27 | : 'No keywords' }
28 |
29 |
30 |
Component & Focuses
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | { focuses.map( focus =>
40 |
41 |
42 |
43 |
44 | ) }
45 |
46 |
47 |
48 |
Milestone
49 |
{ attributes.milestone || 'No milestone' }
50 |
51 |
52 |
Version
53 |
{ attributes.version || 'No version' }
54 |
55 | ;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/TicketUpdate.css:
--------------------------------------------------------------------------------
1 | .TicketUpdate {
2 | position: relative;
3 | }
4 |
5 | .TicketUpdate:before {
6 | position: absolute;
7 | top: 0.6rem;
8 | left: 0;
9 | right: 0;
10 |
11 | display: block;
12 | content: " ";
13 |
14 | border-top: 3px dashed #bfe7f3;
15 | }
16 |
17 | .TicketUpdate > .TimelineEvent:first-child {
18 | /*padding-top: calc( 0.75rem + 1rem );*/
19 | padding-top: 1.75rem;
20 | }
--------------------------------------------------------------------------------
/src/components/TicketUpdate.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 |
4 | import CommentEditor from './CommentEditor';
5 | import Timeline from './Timeline';
6 | import TimelineEvent from './TimelineEvent';
7 |
8 | import './TicketUpdate.css';
9 |
10 | class TicketUpdate extends React.PureComponent {
11 | render() {
12 | const { ticket, uploader, user, onComment } = this.props;
13 | const upload_icon = ;
14 | return
15 | { uploader ?
16 |
17 | { uploader }
18 |
19 | : null }
20 |
21 | onComment( text ) }
25 | />
26 |
27 | ;
28 | }
29 | }
30 |
31 | export default connect(
32 | ({ user }) => ({ user })
33 | )( TicketUpdate );
34 |
--------------------------------------------------------------------------------
/src/components/Time.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import React from 'react';
3 |
4 | export default props => {
5 | let date;
6 | if ( props.date ) {
7 | date = moment( props.date );
8 | } else {
9 | date = moment.unix( props.timestamp );
10 | }
11 |
12 | return ;
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/Timeline.css:
--------------------------------------------------------------------------------
1 | .Timeline {
2 | list-style-type: none;
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Timeline.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './Timeline.css';
4 |
5 | export default ({ children, className }) => (
6 |
9 | );
10 |
--------------------------------------------------------------------------------
/src/components/TimelineEvent.css:
--------------------------------------------------------------------------------
1 | .TimelineEvent {
2 | padding-top: 0.75rem;
3 | padding-bottom: 0.75rem;
4 | position: relative;
5 | }
6 |
7 | .TimelineEvent:last-of-type {
8 | padding-bottom: 0;
9 | }
10 |
11 | .TimelineEvent.compact {
12 | padding-left: calc( 48px + 1rem + 2rem );
13 | color: #555d66;
14 | }
15 | .TimelineEvent:before {
16 | content: " ";
17 | display: block;
18 | border-left: 3px solid #bfe7f3;
19 | position: absolute;
20 | top: 0;
21 | bottom: 0;
22 | left: calc( 48px + 1rem + 0.5rem );
23 | z-index: -1;
24 | width: 40px;
25 | }
26 |
27 | .TimelineEvent-icon {
28 | position: absolute;
29 | left: calc( 48px + 0.5rem );
30 | margin-top: -0.35rem;
31 | width: 2rem;
32 | height: 2rem;
33 | border-radius: 1rem;
34 | background: #bfe7f3;
35 | display: flex;
36 | justify-content: center;
37 | align-items: center;
38 | }
39 | .TimelineEvent.workflow .TimelineEvent-icon {
40 | background: #46b450;
41 | color: #fff;
42 | }
43 | .TimelineEvent.closed .TimelineEvent-icon {
44 | background: #dc3232;
45 | }
46 |
47 | .TimelineEvent-icon .dashicons {
48 | height: 16px;
49 | width: 16px;
50 | font-size: 16px;
51 | }
52 |
53 | .TimelineEvent.compact .UserLink {
54 | color: inherit;
55 | font-weight: bold;
56 | }
57 |
58 | .TimelineEvent.compact .UserLink:hover {
59 | color: #00a0d2;
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/TimelineEvent.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | import './TimelineEvent.css';
5 |
6 | export default class TimelineEvent extends React.PureComponent {
7 | render() {
8 | const { children, closed, compact, icon, workflow, ...attrs } = this.props;
9 |
10 | const className = [
11 | 'TimelineEvent',
12 | closed ? 'closed' : null,
13 | compact ? 'compact' : null,
14 | workflow ? 'workflow' : null,
15 | ].filter( c => !! c ).join( ' ' );
16 | return
17 | { icon ?
18 | { icon }
19 | : null }
20 | { children }
21 | ;
22 | }
23 | }
24 | TimelineEvent.propTypes = {
25 | closed: PropTypes.bool,
26 | compact: PropTypes.bool,
27 | workflow: PropTypes.bool,
28 | };
29 | TimelineEvent.defaultProps = {
30 | closed: false,
31 | compact: false,
32 | workflow: false,
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/UserLink.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | export default ({ user }) => (
5 |
6 | @{ user }
7 |
8 | );
9 |
--------------------------------------------------------------------------------
/src/containers/Attachment.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 |
4 | import AttachmentComponent from '../components/Attachment';
5 | import Trac from '../lib/trac';
6 |
7 | class Attachment extends React.PureComponent {
8 | constructor( props ) {
9 | super( props );
10 |
11 | this.state = {
12 | data: null,
13 | };
14 |
15 | this.api = new Trac( props.user );
16 | }
17 |
18 | componentDidMount() {
19 | const { id, ticket } = this.props;
20 |
21 | this.api.call( 'ticket.getAttachment', [ ticket, id ] )
22 | .then( data => this.setState({ data }) );
23 | }
24 |
25 | render() {
26 | const { id, ticket } = this.props;
27 | const { data } = this.state;
28 |
29 | return ;
35 | }
36 | }
37 | export default connect(
38 | ({ user }) => ({ user })
39 | )( Attachment );
40 |
--------------------------------------------------------------------------------
/src/containers/AttachmentUpload.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 |
4 | import { push_attachment, push_ticket_change, update_prs } from '../actions';
5 | import AttachmentUploadComponent from '../components/AttachmentUpload';
6 |
7 | class AttachmentUpload extends React.PureComponent {
8 | componentDidMount() {
9 | const { dispatch, prs } = this.props;
10 | if ( ! prs.state ) {
11 | dispatch( update_prs() );
12 | }
13 | }
14 |
15 | onUpload( upload ) {
16 | const { dispatch, ticket, user } = this.props;
17 | const { data, description, filename } = upload;
18 |
19 | const id = parseInt( ticket.id, 10 );
20 |
21 | const parameters = [
22 | // int ticket
23 | id,
24 |
25 | // string filename
26 | filename,
27 |
28 | // string description
29 | description,
30 |
31 | // Binary data
32 | data,
33 |
34 | // boolean replace=True
35 | false,
36 | ];
37 | const types = {
38 | // Binary data
39 | 3: 'base64',
40 | };
41 |
42 | // Optimistically render.
43 | const tempDate = new Date();
44 | const change = [
45 | // timestamp
46 | tempDate,
47 |
48 | // author
49 | user.username,
50 |
51 | // field
52 | 'attachment',
53 |
54 | // oldval
55 | '',
56 |
57 | // newval (filename)
58 | filename,
59 |
60 | // permanent
61 | true,
62 | ];
63 | const tempAttachment = {
64 | id: filename,
65 | description,
66 | size: 0,
67 | timestamp: tempDate,
68 | author: user.username,
69 | isUploading: true,
70 | };
71 | dispatch( push_ticket_change( id, change ) );
72 | dispatch( push_attachment( id, tempAttachment ) );
73 |
74 | // And finally, save.
75 | this.api.call( 'ticket.putAttachment', parameters, types )
76 | .then( () => {
77 | // Reload changes and attachments.
78 | this.loadTicketAndChanges( id, 'attachments' );
79 | });
80 | }
81 |
82 | render() {
83 | const { dispatch, prs, ticket } = this.props;
84 |
85 | if ( ! ticket ) {
86 | return null;
87 | }
88 |
89 | return dispatch( update_prs() ) }
93 | onUpload={ upload => this.onUpload( upload ) }
94 | />;
95 | }
96 | }
97 |
98 | export default connect(
99 | ({ prs, user }) => ({ prs, user })
100 | )( AttachmentUpload );
101 |
--------------------------------------------------------------------------------
/src/containers/Board.js:
--------------------------------------------------------------------------------
1 | import qs from 'query-string';
2 | import React from 'react';
3 | import DocumentTitle from 'react-document-title';
4 | import { connect } from 'react-redux';
5 | import { withRouter } from 'react-router-dom';
6 |
7 | import { set_query_results, set_ticket_data } from '../actions';
8 | import BoardComponent from '../components/Board';
9 | import Trac from '../lib/trac';
10 | import { parseTicketResponse } from '../lib/workflow';
11 |
12 | class Board extends React.PureComponent {
13 | constructor( props ) {
14 | super( props );
15 |
16 | this.state = {
17 | loading: true,
18 | };
19 |
20 | this.api = new Trac( props.user );
21 | }
22 |
23 | componentWillMount() {
24 | this.fetchResults( this.props.params );
25 | }
26 |
27 | componentWillReceiveProps( nextProps ) {
28 | if ( this.props.user !== nextProps.user ) {
29 | this.api = new Trac( nextProps.user );
30 | }
31 | if ( this.props.params !== nextProps.params ) {
32 | this.fetchResults( nextProps.params );
33 | }
34 | }
35 |
36 | fetchResults( params ) {
37 | const { dispatch, tickets } = this.props;
38 |
39 | this.setState({ loading: true });
40 |
41 | // Build our query using Trac's query language:
42 | // https://trac.edgewall.org/wiki/TracQuery#QueryLanguage
43 | const query = {
44 | status: '!closed',
45 |
46 | // Ordering
47 | order: 'time',
48 | desc: '1',
49 |
50 | ...params,
51 | };
52 | const queries = Object.keys( query ).map( key => {
53 | const value = query[ key ];
54 | const comparison = '=';
55 | return key + comparison + value;
56 | });
57 |
58 | // Force no pagination
59 | queries.push( 'max=0' );
60 |
61 | // Build ticket query.
62 | const qstr = queries.join( '&' );
63 |
64 | // Query for ticket IDs...
65 | this.api.call( 'ticket.query', [ qstr ] )
66 | .then( ids => {
67 | if ( 'faultCode' in ids ) {
68 | console.log( ids );
69 | return;
70 | }
71 |
72 | dispatch( set_query_results( ids ) );
73 |
74 | // Then fetch details for all of them.
75 | const missing = ids.filter( id => !tickets[ id ] );
76 | const calls = missing.map( id => ({ methodName: 'ticket.get', params: [ id ] }) );
77 | if ( calls.length > 0 ) {
78 | this.api.call( 'system.multicall', [ calls ] ).then( data => {
79 | const tickets = data.map( item => parseTicketResponse( item[0] ) );
80 |
81 | tickets.forEach( item => {
82 | dispatch( set_ticket_data( item.id, item ) );
83 | });
84 | this.setState({ loading: false });
85 | });
86 | } else {
87 | this.setState({ loading: false });
88 | }
89 | });
90 | }
91 |
92 | onSetParams( params ) {
93 | const { history, location } = this.props;
94 | const nextLocation = {
95 | ...location,
96 | search: '?' + qs.stringify( params ),
97 | };
98 | history.push( nextLocation );
99 | }
100 |
101 | render() {
102 | const { query, params, tickets } = this.props;
103 | const { loading } = this.state;
104 |
105 | const selectedTickets = loading ? [] : query.results.map( id => tickets[ id ] );
106 |
107 | return
108 | this.onSetParams( nextParams ) }
114 | onUpdateQuery={ nextParams => this.onSetParams( { ...params, ...nextParams } ) }
115 | />
116 | ;
117 | }
118 | }
119 |
120 | export default withRouter( connect(
121 | ({ query, tickets, user }) => ({ query, tickets, user })
122 | )( Board ) );
123 |
--------------------------------------------------------------------------------
/src/containers/Query.js:
--------------------------------------------------------------------------------
1 | import qs from 'query-string';
2 | import React from 'react';
3 | import DocumentTitle from 'react-document-title';
4 | import { connect } from 'react-redux';
5 | import { withRouter } from 'react-router-dom';
6 |
7 | import { set_query_results, set_ticket_data } from '../actions';
8 | import QueryComponent from '../components/Query';
9 | import Trac from '../lib/trac';
10 | import { parseTicketResponse } from '../lib/workflow';
11 |
12 | class Query extends React.PureComponent {
13 | constructor( props ) {
14 | super( props );
15 |
16 | this.state = {
17 | loading: true,
18 | };
19 |
20 | this.api = new Trac( props.user );
21 | }
22 |
23 | componentWillMount() {
24 | this.fetchResults( this.props.params );
25 | }
26 |
27 | componentWillReceiveProps( nextProps ) {
28 | if ( this.props.user !== nextProps.user ) {
29 | this.api = new Trac( nextProps.user );
30 | }
31 | if ( this.props.params !== nextProps.params ) {
32 | this.fetchResults( nextProps.params );
33 | }
34 | }
35 |
36 | fetchResults( params ) {
37 | const { dispatch, tickets } = this.props;
38 |
39 | this.setState({ loading: true });
40 |
41 | // Build our query using Trac's query language:
42 | // https://trac.edgewall.org/wiki/TracQuery#QueryLanguage
43 | const query = {
44 | status: '!closed',
45 |
46 | // Ordering
47 | order: 'time',
48 | desc: '1',
49 |
50 | // Pagination
51 | page: '1',
52 |
53 | ...params,
54 | };
55 | const queries = Object.keys( query ).map( key => {
56 | const value = query[ key ];
57 | const comparison = '=';
58 | return key + comparison + value;
59 | });
60 |
61 | // Force pagination
62 | queries.push( 'max=25' );
63 |
64 | // Build ticket query.
65 | const qstr = queries.join( '&' );
66 |
67 | // Query for ticket IDs...
68 | this.api.call( 'ticket.query', [ qstr ] )
69 | .then( ids => {
70 | if ( 'faultCode' in ids ) {
71 | if ( ids.faultString.indexOf( 'beyond the number of pages' ) > 0 ) {
72 | dispatch( set_query_results( [] ) );
73 | this.setState({ loading: false });
74 | return;
75 | }
76 |
77 | console.log( ids );
78 | return;
79 | }
80 |
81 | dispatch( set_query_results( ids ) );
82 |
83 | // Then fetch details for all of them.
84 | const missing = ids.filter( id => !tickets[ id ] );
85 | const calls = missing.map( id => ({ methodName: 'ticket.get', params: [ id ] }) );
86 | if ( calls.length > 0 ) {
87 | this.api.call( 'system.multicall', [ calls ] ).then( data => {
88 | const tickets = data.map( item => parseTicketResponse( item[0] ) );
89 |
90 | tickets.forEach( item => {
91 | dispatch( set_ticket_data( item.id, item ) );
92 | });
93 | this.setState({ loading: false });
94 | });
95 | } else {
96 | this.setState({ loading: false });
97 | }
98 | });
99 | }
100 |
101 | onNext() {
102 | const { params } = this.props;
103 |
104 | const currentPage = params.page ? parseInt( params.page, 10 ) : 1;
105 |
106 | const nextParams = {
107 | ...params,
108 | page: currentPage + 1,
109 | }
110 | this.onSetParams( nextParams );
111 | }
112 |
113 | onPrevious() {
114 | const { params } = this.props;
115 |
116 | const currentPage = params.page ? parseInt( params.page, 10 ) : 1;
117 |
118 | const nextParams = {
119 | ...params,
120 | page: currentPage - 1,
121 | }
122 | this.onSetParams( nextParams );
123 | }
124 |
125 | onSetParams( params ) {
126 | const { history, location } = this.props;
127 | const nextLocation = {
128 | ...location,
129 | search: '?' + qs.stringify( params ),
130 | };
131 | history.push( nextLocation );
132 | }
133 |
134 | render() {
135 | const { query, params, tickets } = this.props;
136 | const { loading } = this.state;
137 |
138 | const selectedTickets = loading ? [] : query.results.map( id => tickets[ id ] );
139 |
140 | return
141 | this.onNext() }
147 | onPrevious={ () => this.onPrevious() }
148 | onSetQuery={ nextParams => this.onSetParams( nextParams ) }
149 | onUpdateQuery={ nextParams => this.onSetParams( { ...params, ...nextParams } ) }
150 | />
151 | ;
152 | }
153 | }
154 |
155 | export default withRouter( connect(
156 | ({ query, tickets, user }) => ({ query, tickets, user })
157 | )( Query ) );
158 |
--------------------------------------------------------------------------------
/src/containers/Summary.css:
--------------------------------------------------------------------------------
1 | .Summary-components {
2 | font-size: 1.2em;
3 | line-height: 1.7;
4 | }
5 | .Summary-components .Tag {
6 | margin-right: 0.5em;
7 | }
--------------------------------------------------------------------------------
/src/containers/Summary.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DocumentTitle from 'react-document-title';
3 | import { connect } from 'react-redux';
4 | import { Link } from 'react-router-dom';
5 |
6 | import { set_components } from '../actions';
7 | import ErrorComponent from '../components/Error';
8 | import Loading from '../components/Loading';
9 | import Tag from '../components/Tag';
10 | import Trac from '../lib/trac';
11 |
12 | import './Summary.css';
13 |
14 | class Summary extends React.PureComponent {
15 | constructor( props ) {
16 | super( props );
17 |
18 | this.state = {
19 | error: null,
20 | loading: true,
21 | components: null,
22 | };
23 |
24 | this.api = new Trac( props.user );
25 | }
26 |
27 | componentWillReceiveProps( nextProps ) {
28 | if ( this.props.user !== nextProps.user ) {
29 | this.api = new Trac( nextProps.user );
30 | }
31 | }
32 |
33 | componentDidMount() {
34 | if ( ! this.api ) {
35 | console.log( 'wtf' );
36 | console.log( this );
37 | return;
38 | }
39 |
40 | this.loadComponents();
41 | }
42 |
43 | loadComponents() {
44 | const { dispatch } = this.props;
45 | this.api.call( 'ticket.component.getAll' ).then( names => {
46 | const components = {};
47 | names.forEach( name => {
48 | components[ name ] = {
49 | name,
50 | details: null,
51 | };
52 | });
53 |
54 | dispatch( set_components( components ) );
55 | this.setState({ loading: false });
56 | }).catch( e => {
57 | this.setState({ loading: false, error: e });
58 | });
59 | }
60 |
61 | onTryAgain( e ) {
62 | e.preventDefault();
63 |
64 | this.setState({ loading: true, error: null });
65 | this.loadComponents();
66 | }
67 |
68 | render() {
69 | const { components } = this.props;
70 | const { error, loading } = this.state;
71 |
72 | if ( loading ) {
73 | return ;
74 | }
75 |
76 | if ( error ) {
77 | return
81 | An error occurred while trying to load components from Trac.
82 | If you're running this locally, ensure your Trac proxy is running.
83 | this.onTryAgain( e ) }
85 | type="button"
86 | >Try again
87 | ;
88 | }
89 |
90 | return
91 |
92 |
Browse by Component
93 |
94 | { Object.values( components ).map( component =>
95 |
96 |
97 |
98 | {/*
99 | {
101 | e.preventDefault();
102 | this.onSelect( component );
103 | }}
104 | >
105 | { component.name }
106 |
107 | */}
108 | )}
109 |
110 |
111 | ;
112 | }
113 | }
114 |
115 | export default connect(
116 | ({ components, user }) => ({ components, user })
117 | )( Summary );
118 |
--------------------------------------------------------------------------------
/src/containers/Ticket.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DocumentTitle from 'react-document-title';
3 | import { connect } from 'react-redux';
4 |
5 | import AttachmentUpload from './AttachmentUpload';
6 | import { push_ticket_change, set_ticket_attachments, set_ticket_changes, set_ticket_data } from '../actions';
7 | import Loading from '../components/Loading';
8 | import TicketComponent from '../components/Ticket';
9 | import Trac from '../lib/trac';
10 | import { parseAttachmentList, parseTicketResponse } from '../lib/workflow';
11 |
12 | class Ticket extends React.PureComponent {
13 | constructor( props ) {
14 | super( props );
15 |
16 | this.state = {
17 | data: null,
18 | ticketChanges: null,
19 | };
20 |
21 | this.api = new Trac( props.user );
22 | }
23 |
24 | componentWillMount() {
25 | this.maybeLoadTicket( this.props );
26 | }
27 |
28 | componentWillReceiveProps( nextProps ) {
29 | if ( this.props.user !== nextProps.user ) {
30 | this.api = new Trac( nextProps.user );
31 | }
32 |
33 | this.maybeLoadTicket( nextProps );
34 | }
35 |
36 | maybeLoadTicket( props ) {
37 | const { data, id } = props;
38 | if ( this.loader ) {
39 | return;
40 | }
41 |
42 | // Load the full data in, if we're missing everything.
43 | if ( ! data ) {
44 | this.loadTicketAndChanges( id );
45 | return;
46 | }
47 |
48 | // Otherwise, load the bits we need.
49 | if ( ! data.attributes ) {
50 | this.loadTicket( id );
51 | }
52 | if ( ! data.changes ) {
53 | this.loadChanges( id );
54 | }
55 | }
56 |
57 | loadTicket( id ) {
58 | this.loadTicketAndChanges( id, 'ticket' );
59 | }
60 |
61 | loadChanges( id ) {
62 | this.loadTicketAndChanges( id, 'changes' );
63 | }
64 |
65 | loadTicketAndChanges( id, force = null ) {
66 | const { data, dispatch } = this.props;
67 | const calls = [];
68 | const handlers = [];
69 |
70 | if ( ! data || force === 'ticket' ) {
71 | calls.push({
72 | methodName: 'ticket.get',
73 | params: [ id ]
74 | });
75 | handlers.push( data => dispatch( set_ticket_data( id, parseTicketResponse( data ) ) ) );
76 | }
77 | if ( ! data || ! data.changes || force === 'changes' ) {
78 | calls.push({
79 | methodName: 'ticket.changeLog',
80 | params: [ id, 0 ]
81 | });
82 | handlers.push( data => dispatch( set_ticket_changes( id, data ) ) );
83 | }
84 | if ( ! data || ! data.attachments || force === 'attachments' ) {
85 | calls.push({
86 | methodName: 'ticket.listAttachments',
87 | params: [ id ]
88 | });
89 | handlers.push( data => dispatch( set_ticket_attachments( id, parseAttachmentList( data ) ) ) );
90 | }
91 | if ( calls.length < 1 ) {
92 | return;
93 | }
94 |
95 | this.loader = this.api.call( 'system.multicall', [ calls ] )
96 | .then( results => {
97 | results.forEach( ( data, index ) => {
98 | const callback = handlers[ index ];
99 | callback( data[0] );
100 | });
101 | this.loader = null;
102 | });
103 | }
104 |
105 | onComment( text ) {
106 | const { data, dispatch, user } = this.props;
107 |
108 | const parameters = [
109 | // int id
110 | data.id,
111 |
112 | // string comment
113 | text,
114 |
115 | // struct attributes={}
116 | {
117 | // Don't alter attributes ("leave as new", e.g.)
118 | 'action': 'leave',
119 |
120 | // Hack.
121 | '_ts': data.attributes._ts,
122 | 'view_time': `${data.attributes._ts}`
123 | },
124 | // boolean notify=False
125 | true,
126 | // string author=""
127 | // DateTime when=None
128 | ];
129 |
130 | // Optimistically render.
131 | const change = [
132 | // timestamp
133 | new Date(),
134 |
135 | // author
136 | user.username,
137 |
138 | // field
139 | 'comment',
140 |
141 | // oldval (number)
142 | '??',
143 |
144 | // newval (text)
145 | text,
146 |
147 | // permanent
148 | true,
149 | ];
150 | dispatch( push_ticket_change( data.id, change ) );
151 |
152 | // And finally, save.
153 | this.api.call( 'ticket.update', parameters )
154 | .then( data => {
155 | // Update data from response...
156 | dispatch( set_ticket_data( data.id, parseTicketResponse( data ) ) );
157 |
158 | // ...and reload the changes.
159 | this.loadChanges( this.props.id );
160 | });
161 | }
162 |
163 | render() {
164 | const { data, id } = this.props;
165 |
166 | if ( ! data ) {
167 | return ;
168 | }
169 |
170 | const title = data.attributes ?
171 | `#${ id }: ${ data.attributes.summary }` :
172 | `#${ id }: Loading...`;
173 |
174 | const uploader = this.loadTicketAndChanges( id, 'attachments' ) }
177 | />;
178 |
179 | return
180 | this.onComment( text ) }
185 | />
186 | ;
187 | }
188 | }
189 |
190 | export default connect(
191 | (state, props) => {
192 | return {
193 | data: state.tickets[ props.id ] || null,
194 | user: state.user,
195 | };
196 | }
197 | )( Ticket );
198 |
--------------------------------------------------------------------------------
/src/containers/TicketTimeline.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import TicketChanges from '../components/TicketChanges';
5 |
6 | export default class TicketComments extends React.Component {
7 | constructor( props ) {
8 | super( props );
9 |
10 | this.state = {
11 | loading: true,
12 | data: null,
13 | }
14 | }
15 |
16 | componentDidMount() {
17 | const { id, since } = this.props;
18 |
19 | this.api.call( 'ticket.changeLog', [ id, since ] )
20 | .then( data => this.setState({ data, loading: false }) );
21 | }
22 |
23 | render() {
24 | const { data, loading } = this.state;
25 |
26 | if ( loading ) {
27 | return Loading…
;
28 | }
29 |
30 | return ;
33 | }
34 | }
35 | TicketComments.propTypes = {
36 | id: PropTypes.number.isRequired,
37 | since: PropTypes.number,
38 | };
39 | TicketComments.defaultProps = {
40 | since: 0,
41 | };
42 |
--------------------------------------------------------------------------------
/src/containers/selectors/LabelSelect.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import DropSelect from '../../components/DropSelect';
4 | import Tag from '../../components/Tag';
5 |
6 | const CORE_LABELS = {
7 | 'has-patch' : 'Proposed solution attached and ready for review.',
8 | 'needs-patch' : 'Ticket needs a new patch.',
9 | 'needs-refresh' : 'Patch no longer applies cleanly and needs to be updated.',
10 | 'reporter-feedback' : 'Feedback is needed from the reporter.',
11 | 'dev-feedback' : 'Feedback is needed from a core developer.',
12 | '2nd-opinion' : 'A second opinion is desired for the problem or solution.',
13 | 'close' : 'The ticket is a candidate for closure.',
14 | 'needs-testing' : 'Patch has a particular need for testing.',
15 | 'ui-feedback' : 'Feedback is needed from the user interface perspective, generally from the UI team.',
16 | 'ux-feedback' : 'Feedback is needed from the user experience perspective, generally from a UX lead.',
17 | 'has-unit-tests' : 'Proposed solution has unit test coverage.',
18 | 'needs-unit-tests' : 'Ticket has a particular need for unit tests.',
19 | 'needs-docs' : 'Inline documentation is needed.',
20 | 'needs-codex' : 'The Codex needs to be updated or expanded.',
21 | 'has-screenshots' : 'Visual changes are documented with screenshots.',
22 | 'needs-screenshots' : 'Screenshots are needed as a visual change log.',
23 | 'commit' : 'Patch is a suggested commit candidate.',
24 | 'early' : 'Ticket should be addressed early in the next dev cycle.',
25 | 'i18n-change' : 'A string change, used only after string freeze.',
26 | 'good-first-bug': 'This ticket is great for a new contributor to work on, generally because it is easy or well-contained.',
27 | 'fixed-major': 'The commits of this ticket need to be backported.'
28 | };
29 |
30 | const ITEMS = Object.keys( CORE_LABELS ).map( label => {
31 | return {
32 | id: label,
33 | title: ,
34 | value: label,
35 | };
36 | });
37 |
38 | export default ({ label, selected, onSelect }) => {
39 | let keywords;
40 | if ( selected && Array.isArray( selected ) ) {
41 | keywords = selected.map( k => k.replace( '~', '' ) );
42 | } else if ( selected ) {
43 | keywords = [ selected.replace( '~', '' ) ];
44 | } else {
45 | keywords = [];
46 | }
47 |
48 | const onAdd = value => {
49 | if ( value === null ) {
50 | onSelect({
51 | keywords: undefined,
52 | });
53 | return;
54 | }
55 |
56 | // Add to list.
57 | onSelect({
58 | keywords: [ ...keywords, value ].map( v => "~" + v ),
59 | });
60 | };
61 | const onRemove = value => {
62 | const nextKeywords = keywords.filter( v => v !== value );
63 | if ( nextKeywords.length === 0 ) {
64 | onSelect({
65 | keywords: undefined,
66 | });
67 | return;
68 | }
69 |
70 | onSelect({
71 | keywords: nextKeywords.map( v => "~" + v ),
72 | });
73 | };
74 |
75 | return ;
83 | };
84 |
--------------------------------------------------------------------------------
/src/containers/selectors/MilestoneSelect.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 |
4 | import DropSelect from '../../components/DropSelect';
5 | import Trac from '../../lib/trac';
6 |
7 | class MilestoneSelect extends React.PureComponent {
8 | constructor( props ) {
9 | super( props );
10 |
11 | this.state = {
12 | items: [],
13 | loading: true,
14 | };
15 |
16 | this.api = new Trac( props.user );
17 | }
18 |
19 | onLoad() {
20 | this.api.call( 'ticket.milestone.getAll' )
21 | .then( items => this.setState({ items: items.reverse(), loading: false }) );
22 | }
23 |
24 | render() {
25 | const { label, selected } = this.props;
26 | const { loading, items } = this.state;
27 |
28 | return this.props.onSelect( undefined ) }
36 | onLoad={ () => this.onLoad() }
37 | onSelect={ this.props.onSelect }
38 | />;
39 | }
40 | }
41 |
42 | MilestoneSelect.defaultProps = {
43 | label: "Milestones",
44 | };
45 |
46 | export default connect(
47 | ({ user }) => ({ user })
48 | )( MilestoneSelect );
49 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | font-family: "Open Sans", sans-serif;
9 | font-size: 14px;
10 | line-height: 1.4;
11 | }
12 |
13 | .wrapper {
14 | max-width: 960px;
15 | margin: 0 auto;
16 | }
17 |
18 | p {
19 | margin: 0 0 1em;
20 | }
21 | p:last-child {
22 | margin-bottom: 0;
23 | }
24 |
25 | a {
26 | color: #00a0d2;
27 | text-decoration: none;
28 | }
29 |
30 | pre {
31 | background: #fff8e5;
32 | padding: 1rem;
33 | border-radius: 3px;
34 | overflow: scroll;
35 | }
36 |
37 | code {
38 | background: #f5f2f0;
39 | font-size: 0.9em;
40 | padding: 0.2em 0.3em;
41 | border-radius: 3px;
42 | }
43 |
44 | blockquote {
45 | border-left: 2px solid #cbcdce;
46 | margin-left: 0;
47 | padding-left: 1rem;
48 | color: #82878c;
49 | }
50 |
51 | hr {
52 | border: 0;
53 | height: 0.2rem;
54 | background: #cbcdce;
55 | margin: 1rem 0;
56 | }
57 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { applyMiddleware, createStore } from 'redux';
4 | import thunk from 'redux-thunk';
5 | import { Provider } from 'react-redux';
6 |
7 | import App from './App';
8 | import reducer from './reducers';
9 | import registerServiceWorker from './registerServiceWorker';
10 |
11 | import './index.css';
12 |
13 | // Prepare initial state for the store.
14 | const initialState = {
15 | prs: {
16 | state: null,
17 | items: [],
18 | },
19 | query: {
20 | params: {},
21 | results: [],
22 | },
23 | tickets: {},
24 | user: {},
25 | };
26 | const existing = localStorage.getItem( 'trac-auth' );
27 | if ( existing ) {
28 | initialState.user = JSON.parse( existing );
29 | }
30 |
31 | // Prepare middleware.
32 | const middleware = applyMiddleware(
33 | thunk,
34 | );
35 |
36 | // Actually create the store.
37 | const store = createStore( reducer, initialState, middleware );
38 |
39 | // Now, render.
40 | const render = App => {
41 | ReactDOM.render(
42 |
43 |
44 | ,
45 | document.getElementById( 'root' )
46 | );
47 | };
48 | render( App );
49 |
50 | // Register hot-reloading.
51 | if ( module.hot ) {
52 | module.hot.accept( './App', () => {
53 | import( './App' )
54 | .then( NextApp => render( NextApp.default ) );
55 | });
56 | }
57 |
58 | registerServiceWorker();
59 |
--------------------------------------------------------------------------------
/src/lib/text-formatter.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router-dom';
3 |
4 | import CodeBlock from '../components/CodeBlock';
5 | import UserLink from '../components/UserLink';
6 |
7 | const PARA_MARKER = '__PARA_MARKER__';
8 |
9 | const formatLeaf = ( leaf, context ) => {
10 | // Special-case.
11 | if ( typeof leaf === 'string' ) {
12 | return leaf;
13 | }
14 |
15 | switch ( leaf.type ) {
16 | // Headings.
17 | case 'heading': {
18 | const { level, text } = leaf;
19 | const el = 'h' + level;
20 |
21 | return React.createElement( el, {}, [ text ] );
22 | }
23 |
24 | // Lists.
25 | case 'unordered-list': {
26 | return { leaf.children.map( item => { formatTree( item, context ) } ) } ;
27 | }
28 | case 'ordered-list': {
29 | return
30 | { leaf.children.map( item => { formatTree( item, context ) } ) }
31 | ;
32 | }
33 |
34 | case 'citation':
35 | return { formatTree( leaf.children, context ) } ;
36 |
37 | // Code.
38 | case 'preformatted':
39 | return { leaf.text } ;
40 |
41 | // Basic text formatting.
42 | case 'bold':
43 | return { formatTree( leaf.children, context, true ) } ;
44 |
45 | case 'italic':
46 | return { formatTree( leaf.children, context, true ) } ;
47 |
48 | case 'strike':
49 | return { formatTree( leaf.children, context, true ) } ;
50 |
51 | case 'code':
52 | return { leaf.text }
;
53 |
54 | case 'horizontal-line':
55 | return ;
56 |
57 | case 'break':
58 | return ;
59 |
60 | // Links.
61 | case 'link':
62 | return { formatTree( leaf.children || leaf.text, context, true ) } ;
63 |
64 | // Cross-referencing.
65 | case 'ticket': {
66 | const { id } = leaf;
67 |
68 | return { leaf.text };
69 | }
70 | case 'mention': {
71 | const id = leaf.text;
72 |
73 | return ;
74 | }
75 | case 'commit': {
76 | const { id, text } = leaf;
77 |
78 | return { text } ;
79 | }
80 | case 'attachment': {
81 | const { id, text } = leaf;
82 | const ticket = leaf.ticket || context.ticket || 0;
83 |
84 | return { text };
85 | }
86 |
87 | // Image macro.
88 | case 'image': {
89 | const { url } = leaf;
90 | return ;
91 | }
92 |
93 | // Paragraph separator.
94 | case 'para':
95 | return PARA_MARKER;
96 |
97 | // Default text.
98 | case 'text':
99 | return leaf.text;
100 |
101 | default:
102 | return Unknown type { leaf.type }
103 | }
104 | };
105 |
106 | const formatTree = ( tree, context = {}, inline = false ) => {
107 | // Special-case.
108 | if ( typeof tree === 'string' ) {
109 | return tree;
110 | }
111 |
112 | const leaves = tree.map( leaf => formatLeaf( leaf, context ) );
113 | if ( inline ) {
114 | return leaves;
115 | }
116 |
117 | // Chunk based on paragraph markers.
118 | const chunks = [];
119 | let current = [];
120 | while ( leaves.length > 0 ) {
121 | const leaf = leaves.pop();
122 | if ( leaf !== PARA_MARKER ) {
123 | current.push( leaf );
124 | continue;
125 | }
126 |
127 | chunks.push( current.reverse() );
128 | current = [];
129 | }
130 | if ( current.length ) {
131 | chunks.push( current.reverse() );
132 | }
133 |
134 | // Convert to paragraphs.
135 | return chunks.reverse().map( chunk => {
136 | if ( chunk.length === 1 && typeof chunk !== 'string' ) {
137 | // return chunk;
138 | }
139 |
140 | return { chunk }
;
141 | });
142 | };
143 |
144 | export default formatTree;
145 |
--------------------------------------------------------------------------------
/src/lib/text-parser.js:
--------------------------------------------------------------------------------
1 | import Parser from 'simple-text-parser';
2 |
3 | const configure = parser => {
4 | const simple = type => (_, text) => ({ type, text });
5 | const recursed = type => (_, text) => ({ type, text, children: parser.toTree( text ) });
6 |
7 | // Code.
8 | parser.addRule( /\{\{\{\s*(?:#!(\w+\n))?((.|\n)+?)\}\}\}/, (_, language, text) => {
9 | if ( text.indexOf( '\n' ) >= 0 ) {
10 | return { type: 'preformatted', text, language };
11 | }
12 |
13 | return { type: 'code', text };
14 | });
15 |
16 | // Headings.
17 | parser.addRule( /^(={1,6})(.+?)(=*)$/gm, (_, heading, text) => {
18 | return {
19 | type: 'heading',
20 | level: heading.length,
21 | text
22 | };
23 | });
24 |
25 | // Lists.
26 | const parseList = ( matcher, text ) => {
27 | let lines = text.split( '\n' );
28 |
29 | // If the first item has leading spaces, strip them from each line first.
30 | const leadingSpace = lines[0].match( /^\s+/ );
31 | if ( leadingSpace ) {
32 | lines = lines.map( line => {
33 | if ( line.substr( 0, leadingSpace[0].length ) === leadingSpace[0] ) {
34 | return line.substr( leadingSpace[0].length );
35 | }
36 | return line;
37 | });
38 | }
39 |
40 | // Split each list item into a separate "block".
41 | let stripChars = 0;
42 | const blocks = lines.reduce( ( blocks, line ) => {
43 | const match = line.match( matcher );
44 | if ( match ) {
45 | stripChars = match[1].length;
46 | blocks.push( [ line.substring( stripChars ) ] );
47 | return blocks;
48 | }
49 |
50 | // Strip leading, if it's whitespace.
51 | const stripped = line.substring( 0, stripChars ).trim().length === 0 ? line.substring( stripChars ) : line;
52 | blocks[ blocks.length - 1 ].push( stripped );
53 | return blocks;
54 | }, [] );
55 | return blocks.map( block => block.join( '\n' ) ).map( item => parser.toTree( item + '\n' ) );
56 | };
57 |
58 | parser.addRule( /((^ *[*-] (.+(\n {2}.+)*)(\n|$))+)/m, (_, text, ws) => {
59 | return {
60 | type: 'unordered-list',
61 | text,
62 | children: parseList( /^([*-] )/, text, ws.length ),
63 | }
64 | });
65 | parser.addRule( /(^ *(\d+)\. (.+(\n {2}.+)*(\n|$))+)/m, (_, text, number) => {
66 | return {
67 | type: 'ordered-list',
68 | text,
69 | number,
70 | children: parseList( /^(\d+\. )/, text ),
71 | }
72 | });
73 |
74 | // Quotes.
75 | parser.addRule( /((^>+.+\n)+)/m, (_, text) => {
76 | // Strip leading > and maybe one space from each line.
77 | const content = text.split( '\n' )
78 | .map( line => line.substr( 1 ) )
79 | .map( line => line[0] === ' ' ? line.substr( 1 ) : line );
80 |
81 | return {
82 | type: 'citation',
83 | text: text,
84 | children: parser.toTree( content.join( '\n' ) ),
85 | };
86 | });
87 |
88 | // Basic text formatting.
89 | parser.addRule( /'''(.+?)'''/g, recursed( 'bold' ) );
90 | parser.addRule( /\*\*(.+?)\*\*/g, recursed( 'bold' ) );
91 | parser.addRule( /''(.+?)''/g, recursed( 'italic' ) );
92 | parser.addRule( /~~(.+?)~~/g, recursed( 'strike' ) );
93 | parser.addRule( /`(.+?)`/g, simple( 'code' ) );
94 | parser.addRule( /-{4,}/g, simple( 'horizontal-line' ) );
95 | parser.addRule( /(\[\[br\]\]|\\\\)/gi, simple( 'break' ) );
96 |
97 | // WikiCreole-style italic. Also matches URLs, so skip for now.
98 | // parser.addRule( /\/\/(.+?)\/\//g, simple( 'italic' ) );
99 |
100 | // Links.
101 | parser.addRule( /\[(\S+)(?: ([^\]]+))?]/, (_, url, text) => {
102 | // Match internal links first.
103 | if ( url.match( /^\d+$/ ) ) {
104 | return {
105 | type: 'commit',
106 | text: text || `[${ url }]`,
107 | id: url,
108 | }
109 | }
110 | let matches = url.match( /^ticket:(\d+)$/i );
111 | if ( matches ) {
112 | return {
113 | type: 'ticket',
114 | text: text || matches[1],
115 | id: matches[1],
116 | };
117 | }
118 | matches = url.match( /^changeset:(\d+)$/i );
119 | if ( matches ) {
120 | return {
121 | type: 'commit',
122 | text: text || matches[1],
123 | id: matches[1],
124 | };
125 | }
126 | matches = url.match( /^attachment:(([^:]+)(?::ticket:(\d+))?)$/i );
127 | if ( matches ) {
128 | return {
129 | type: 'attachment',
130 | text: text || matches[1],
131 | id: matches[2],
132 | ticket: matches[3] || null,
133 | };
134 | }
135 | matches = url.match( /Image\((https?:\/\/[^)]+)\)/ );
136 | if ( matches ) {
137 | return {
138 | type: 'link',
139 | url: matches[1],
140 | text: parser.toTree( matches[0] ),
141 | };
142 | }
143 |
144 | return {
145 | type: 'link',
146 | url,
147 | text: parser.toTree( text || url ),
148 | };
149 | });
150 |
151 | // Image macro.
152 | parser.addRule( /Image\((https?:\/\/[^)]+)\)/, (_, url) => ({ type: 'image', url }) );
153 |
154 | // Bare URLs.
155 | parser.addRule( /\b(https?:\/\/\S+)\b/, (_, url) => {
156 | return {
157 | type: 'link',
158 | url,
159 | text: url
160 | }
161 | });
162 |
163 | // Cross-referencing.
164 | parser.addRule( /\battachment:([^:]+)(?::ticket:(\d+))?\b/i, (text, id, ticket) => ({ type: 'attachment', text, id, ticket }) );
165 | parser.addRule( /(ticket:|#)(\d+)/gm, (text, _, id) => ({ type: 'ticket', text, id }) );
166 | parser.addRule( /@(.+?)\b/g, simple( 'mention' ) );
167 | parser.addRule( /\b(?:r|changeset:)(\d+)\b/g, (text, id) => ({ type: 'commit', text, id }) );
168 |
169 | // Paragraph separator.
170 | parser.addRule( /\n\n/g, () => ({ type: 'para' }) );
171 | };
172 |
173 | export default text => {
174 | const parser = new Parser();
175 |
176 | configure( parser );
177 |
178 | return parser.toTree( text.replace( /\r\n/g, '\n' ) );
179 | };
180 |
--------------------------------------------------------------------------------
/src/lib/trac.js:
--------------------------------------------------------------------------------
1 | const PROXY_URL = '/proxy';
2 |
3 | export default class TracAPI {
4 | constructor( user ) {
5 | this.user = user;
6 | }
7 |
8 | getHeaders() {
9 | const auth_string = [ this.user.username, this.user.password ].join( ':' );
10 |
11 | return {
12 | Authorization: `Basic ${ btoa( auth_string ) }`,
13 | }
14 | }
15 |
16 | call( method, parameters = [], types = null ) {
17 | const options = {
18 | method: 'POST',
19 | headers: {
20 | 'Content-Type': 'application/json',
21 | ...this.getHeaders(),
22 | },
23 | credentials: 'include',
24 | body: JSON.stringify( {
25 | method,
26 | parameters,
27 | types,
28 | } ),
29 | };
30 |
31 | return fetch( PROXY_URL, options )
32 | .then( resp => resp.json() );
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/lib/workflow.js:
--------------------------------------------------------------------------------
1 | export const getKeywords = ticket => {
2 | const keywords = ticket.attributes.keywords.split( ' ' );
3 |
4 | // Remove workflow keywords.
5 | return keywords.filter( keyword => {
6 | if ( keyword === 'has-patch' || keyword === 'needs-testing' ) {
7 | return false;
8 | }
9 |
10 | return true;
11 | });
12 | };
13 |
14 | export const getTicketType = ticket => {
15 | switch ( ticket.attributes.type ) {
16 | case 'defect (bug)':
17 | return 'bug';
18 |
19 | case 'enhancement':
20 | case 'feature request':
21 | return 'enhancement';
22 |
23 | case 'task (blessed)':
24 | return 'task';
25 |
26 | default:
27 | return ticket.attributes.type;
28 | }
29 | };
30 |
31 | export const parseTicketResponse = data => {
32 | const [ id, time_created, time_changed, attributes ] = data;
33 | return {
34 | id,
35 | time_created,
36 | time_changed,
37 | attributes
38 | };
39 | };
40 |
41 | export const parseAttachmentResult = data => {
42 | const [ id, description, size, datetime, author ] = data;
43 | return {
44 | id,
45 | description,
46 | size,
47 | datetime,
48 | author
49 | };
50 | };
51 |
52 | export const parseAttachmentList = data => {
53 | return data
54 | .map( att => parseAttachmentResult( att ) )
55 | .reduce( (result, item) => {
56 | result[ item.id ] = item;
57 | return result;
58 | }, {} );
59 | }
60 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/reducers/components.js:
--------------------------------------------------------------------------------
1 | import { SET_COMPONENTS } from '../actions';
2 |
3 | export default function components( state = {}, action ) {
4 | switch ( action.type ) {
5 | case SET_COMPONENTS:
6 | return { ...action.components };
7 |
8 | default:
9 | return state;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import components from './components';
4 | import prs from './prs';
5 | import query from './query';
6 | import tickets from './tickets';
7 | import user from './user';
8 |
9 | export default combineReducers({
10 | components,
11 | prs,
12 | query,
13 | tickets,
14 | user,
15 | });
16 |
--------------------------------------------------------------------------------
/src/reducers/prs.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_PRS, REQUEST_PRS } from '../actions';
2 |
3 | export default function prs( state = {}, action ) {
4 | switch ( action.type ) {
5 | case REQUEST_PRS:
6 | return {
7 | ...state,
8 | status: 'loading',
9 | };
10 |
11 | case RECEIVE_PRS:
12 | return {
13 | ...state,
14 | status: 'loaded',
15 | items: [ ...action.prs ]
16 | };
17 |
18 | default:
19 | return state;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/reducers/query.js:
--------------------------------------------------------------------------------
1 | import { SET_QUERY_PARAMS, SET_QUERY_RESULTS } from '../actions';
2 |
3 | export default function query( state = {}, action ) {
4 | switch ( action.type ) {
5 | case SET_QUERY_PARAMS:
6 | return {
7 | ...state,
8 | params: action.params,
9 | results: [],
10 | };
11 |
12 | case SET_QUERY_RESULTS:
13 | return {
14 | ...state,
15 | results: action.results,
16 | };
17 |
18 | default:
19 | return state;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/reducers/tickets.js:
--------------------------------------------------------------------------------
1 | import { PUSH_ATTACHMENT, PUSH_TICKET_CHANGE, SET_TICKET_ATTACHMENTS, SET_TICKET_CHANGES, SET_TICKET_DATA } from '../actions';
2 |
3 | export default function tickets( state = {}, action ) {
4 | switch ( action.type ) {
5 | case PUSH_ATTACHMENT: {
6 | const ticket = state[ action.id ] || {};
7 | const attachments = ticket.attachments || {};
8 |
9 | return {
10 | ...state,
11 | [ action.id ]: {
12 | ...ticket,
13 | attachments: {
14 | ...attachments,
15 | [ action.attachment.id ]: action.attachment,
16 | },
17 | },
18 | };
19 | }
20 |
21 | case PUSH_TICKET_CHANGE: {
22 | const ticket = state[ action.id ] || {};
23 | const changes = ticket.changes || [];
24 |
25 | return {
26 | ...state,
27 | [ action.id ]: {
28 | ...ticket,
29 | changes: [
30 | ...changes,
31 | action.change,
32 | ],
33 | },
34 | };
35 | }
36 |
37 | case SET_TICKET_ATTACHMENTS: {
38 | const ticket = state[ action.id ] || {};
39 | return {
40 | ...state,
41 | [ action.id ]: {
42 | ...ticket,
43 | attachments: action.attachments,
44 | }
45 | }
46 | }
47 |
48 | case SET_TICKET_CHANGES: {
49 | const ticket = state[ action.id ] || {};
50 | return {
51 | ...state,
52 | [ action.id ]: {
53 | ...ticket,
54 | changes: action.changes,
55 | },
56 | };
57 | }
58 |
59 | case SET_TICKET_DATA: {
60 | const ticket = state[ action.id ] || {};
61 | return {
62 | ...state,
63 | [ action.id ]: {
64 | ...ticket,
65 | ...action.data
66 | },
67 | };
68 | }
69 |
70 | default:
71 | return state;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/reducers/user.js:
--------------------------------------------------------------------------------
1 | import { RESET_USER_CREDENTIALS, SET_USER_CREDENTIALS } from '../actions';
2 |
3 | export default function user( state = {}, action ) {
4 | switch ( action.type ) {
5 | case RESET_USER_CREDENTIALS:
6 | return {};
7 |
8 | case SET_USER_CREDENTIALS:
9 | return {
10 | ...state,
11 | ...action.user
12 | };
13 |
14 | default:
15 | return state;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl);
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/slack.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------