Allow issue creation via UI
This commit is contained in:
parent
d510116c4b
commit
a7297e3d12
@ -2,13 +2,36 @@ import React from 'react';
|
||||
import { Page } from '../Page';
|
||||
import { connect } from 'react-redux';
|
||||
import Board from '@lourenci/react-kanban';
|
||||
import { fetchIssues } from '../../store/actions/issues';
|
||||
import { fetchBoards } from '../../store/actions/boards';
|
||||
import { createIssue } from '../../store/actions/issues';
|
||||
import { buildKanboard, moveCard } from '../../store/actions/kanboards';
|
||||
import { Loader } from '../Loader';
|
||||
import { IssueCard } from './IssueCard';
|
||||
import { Modal } from '../Modal';
|
||||
|
||||
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() {
|
||||
return (
|
||||
<Page>
|
||||
@ -34,54 +57,99 @@ export class BoardPage extends React.Component {
|
||||
onCardDragEnd={this.onCardDragEnd.bind(this)}>
|
||||
{kanboard}
|
||||
</Board>
|
||||
{ this.renderNewCardModal() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCard(card) {
|
||||
renderNewCardModal() {
|
||||
const { newCardModalActive, newCardLaneID } = this.state;
|
||||
const { board } = this.props;
|
||||
if (!board) return null;
|
||||
return (
|
||||
<div className="kanboard-card">
|
||||
<div className="box">
|
||||
<div className="media">
|
||||
{
|
||||
card.issue.assignee ?
|
||||
<div class="media-left">
|
||||
<figure class="image is-64x64">
|
||||
<img src={card.issue.assignee.avatar_url} alt="Image" />
|
||||
<small>{`@${card.issue.assignee.login}`}</small>
|
||||
</figure>
|
||||
<Modal active={newCardModalActive}>
|
||||
<div className="new-card-modal">
|
||||
<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>
|
||||
: null
|
||||
}
|
||||
<div className="media-content">
|
||||
<div className="content">
|
||||
<p>
|
||||
<strong>{`#${card.issue.number}`}</strong>
|
||||
{ card.issue.milestone ? <small>{`- ${card.issue.milestone.title}`}</small> : null }
|
||||
<br />
|
||||
<span className="is-size-6">{card.issue.title}</span>
|
||||
</p>
|
||||
<div className="level is-mobile">
|
||||
<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>
|
||||
<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>
|
||||
{
|
||||
board.projects.map((p, i) => {
|
||||
return <option key={`new-card-project-${i}`} value={p}>{p}</option>
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-grouped is-grouped-right">
|
||||
<p className="control">
|
||||
<a className="button is-light"
|
||||
onClick={this.onNewCardCloseClick}>
|
||||
Annuler
|
||||
</a>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-primary"
|
||||
onClick={this.onNewCardSaveClick}>
|
||||
Enregistrer
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
renderCard(card) {
|
||||
return <IssueCard card={card} />;
|
||||
}
|
||||
|
||||
renderLaneHeader(lane) {
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
if (prevProps.board !== this.props.board) this.requestBuildKanboard();
|
||||
}
|
||||
|
47
client/src/components/BoardPage/IssueCard.jsx
Normal file
47
client/src/components/BoardPage/IssueCard.jsx
Normal 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>
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@ export class HomePage extends React.Component {
|
||||
return (
|
||||
<Page>
|
||||
<div className="container is-fluid">
|
||||
<div className="level">
|
||||
<div className="level has-margin-top-normal">
|
||||
<div className="level-left"></div>
|
||||
<div className="level-right">
|
||||
<div className="buttons">
|
||||
|
20
client/src/components/Modal.jsx
Normal file
20
client/src/components/Modal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export class Navbar extends React.Component {
|
||||
export class Navbar extends React.PureComponent {
|
||||
render() {
|
||||
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="navbar-brand">
|
||||
<a className="navbar-item" href="#/">
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Navbar } from './Navbar';
|
||||
|
||||
export class Page extends React.Component {
|
||||
export class Page extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
|
@ -7,6 +7,10 @@ html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.has-margin-top-normal {
|
||||
margin-top: $size-normal;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -34,7 +34,13 @@
|
||||
margin-bottom: $size-small;
|
||||
}
|
||||
|
||||
.kanboard-lane-title {
|
||||
.kanboard-lane {
|
||||
margin-bottom: $size-small;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 50% !important;
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
|
||||
.lds-ripple div {
|
||||
position: absolute;
|
||||
border: 4px solid $info;
|
||||
border: 4px solid $grey;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
|
@ -20,4 +20,12 @@ export const REMOVE_LABEL_FAILURE = "REMOVE_LABEL_FAILURE";
|
||||
|
||||
export function removeLabel(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 };
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import { FETCH_ISSUES_SUCCESS } from "../actions/issues";
|
||||
import { FETCH_ISSUES_SUCCESS, CREATE_ISSUE_SUCCESS } from "../actions/issues";
|
||||
|
||||
const defaultState = {
|
||||
byProject: {}
|
||||
@ -8,6 +8,8 @@ export function issuesReducer(state = defaultState, action) {
|
||||
switch(action.type) {
|
||||
case FETCH_ISSUES_SUCCESS:
|
||||
return handleFetchIssuesSuccess(state, action);
|
||||
case CREATE_ISSUE_SUCCESS:
|
||||
return handleCreateIssueSuccess(state, action);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@ -24,4 +26,17 @@ function handleFetchIssuesSuccess(state, action) {
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateIssueSuccess(state, action) {
|
||||
return {
|
||||
...state,
|
||||
byProject: {
|
||||
...state.byProject,
|
||||
[action.project]: [
|
||||
...state.byProject[action.project],
|
||||
action.issue
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { BUILD_KANBOARD_SUCCESS, MOVE_CARD } from "../actions/kanboards";
|
||||
import { CREATE_ISSUE_SUCCESS } from "../actions/issues";
|
||||
|
||||
export const defaultState = {
|
||||
byID: {},
|
||||
|
@ -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 { api } from '../../util/api';
|
||||
|
||||
const boardsLocalStorageKey = 'giteakan.boards';
|
||||
|
||||
export function* fetchBoardsSaga() {
|
||||
let boards;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
export function* fetchIssuesSaga(action) {
|
||||
@ -64,5 +64,26 @@ export function* removeLabelSaga(action) {
|
||||
|
||||
|
||||
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 });
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { select, put } from 'redux-saga/effects';
|
||||
import { fetchIssues, addLabel, removeLabel } from '../actions/issues';
|
||||
import { fetchIssuesSaga } from './issues';
|
||||
import { BUILD_KANBOARD_SUCCESS } from '../actions/kanboards';
|
||||
import { BUILD_KANBOARD_SUCCESS, buildKanboard } from '../actions/kanboards';
|
||||
|
||||
export function* moveCardSaga(action) {
|
||||
const {
|
||||
@ -30,7 +30,6 @@ export function* moveCardSaga(action) {
|
||||
}
|
||||
|
||||
export function* buildKanboardSaga(action) {
|
||||
|
||||
const { board } = action;
|
||||
|
||||
let kanboard;
|
||||
@ -50,7 +49,18 @@ export function* buildKanboardSaga(action) {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -2,14 +2,14 @@ import { all, takeEvery, takeLatest } from 'redux-saga/effects';
|
||||
import { failuresSaga } from './failure';
|
||||
import { FETCH_BOARDS_REQUEST, SAVE_BOARD_REQUEST } from '../actions/boards';
|
||||
import { fetchBoardsSaga, saveBoardSaga } from './boards';
|
||||
import { FETCH_ISSUES_REQUEST, ADD_LABEL_REQUEST, REMOVE_LABEL_REQUEST } from '../actions/issues';
|
||||
import { fetchIssuesSaga, addLabelSaga, removeLabelSaga } from './issues';
|
||||
import { FETCH_ISSUES_REQUEST, ADD_LABEL_REQUEST, REMOVE_LABEL_REQUEST, CREATE_ISSUE_REQUEST, CREATE_ISSUE_SUCCESS } from '../actions/issues';
|
||||
import { fetchIssuesSaga, addLabelSaga, removeLabelSaga, createIssueSaga } from './issues';
|
||||
import { FETCH_PROJECTS_REQUEST } from '../actions/projects';
|
||||
import { fetchProjectsSaga } from './projects';
|
||||
import { LOGOUT } from '../actions/logout';
|
||||
import { logoutSaga } from './logout';
|
||||
import { BUILD_KANBOARD_REQUEST, MOVE_CARD } from '../actions/kanboards';
|
||||
import { buildKanboardSaga, moveCardSaga } from './kanboards';
|
||||
import { buildKanboardSaga, moveCardSaga, refreshKanboardSaga } from './kanboards';
|
||||
|
||||
export function* rootSaga() {
|
||||
yield all([
|
||||
@ -22,6 +22,8 @@ export function* rootSaga() {
|
||||
takeEvery(MOVE_CARD, moveCardSaga),
|
||||
takeEvery(ADD_LABEL_REQUEST, addLabelSaga),
|
||||
takeEvery(REMOVE_LABEL_REQUEST, removeLabelSaga),
|
||||
takeLatest(CREATE_ISSUE_REQUEST, createIssueSaga),
|
||||
takeLatest(CREATE_ISSUE_SUCCESS, refreshKanboardSaga),
|
||||
takeLatest(LOGOUT, logoutSaga)
|
||||
]);
|
||||
}
|
||||
|
@ -52,6 +52,23 @@ export class GiteaClient {
|
||||
.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) {
|
||||
if (!res.ok) return Promise.reject(new Error('Request failed'));
|
||||
return res;
|
||||
|
Loading…
Reference in New Issue
Block a user