Possibilité de créer une voie de type "Backlog"

Une voie peut désormais "récolter" toutes les issues qui ne sont pas
déjà sélectionnées par d'autres voies i.e. matérialiser un "backlog".

Voir #22
This commit is contained in:
wpetit 2020-04-30 15:43:40 +02:00
parent 647c5c0806
commit b4ce7c3777
10 changed files with 158 additions and 44 deletions

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Page } from '../Page'; import { Page } from '../Page';
import { connect } from 'react-redux'; import { connect, DispatchProp } from 'react-redux';
import Board from '@lourenci/react-kanban'; import Board from '@lourenci/react-kanban';
import { fetchBoards } from '../../store/actions/boards'; import { fetchBoards } from '../../store/actions/boards';
import { createIssue } from '../../store/actions/issues'; import { createIssue } from '../../store/actions/issues';
@ -9,7 +9,11 @@ import { Loader } from '../Loader';
import { IssueCard } from './IssueCard'; import { IssueCard } from './IssueCard';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
export class BoardPage extends React.Component { export interface BoardPageProps extends DispatchProp {
board: any
kanboard: any
}
export class BoardPage extends React.Component<BoardPageProps> {
state = { state = {
newCardModalActive: false, newCardModalActive: false,
@ -21,7 +25,11 @@ export class BoardPage extends React.Component {
} }
} }
constructor(props) { onNewCardTitleChange: (evt: any) => void;
onNewCardBodyChange: (evt: any) => void;
onNewCardProjectChange: (evt: any) => void;
constructor(props: BoardPageProps) {
super(props); super(props);
this.renderLaneHeader = this.renderLaneHeader.bind(this); this.renderLaneHeader = this.renderLaneHeader.bind(this);
this.onNewCardClick = this.onNewCardClick.bind(this); this.onNewCardClick = this.onNewCardClick.bind(this);
@ -90,18 +98,18 @@ export class BoardPage extends React.Component {
placeholder="Description du nouveau ticket..." placeholder="Description du nouveau ticket..."
value={this.state.newCard.body} value={this.state.newCard.body}
onChange={this.onNewCardBodyChange} onChange={this.onNewCardBodyChange}
rows="10"> rows={10}>
</textarea> </textarea>
</div> </div>
</div> </div>
<div className="field"> <div className="field">
<div className="control is-expanded"> <div className="control is-expanded">
<div className="select is-fullwidth" <div className="select is-fullwidth">
<select
value={this.state.newCard.project} value={this.state.newCard.project}
onChange={this.onNewCardProjectChange}> onChange={this.onNewCardProjectChange}>
<select>
{ {
board.projects.map((p, i) => { board.projects.map((p: any, i: number) => {
return <option key={`new-card-project-${i}`} value={p}>{p}</option> return <option key={`new-card-project-${i}`} value={p}>{p}</option>
}) })
} }
@ -130,11 +138,11 @@ export class BoardPage extends React.Component {
) )
} }
renderCard(card) { renderCard(card: any) {
return <IssueCard card={card} />; return <IssueCard card={card} />;
} }
renderLaneHeader(lane) { renderLaneHeader(lane: any) {
return ( return (
<div className="kanboard-lane"> <div className="kanboard-lane">
<div className="level"> <div className="level">
@ -154,7 +162,7 @@ export class BoardPage extends React.Component {
) )
} }
onCardDragEnd(source, dest) { onCardDragEnd(source: any, dest: any) {
const { board } = this.props; const { board } = this.props;
this.props.dispatch(moveCard( this.props.dispatch(moveCard(
board.id, board.id,
@ -175,7 +183,7 @@ export class BoardPage extends React.Component {
this.requestBuildKanboard(); this.requestBuildKanboard();
} }
onNewCardClick(laneID) { onNewCardClick(laneID: string) {
const { board } = this.props; const { board } = this.props;
this.setState({ this.setState({
newCardModalActive: true, newCardModalActive: true,
@ -192,9 +200,9 @@ export class BoardPage extends React.Component {
this.setState({ newCardModalActive: false }); this.setState({ newCardModalActive: false });
} }
onNewCardAttrChange(attrName, evt) { onNewCardAttrChange(attrName: string, evt: React.ChangeEvent) {
const value = evt.target.value; const value = (evt.target as HTMLInputElement).value;
this.setState(state => { this.setState((state: any) => {
return { return {
...state, ...state,
newCard: { newCard: {
@ -217,7 +225,7 @@ export class BoardPage extends React.Component {
)); ));
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps: any) {
if (prevProps.board !== this.props.board) this.requestBuildKanboard(); if (prevProps.board !== this.props.board) this.requestBuildKanboard();
} }
@ -233,7 +241,7 @@ export class BoardPage extends React.Component {
} }
export const ConnectedBoardPage = connect(function(state, props) { export const ConnectedBoardPage = connect(function(state: any, props: any) {
const boardID = props.match.params.id; const boardID = props.match.params.id;
return { return {
board: state.boards.byID[boardID], board: state.boards.byID[boardID],

View File

@ -30,6 +30,7 @@ export class EditBoardPage extends React.Component<EditorBoardPageProps> {
onBoardDescriptionChange: (evt: any) => void; onBoardDescriptionChange: (evt: any) => void;
onBoardLaneTitleChange: (laneIndex: any, evt: any) => void; onBoardLaneTitleChange: (laneIndex: any, evt: any) => void;
onBoardLaneIssueLabelChange: (laneIndex: any, evt: any) => void; onBoardLaneIssueLabelChange: (laneIndex: any, evt: any) => void;
onBoardLaneIssueCollectRemainingIssuesChange: (laneIndex: any, evt: any) => void;
static getDerivedStateFromProps(props: any, state: any) { static getDerivedStateFromProps(props: any, state: any) {
const { board, isLoading } = props; const { board, isLoading } = props;
@ -54,6 +55,7 @@ export class EditBoardPage extends React.Component<EditorBoardPageProps> {
this.onBoardDescriptionChange = this.onBoardAttrChange.bind(this, 'description'); this.onBoardDescriptionChange = this.onBoardAttrChange.bind(this, 'description');
this.onBoardLaneTitleChange = this.onBoardLaneAttrChange.bind(this, 'title'); this.onBoardLaneTitleChange = this.onBoardLaneAttrChange.bind(this, 'title');
this.onBoardLaneIssueLabelChange = this.onBoardLaneAttrChange.bind(this, 'issueLabel'); this.onBoardLaneIssueLabelChange = this.onBoardLaneAttrChange.bind(this, 'issueLabel');
this.onBoardLaneIssueCollectRemainingIssuesChange = this.onBoardLaneAttrChange.bind(this, 'collectRemainingIssues');
this.onDeleteBoardClick = this.onDeleteBoardClick.bind(this); this.onDeleteBoardClick = this.onDeleteBoardClick.bind(this);
} }
@ -223,6 +225,21 @@ export class EditBoardPage extends React.Component<EditorBoardPageProps> {
</div> </div>
</div> </div>
</div> </div>
<div className="field is-horizontal">
<div className="field-label is-normal"></div>
<div className="field-body">
<div className="field">
<div className="control">
<label className="checkbox">
<input type="checkbox"
checked={lane.hasOwnProperty('collectRemainingIssues') ? !!lane.collectRemainingIssues : false}
onChange={this.onBoardLaneIssueCollectRemainingIssuesChange.bind(this, laneIndex)} />
Inclure tous les tickets "restants" (i.e. non sélectionnés par les autres voies)
</label>
</div>
</div>
</div>
</div>
</div> </div>
<div className="column is-2 is-flex"> <div className="column is-2 is-flex">
<div className="buttons"> <div className="buttons">
@ -378,13 +395,15 @@ export class EditBoardPage extends React.Component<EditorBoardPageProps> {
} }
onBoardLaneAttrChange(attrName: string, laneIndex: number, evt: React.ChangeEvent) { onBoardLaneAttrChange(attrName: string, laneIndex: number, evt: React.ChangeEvent) {
const value = (evt.target as HTMLInputElement).value; const input = evt.target as HTMLInputElement;
const value = input.type === "checkbox" ? input.checked : input.value;
this.setState((state: any) => { this.setState((state: any) => {
const lanes = [ ...state.board.lanes ]; const lanes = [ ...state.board.lanes ];
lanes[laneIndex] = { lanes[laneIndex] = {
...state.board.lanes[laneIndex], ...state.board.lanes[laneIndex],
[attrName]: value [attrName]: value
}; };
console.log(lanes);
return { return {
...state, ...state,
edited: true, edited: true,

View File

@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
export class IssueCard extends React.PureComponent { export interface IssueCardProps {
card: any
}
export class IssueCard extends React.PureComponent<IssueCardProps> {
render() { render() {
const { card } = this.props; const { card } = this.props;
const issueURLInfo = extractInfoFromIssueURL(card.issue.url); const issueURLInfo = extractInfoFromIssueURL(card.issue.url);
@ -49,9 +53,12 @@ export class IssueCard extends React.PureComponent {
} }
} }
function extractInfoFromIssueURL(issueURL) { function extractInfoFromIssueURL(issueURL: string): any|void {
const pattern = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/.*$/; const pattern = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/.*$/;
const matches = pattern.exec(issueURL); const matches = pattern.exec(issueURL);
if (!matches) return;
return { return {
baseURL: matches[1], baseURL: matches[1],
owner: matches[2], owner: matches[2],

View File

@ -2,8 +2,8 @@ import React, { PropsWithChildren } from 'react';
export interface ModalProps { export interface ModalProps {
active: boolean active: boolean
showCloseButton: boolean showCloseButton?: boolean
onClose: (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void onClose?: (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
} }
export class Modal extends React.PureComponent<PropsWithChildren<ModalProps>> { export class Modal extends React.PureComponent<PropsWithChildren<ModalProps>> {

View File

@ -1,4 +1,5 @@
declare module "*.svg" { declare module "*.svg" {
const content: any; const content: any;
export default content; export default content;
} }
declare module '@lourenci/react-kanban';

View File

@ -2,6 +2,9 @@ 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, buildKanboard, BUILD_KANBOARD_FAILURE } from '../actions/kanboards'; import { BUILD_KANBOARD_SUCCESS, buildKanboard, BUILD_KANBOARD_FAILURE } from '../actions/kanboards';
import { Project, Issue } from '../../types/gitea';
import { Board, BoardLane } from '../../types/board';
import { KanboardLane, Kanboard, KanboardCard } from '../../types/kanboard';
export function* moveCardSaga(action: any) { export function* moveCardSaga(action: any) {
const { const {
@ -64,21 +67,21 @@ export function* refreshKanboardSaga(action: any) {
} }
} }
function createCards(projects: any[], issues: any, lane: any) { function createCards(projects: Project[], issues: any, lane: BoardLane, rest: Set<KanboardCard>) {
return projects.reduce((laneCards, p) => { const cards: KanboardCard[] = projects.reduce((laneCards, p) => {
const projectIssues = p in issues.byProject ? issues.byProject[p] : []; const projectIssues = p in issues.byProject ? issues.byProject[p] : [];
return projectIssues.reduce((projectCards: any, issue: any) => { return projectIssues.reduce((projectCards: KanboardCard[], issue: any) => {
const hasLabel = issue.labels.some((l: any) => l.name === lane.issueLabel); const hasLabel = issue.labels.some((l: any) => l.name === lane.issueLabel);
const card = getMemoizedKanboardCard(issue.id, issue.title, p, issue);
if (hasLabel) { if (hasLabel) {
projectCards.push({ projectCards.push(card);
id: issue.id, rest.delete(card);
title: issue.title, } else {
project: p, rest.add(card);
issue: issue,
});
} }
return projectCards; return projectCards;
@ -86,22 +89,55 @@ function createCards(projects: any[], issues: any, lane: any) {
}, laneCards); }, laneCards);
}, []); }, []);
return cards;
} }
function createLane(projects: any, issues: any, lane: any, index: any) { const kanboardCardMemo: {[key: string]: KanboardCard} = {};
return {
id: index, function getMemoizedKanboardCard(id: number, title: string, project: Project, issue: Issue): KanboardCard {
title: lane.title, const key = `${project.id}-${issue.id}-${id}`;
cards: createCards(projects, issues, lane) if (kanboardCardMemo.hasOwnProperty(key)) return kanboardCardMemo[key];
} kanboardCardMemo[key] = { id, title, project, issue };
return kanboardCardMemo[key];
} }
function createKanboard(board: any, issues: any) { function resetKandboarCardMemo() {
Object.keys(kanboardCardMemo).forEach(k => delete kanboardCardMemo[k]);
}
function createKanboardLanes(board: Board, issues: any): KanboardLane[] {
const lanes: KanboardLane[] = [];
const rest = new Set<KanboardCard>();
resetKandboarCardMemo();
board.lanes.forEach((l: BoardLane, i: number) => {
const cards = createCards(board.projects, issues, l, rest);
lanes.push({
id: i,
title: l.title,
cards,
});
});
// Assign remaining issues
board.lanes.forEach((l: BoardLane, i: number) => {
if (!l.collectRemainingIssues) return;
lanes[i].cards.push(...Array.from(rest.values()));
});
resetKandboarCardMemo();
return lanes;
}
function createKanboard(board: Board, issues: any) {
if (!board) return null; if (!board) return null;
const kanboard = { const kanboard = {
id: board.id, id: board.id,
lanes: board.lanes.map(createLane.bind(null, board.projects, issues)), lanes: createKanboardLanes(board, issues),
}; };
return kanboard; return kanboard;

19
client/src/types/board.ts Normal file
View File

@ -0,0 +1,19 @@
import { Project } from "./gitea"
export type BoardLaneId = string
export type BoardId = string
export interface Board {
id: BoardId
title: string
description: string
lanes: BoardLane[]
projects: Project[]
};
export interface BoardLane {
id: BoardLaneId
title: string
issueLabel: string
collectRemainingIssues: boolean
};

View File

@ -0,0 +1,2 @@
export type Project = any
export type Issue = any

View File

@ -0,0 +1,21 @@
import { Project, Issue } from "./gitea";
import { BoardId } from "./board";
export interface Kanboard {
id: BoardId
lanes: KanboardLane[]
}
export interface KanboardLane {
id: number
title: string
cards: KanboardCard[]
};
export interface KanboardCard {
id: number
title: string
project: Project
issue: Issue
}

View File

@ -23,4 +23,5 @@ type BoardLane struct {
ID BoardLaneID `json:"id"` ID BoardLaneID `json:"id"`
Title string `json:"title"` Title string `json:"title"`
IssueLabel string `json:"issueLabel"` IssueLabel string `json:"issueLabel"`
CollectRemainingIssues bool `json:"collectRemainingIssues"`
} }