Allow issue creation via UI

This commit is contained in:
wpetit 2019-12-05 22:37:09 +01:00
parent d510116c4b
commit a7297e3d12
17 changed files with 310 additions and 51 deletions

View File

@ -2,13 +2,36 @@ import React from 'react';
import { Page } from '../Page'; import { Page } from '../Page';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Board from '@lourenci/react-kanban'; import Board from '@lourenci/react-kanban';
import { fetchIssues } from '../../store/actions/issues';
import { fetchBoards } from '../../store/actions/boards'; import { fetchBoards } from '../../store/actions/boards';
import { createIssue } from '../../store/actions/issues';
import { buildKanboard, moveCard } from '../../store/actions/kanboards'; import { buildKanboard, moveCard } from '../../store/actions/kanboards';
import { Loader } from '../Loader'; import { Loader } from '../Loader';
import { IssueCard } from './IssueCard';
import { Modal } from '../Modal';
export class BoardPage extends React.Component { export class BoardPage extends React.Component {
state = {
newCardModalActive: false,
newCardLaneID: 0,
newCard: {
title: "",
body: "",
project: ""
}
}
constructor(props) {
super(props);
this.renderLaneHeader = this.renderLaneHeader.bind(this);
this.onNewCardClick = this.onNewCardClick.bind(this);
this.onNewCardCloseClick = this.onNewCardCloseClick.bind(this);
this.onNewCardTitleChange = this.onNewCardAttrChange.bind(this, 'title');
this.onNewCardBodyChange = this.onNewCardAttrChange.bind(this, 'body');
this.onNewCardProjectChange = this.onNewCardAttrChange.bind(this, 'project');
this.onNewCardSaveClick = this.onNewCardSaveClick.bind(this);
}
render() { render() {
return ( return (
<Page> <Page>
@ -34,54 +57,99 @@ export class BoardPage extends React.Component {
onCardDragEnd={this.onCardDragEnd.bind(this)}> onCardDragEnd={this.onCardDragEnd.bind(this)}>
{kanboard} {kanboard}
</Board> </Board>
{ this.renderNewCardModal() }
</div> </div>
); );
} }
renderCard(card) { renderNewCardModal() {
const { newCardModalActive, newCardLaneID } = this.state;
const { board } = this.props;
if (!board) return null;
return ( return (
<div className="kanboard-card"> <Modal active={newCardModalActive}>
<div className="box"> <div className="new-card-modal">
<div className="media"> <article className="message">
<div className="message-header">
<p><span>"{board.lanes[newCardLaneID].title}" - Nouveau ticket</span></p>
<button className="delete" aria-label="delete" onClick={this.onNewCardCloseClick}></button>
</div>
<div className="message-body">
<div className="field">
<div className="control">
<input className="input is-medium" type="text"
placeholder="Titre de votre ticket..."
value={this.state.newCard.title}
onChange={this.onNewCardTitleChange} />
</div>
</div>
<div className="field">
<div className="control">
<textarea className="textarea"
placeholder="Description du nouveau ticket..."
value={this.state.newCard.body}
onChange={this.onNewCardBodyChange}
rows="10">
</textarea>
</div>
</div>
<div className="field">
<div className="control is-expanded">
<div className="select is-fullwidth"
value={this.state.newCard.project}
onChange={this.onNewCardProjectChange}>
<select>
{ {
card.issue.assignee ? board.projects.map((p, i) => {
<div class="media-left"> return <option key={`new-card-project-${i}`} value={p}>{p}</option>
<figure class="image is-64x64"> })
<img src={card.issue.assignee.avatar_url} alt="Image" />
<small>{`@${card.issue.assignee.login}`}</small>
</figure>
</div>
: null
} }
<div className="media-content"> </select>
<div className="content"> </div>
<p> </div>
<strong>{`#${card.issue.number}`}</strong>&nbsp; </div>
{ card.issue.milestone ? <small>{`- ${card.issue.milestone.title}`}</small> : null } <div className="field is-grouped is-grouped-right">
<br /> <p className="control">
<span className="is-size-6">{card.issue.title}</span> <a className="button is-light"
</p> onClick={this.onNewCardCloseClick}>
<div className="level is-mobile"> Annuler
<div className="level-left"></div>
<div className="level-right">
<a className="level-item" target="_blank" href={card.issue.url.replace('/api/v1/repos', '')}>
<span className="icon is-small has-text-info">
<i className="fas fa-search" aria-hidden="true"></i>
</span>
</a> </a>
</p>
<p className="control">
<a className="button is-primary"
onClick={this.onNewCardSaveClick}>
Enregistrer
</a>
</p>
</div> </div>
</div> </div>
</article>
</div> </div>
</div> </Modal>
</div> )
</div> }
</div>
); renderCard(card) {
return <IssueCard card={card} />;
} }
renderLaneHeader(lane) { renderLaneHeader(lane) {
return ( return (
<h3 className="kanboard-lane-title is-size-3">{lane.title}</h3> <div className="kanboard-lane">
<div className="level">
<div className="level-left">
<h3 className="level-item is-size-3">{lane.title}</h3>
</div>
<div className="level-right">
<button className="button is-light level-item is-medium"
onClick={this.onNewCardClick.bind(this, lane.id)}>
<span className="icon">
<i className="fas fa-plus" aria-hidden="true"></i>
</span>
</button>
</div>
</div>
</div>
) )
} }
@ -106,6 +174,48 @@ export class BoardPage extends React.Component {
this.requestBuildKanboard(); this.requestBuildKanboard();
} }
onNewCardClick(laneID) {
const { board } = this.props;
this.setState({
newCardModalActive: true,
newCardLaneID: laneID,
newCard: {
title: "",
body: "",
project: board.projects[0],
}
});
}
onNewCardCloseClick() {
this.setState({ newCardModalActive: false });
}
onNewCardAttrChange(attrName, evt) {
const value = evt.target.value;
this.setState(state => {
return {
...state,
newCard: {
...state.newCard,
[attrName]: value,
}
}
})
}
onNewCardSaveClick() {
const { newCard, newCardLaneID } = this.state;
const { board } = this.props;
this.setState({ newCardModalActive: false });
this.props.dispatch(createIssue(
newCard.project,
newCard.title,
newCard.body,
board.lanes[newCardLaneID].issueLabel
));
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.board !== this.props.board) this.requestBuildKanboard(); if (prevProps.board !== this.props.board) this.requestBuildKanboard();
} }

View File

@ -0,0 +1,47 @@
import React from 'react';
export class IssueCard extends React.PureComponent {
render() {
const { card } = this.props;
return (
<div className="kanboard-card">
<div className="box">
<div className="media">
{
card.issue.assignee ?
<div className="media-left">
<figure className="image is-64x64">
<img src={card.issue.assignee.avatar_url} alt="Image" />
</figure>
<small>{`@${card.issue.assignee.login}`}</small>
</div>
: null
}
<div className="media-content">
<div className="content">
<p>
<strong>{`#${card.issue.number}`}</strong>&nbsp;
{ card.issue.milestone ? <small>{`- ${card.issue.milestone.title}`}</small> : null }
<br />
<span className="is-size-6">{card.issue.title}</span>
</p>
</div>
</div>
</div>
<div className="level is-mobile" style={{marginTop:'1rem'}}>
<div className="level-left">
<small className="level-item"><a href="#">{card.project}</a></small>
</div>
<div className="level-right">
<a className="level-item" target="_blank" href={card.issue.url.replace('/api/v1/repos', '')}>
<span className="icon is-small has-text-info">
<i className="fas fa-search" aria-hidden="true"></i>
</span>
</a>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -11,7 +11,7 @@ export class HomePage extends React.Component {
return ( return (
<Page> <Page>
<div className="container is-fluid"> <div className="container is-fluid">
<div className="level"> <div className="level has-margin-top-normal">
<div className="level-left"></div> <div className="level-left"></div>
<div className="level-right"> <div className="level-right">
<div className="buttons"> <div className="buttons">

View File

@ -0,0 +1,20 @@
import React from 'react';
export class Modal extends React.PureComponent {
render() {
const { children, active, showCloseButton, onClose } = this.props;
return (
<div className={`modal ${active ? 'is-active': ''}`}>
<div className="modal-background"></div>
<div className="modal-content">
{children}
</div>
{
showCloseButton ?
<button onClick={onClose} className="modal-close is-large" aria-label="close"></button> :
null
}
</div>
);
}
}

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
export class Navbar extends React.Component { export class Navbar extends React.PureComponent {
render() { render() {
return ( return (
<nav className="navbar" role="navigation" aria-label="main navigation" style={{marginBottom: '1.5rem'}}> <nav className="navbar" role="navigation" aria-label="main navigation">
<div className="container is-fluid"> <div className="container is-fluid">
<div className="navbar-brand"> <div className="navbar-brand">
<a className="navbar-item" href="#/"> <a className="navbar-item" href="#/">

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Navbar } from './Navbar'; import { Navbar } from './Navbar';
export class Page extends React.Component { export class Page extends React.PureComponent {
render() { render() {
return ( return (
<React.Fragment> <React.Fragment>

View File

@ -7,6 +7,10 @@ html, body {
height: 100%; height: 100%;
} }
.has-margin-top-normal {
margin-top: $size-normal;
}
#app { #app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -34,7 +34,13 @@
margin-bottom: $size-small; margin-bottom: $size-small;
} }
.kanboard-lane-title { .kanboard-lane {
margin-bottom: $size-small; margin-bottom: $size-small;
} }
} }
.modal-content {
justify-content: center;
align-items: center;
width: 50% !important;
}

View File

@ -16,7 +16,7 @@
.lds-ripple div { .lds-ripple div {
position: absolute; position: absolute;
border: 4px solid $info; border: 4px solid $grey;
opacity: 1; opacity: 1;
border-radius: 50%; border-radius: 50%;
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;

View File

@ -21,3 +21,11 @@ export const REMOVE_LABEL_FAILURE = "REMOVE_LABEL_FAILURE";
export function removeLabel(project, issueNumber, label) { export function removeLabel(project, issueNumber, label) {
return { type: REMOVE_LABEL_REQUEST, project, issueNumber, label }; return { type: REMOVE_LABEL_REQUEST, project, issueNumber, label };
} }
export const CREATE_ISSUE_REQUEST = "CREATE_ISSUE_REQUEST";
export const CREATE_ISSUE_SUCCESS = "CREATE_ISSUE_SUCCESS";
export const CREATE_ISSUE_FAILURE = "CREATE_ISSUE_FAILURE";
export function createIssue(project, title, body, label) {
return { type: CREATE_ISSUE_REQUEST, project, title, body, label };
};

View File

@ -1,4 +1,4 @@
import { FETCH_ISSUES_SUCCESS } from "../actions/issues"; import { FETCH_ISSUES_SUCCESS, CREATE_ISSUE_SUCCESS } from "../actions/issues";
const defaultState = { const defaultState = {
byProject: {} byProject: {}
@ -8,6 +8,8 @@ export function issuesReducer(state = defaultState, action) {
switch(action.type) { switch(action.type) {
case FETCH_ISSUES_SUCCESS: case FETCH_ISSUES_SUCCESS:
return handleFetchIssuesSuccess(state, action); return handleFetchIssuesSuccess(state, action);
case CREATE_ISSUE_SUCCESS:
return handleCreateIssueSuccess(state, action);
default: default:
return state; return state;
} }
@ -25,3 +27,16 @@ function handleFetchIssuesSuccess(state, action) {
} }
} }
} }
function handleCreateIssueSuccess(state, action) {
return {
...state,
byProject: {
...state.byProject,
[action.project]: [
...state.byProject[action.project],
action.issue
]
}
}
}

View File

@ -1,4 +1,5 @@
import { BUILD_KANBOARD_SUCCESS, MOVE_CARD } from "../actions/kanboards"; import { BUILD_KANBOARD_SUCCESS, MOVE_CARD } from "../actions/kanboards";
import { CREATE_ISSUE_SUCCESS } from "../actions/issues";
export const defaultState = { export const defaultState = {
byID: {}, byID: {},

View File

@ -2,8 +2,6 @@ import { put, call } from 'redux-saga/effects';
import { FETCH_BOARDS_SUCCESS, SAVE_BOARD_SUCCESS, SAVE_BOARD_FAILURE, FETCH_BOARDS_FAILURE } from '../actions/boards'; import { FETCH_BOARDS_SUCCESS, SAVE_BOARD_SUCCESS, SAVE_BOARD_FAILURE, FETCH_BOARDS_FAILURE } from '../actions/boards';
import { api } from '../../util/api'; import { api } from '../../util/api';
const boardsLocalStorageKey = 'giteakan.boards';
export function* fetchBoardsSaga() { export function* fetchBoardsSaga() {
let boards; let boards;

View File

@ -1,5 +1,5 @@
import { put, call, retry } from 'redux-saga/effects'; import { put, call, retry } from 'redux-saga/effects';
import { FETCH_ISSUES_SUCCESS, FETCH_ISSUES_FAILURE, ADD_LABEL_FAILURE, ADD_LABEL_SUCCESS, REMOVE_LABEL_FAILURE, REMOVE_LABEL_SUCCESS } from '../actions/issues'; import { FETCH_ISSUES_SUCCESS, FETCH_ISSUES_FAILURE, ADD_LABEL_FAILURE, ADD_LABEL_SUCCESS, REMOVE_LABEL_FAILURE, REMOVE_LABEL_SUCCESS, CREATE_ISSUE_FAILURE, CREATE_ISSUE_SUCCESS } from '../actions/issues';
import { gitea } from '../../util/gitea'; import { gitea } from '../../util/gitea';
export function* fetchIssuesSaga(action) { export function* fetchIssuesSaga(action) {
@ -64,5 +64,26 @@ export function* removeLabelSaga(action) {
yield put({ type: REMOVE_LABEL_SUCCESS, project, issueNumber, label }); yield put({ type: REMOVE_LABEL_SUCCESS, project, issueNumber, label });
}
export function* createIssueSaga(action) {
const { project, title, label, body } = action;
const labels = yield call(gitea.fetchProjectLabels.bind(gitea), project);
const giteaLabel = labels.find(l => l.name === label)
if (!giteaLabel) {
yield put({ type: CREATE_ISSUE_FAILURE, error: new Error(`Label "${label}" not found !`) });
return;
}
let issue;
try {
issue = yield call(gitea.createIssue.bind(gitea), project, title, body, giteaLabel.id);
} catch(error) {
yield put({ type: CREATE_ISSUE_FAILURE, error });
return;
}
yield put({ type: CREATE_ISSUE_SUCCESS, project, title, label, body, issue });
} }

View File

@ -1,7 +1,7 @@
import { select, put } from 'redux-saga/effects'; import { select, put } from 'redux-saga/effects';
import { fetchIssues, addLabel, removeLabel } from '../actions/issues'; import { fetchIssues, addLabel, removeLabel } from '../actions/issues';
import { fetchIssuesSaga } from './issues'; import { fetchIssuesSaga } from './issues';
import { BUILD_KANBOARD_SUCCESS } from '../actions/kanboards'; import { BUILD_KANBOARD_SUCCESS, buildKanboard } from '../actions/kanboards';
export function* moveCardSaga(action) { export function* moveCardSaga(action) {
const { const {
@ -30,7 +30,6 @@ export function* moveCardSaga(action) {
} }
export function* buildKanboardSaga(action) { export function* buildKanboardSaga(action) {
const { board } = action; const { board } = action;
let kanboard; let kanboard;
@ -50,7 +49,18 @@ export function* buildKanboardSaga(action) {
} }
yield put({ type: BUILD_KANBOARD_SUCCESS, kanboard }); yield put({ type: BUILD_KANBOARD_SUCCESS, kanboard });
}
export function* refreshKanboardSaga(action) {
const { project } = action;
const boards = yield select(state => state.boards);
const boardValues = Object.values(boards.byID);
for (let b, i = 0; (b = boardValues[i]); i++) {
const hasProject = b.projects.indexOf(project) !== -1;
if (!hasProject) continue;
yield put(buildKanboard(b));
}
} }
function createCards(projects, issues, lane) { function createCards(projects, issues, lane) {

View File

@ -2,14 +2,14 @@ import { all, takeEvery, takeLatest } from 'redux-saga/effects';
import { failuresSaga } from './failure'; import { failuresSaga } from './failure';
import { FETCH_BOARDS_REQUEST, SAVE_BOARD_REQUEST } from '../actions/boards'; import { FETCH_BOARDS_REQUEST, SAVE_BOARD_REQUEST } from '../actions/boards';
import { fetchBoardsSaga, saveBoardSaga } from './boards'; import { fetchBoardsSaga, saveBoardSaga } from './boards';
import { FETCH_ISSUES_REQUEST, ADD_LABEL_REQUEST, REMOVE_LABEL_REQUEST } from '../actions/issues'; import { FETCH_ISSUES_REQUEST, ADD_LABEL_REQUEST, REMOVE_LABEL_REQUEST, CREATE_ISSUE_REQUEST, CREATE_ISSUE_SUCCESS } from '../actions/issues';
import { fetchIssuesSaga, addLabelSaga, removeLabelSaga } from './issues'; import { fetchIssuesSaga, addLabelSaga, removeLabelSaga, createIssueSaga } from './issues';
import { FETCH_PROJECTS_REQUEST } from '../actions/projects'; import { FETCH_PROJECTS_REQUEST } from '../actions/projects';
import { fetchProjectsSaga } from './projects'; import { fetchProjectsSaga } from './projects';
import { LOGOUT } from '../actions/logout'; import { LOGOUT } from '../actions/logout';
import { logoutSaga } from './logout'; import { logoutSaga } from './logout';
import { BUILD_KANBOARD_REQUEST, MOVE_CARD } from '../actions/kanboards'; import { BUILD_KANBOARD_REQUEST, MOVE_CARD } from '../actions/kanboards';
import { buildKanboardSaga, moveCardSaga } from './kanboards'; import { buildKanboardSaga, moveCardSaga, refreshKanboardSaga } from './kanboards';
export function* rootSaga() { export function* rootSaga() {
yield all([ yield all([
@ -22,6 +22,8 @@ export function* rootSaga() {
takeEvery(MOVE_CARD, moveCardSaga), takeEvery(MOVE_CARD, moveCardSaga),
takeEvery(ADD_LABEL_REQUEST, addLabelSaga), takeEvery(ADD_LABEL_REQUEST, addLabelSaga),
takeEvery(REMOVE_LABEL_REQUEST, removeLabelSaga), takeEvery(REMOVE_LABEL_REQUEST, removeLabelSaga),
takeLatest(CREATE_ISSUE_REQUEST, createIssueSaga),
takeLatest(CREATE_ISSUE_SUCCESS, refreshKanboardSaga),
takeLatest(LOGOUT, logoutSaga) takeLatest(LOGOUT, logoutSaga)
]); ]);
} }

View File

@ -52,6 +52,23 @@ export class GiteaClient {
.then(this.assertAuthorization) .then(this.assertAuthorization)
} }
createIssue(project, title, body, labelID) {
return fetch(`/gitea/api/v1/repos/${project}/issues`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
body,
labels: [labelID],
}),
})
.then(this.assertOk)
.then(this.assertAuthorization)
.then(res => res.json())
}
assertOk(res) { assertOk(res) {
if (!res.ok) return Promise.reject(new Error('Request failed')); if (!res.ok) return Promise.reject(new Error('Request failed'));
return res; return res;