Refacto issue detail
This commit is contained in:
@@ -85,6 +85,15 @@
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composed-issue-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composed-issue-item:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
/* Description */
|
/* Description */
|
||||||
.description-textarea {
|
.description-textarea {
|
||||||
min-height: 40rem;
|
min-height: 40rem;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<option [value]="status">{{ status }}</option>
|
<option [value]="status">{{ status }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
@if (!isNewIssueRoute) {
|
||||||
<div class="more-wrapper">
|
<div class="more-wrapper">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
|
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
|
||||||
@if (moreMenuOpen) {
|
@if (moreMenuOpen) {
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,13 +34,19 @@
|
|||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
<input
|
<input
|
||||||
|
#titleInput="ngModel"
|
||||||
aria-label="Titre"
|
aria-label="Titre"
|
||||||
class="title-input form-control border-0 shadow-none p-0 fw-semibold fs-5"
|
class="title-input form-control border-0 shadow-none p-0 fw-semibold fs-5"
|
||||||
|
[class.is-invalid]="titleInput.invalid && titleInput.touched"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Titre de l'issue..."
|
placeholder="Titre de l'issue..."
|
||||||
|
required
|
||||||
[(ngModel)]="issue.name"
|
[(ngModel)]="issue.name"
|
||||||
(blur)="saveIssue()"
|
(blur)="saveIssue()"
|
||||||
/>
|
/>
|
||||||
|
@if (titleInput.invalid && titleInput.touched) {
|
||||||
|
<div class="text-danger small mt-1">Le titre est obligatoire.</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,12 +75,17 @@
|
|||||||
@if (!isEpicIssue) {
|
@if (!isEpicIssue) {
|
||||||
<div>
|
<div>
|
||||||
<label class="field-label">Epic</label>
|
<label class="field-label">Epic</label>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<select aria-label="Epic" class="form-select form-select-sm" [(ngModel)]="issue.epic" (change)="saveIssue()">
|
<select aria-label="Epic" class="form-select form-select-sm" [(ngModel)]="issue.epic" (change)="saveIssue()">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@for (epicIssue of epicIssues; track epicIssue.id) {
|
@for (epicIssue of epicIssues; track epicIssue.id) {
|
||||||
<option [value]="epicIssue.name">{{ epicIssue.name }}</option>
|
<option [value]="epicIssue.name">{{ epicIssue.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
@if (epicIssueId !== null) {
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" (click)="navigateToEpic()" title="Voir l'Epic">↗</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,15 +193,61 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<ul class="list-group list-group-flush">
|
<ul class="list-group list-group-flush">
|
||||||
@for (composedIssue of composedIssues; track composedIssue.id) {
|
@for (composedIssue of composedIssues; track composedIssue.id) {
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center gap-3 py-3">
|
<li
|
||||||
<div>
|
class="list-group-item d-flex justify-content-between align-items-center gap-3 py-3 composed-issue-item"
|
||||||
<p class="mb-0 fw-semibold">#{{ composedIssue.id }} – {{ composedIssue.name || 'Sans nom' }}</p>
|
(click)="openComposedIssue(composedIssue.id)"
|
||||||
<p class="mb-0 text-secondary small">{{ composedIssue.type }} · {{ composedIssue.status }}</p>
|
>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span [class]="'badge ' + getBadgeClass(composedIssue.type)">{{ composedIssue.type }}</span>
|
||||||
|
<span class="fw-semibold">#{{ composedIssue.id }} – {{ composedIssue.name || 'Sans nom' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-shrink-0">
|
||||||
|
<span class="text-secondary small">{{ composedIssue.priority }}</span>
|
||||||
<span class="text-secondary small text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span>
|
<span class="text-secondary small text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
@if (showCreateInEpic) {
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
aria-label="Titre de la nouvelle issue"
|
||||||
|
class="form-control form-control-sm dep-select"
|
||||||
|
type="text"
|
||||||
|
placeholder="Titre de l'issue..."
|
||||||
|
[(ngModel)]="newIssueName"
|
||||||
|
(keydown.enter)="confirmCreateInEpic()"
|
||||||
|
(keydown.escape)="cancelCreateInEpic()"
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmCreateInEpic()" [disabled]="!newIssueName.trim()">Créer</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelCreateInEpic()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
} @else if (showAddToEpic) {
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<select aria-label="Choisir une issue à ajouter à l'epic" class="form-select form-select-sm dep-select" [(ngModel)]="selectedEpicCandidateId">
|
||||||
|
<option [ngValue]="null">Choisir une issue...</option>
|
||||||
|
@for (candidate of epicCandidates; track candidate.id) {
|
||||||
|
<option [ngValue]="candidate.id">#{{ candidate.id }} – {{ candidate.name || 'Sans nom' }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmAddToEpic()" [disabled]="selectedEpicCandidateId === null">Ajouter</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelAddToEpic()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" (click)="openCreateInEpic()">+ Créer une issue</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="openAddToEpic()">Ajouter une existante</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isNewIssueRoute) {
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" (click)="cancelCreation()">Annuler</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="saveIssue(true)" [disabled]="!issue.name.trim()">Créer l'issue</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@@ -16,14 +17,33 @@ export class IssueDetail {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
private readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||||
|
|
||||||
protected issue: IssueEntity = this.buildIssue();
|
protected issue: IssueEntity = this.buildIssue();
|
||||||
protected readonly issues = this.issuesStore.issues;
|
protected readonly issues = this.issuesStore.issues;
|
||||||
protected moreMenuOpen = false;
|
protected moreMenuOpen = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
|
const id = Number(params.get('id'));
|
||||||
|
if (!id || isNaN(id)) return;
|
||||||
|
const existingIssue = this.issuesStore.getById(id);
|
||||||
|
if (existingIssue) {
|
||||||
|
this.issue = { ...existingIssue };
|
||||||
|
this.editingDescription = false;
|
||||||
|
this.showAddDependency = false;
|
||||||
|
this.showAddToEpic = false;
|
||||||
|
this.showCreateInEpic = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
protected showAddDependency = false;
|
protected showAddDependency = false;
|
||||||
protected selectedCandidateId: number | null = null;
|
protected selectedCandidateId: number | null = null;
|
||||||
protected editingDescription = false;
|
protected editingDescription = false;
|
||||||
|
protected showAddToEpic = false;
|
||||||
|
protected selectedEpicCandidateId: number | null = null;
|
||||||
|
protected showCreateInEpic = false;
|
||||||
|
protected newIssueName = '';
|
||||||
|
|
||||||
protected readonly statusOptions: IssueEntity['status'][] = [
|
protected readonly statusOptions: IssueEntity['status'][] = [
|
||||||
'draft',
|
'draft',
|
||||||
@@ -107,6 +127,66 @@ export class IssueDetail {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get epicCandidates(): IssueEntity[] {
|
||||||
|
const composedIds = new Set(this.composedIssues.map((i) => i.id));
|
||||||
|
return this.issues().filter(
|
||||||
|
(issue) => issue.id !== this.issue.id && !composedIds.has(issue.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openCreateInEpic(): void {
|
||||||
|
this.newIssueName = '';
|
||||||
|
this.showCreateInEpic = true;
|
||||||
|
this.showAddToEpic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancelCreateInEpic(): void {
|
||||||
|
this.showCreateInEpic = false;
|
||||||
|
this.newIssueName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected confirmCreateInEpic(): void {
|
||||||
|
const name = this.newIssueName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
this.issuesStore.upsert({
|
||||||
|
id: this.issuesStore.getNextId(),
|
||||||
|
type: 'Story',
|
||||||
|
assignee: '',
|
||||||
|
epic: this.issue.name,
|
||||||
|
name,
|
||||||
|
dueDate: '',
|
||||||
|
description: '',
|
||||||
|
estimatedTime: null,
|
||||||
|
dependsOnIds: [],
|
||||||
|
priority: 'Moyenne',
|
||||||
|
status: 'draft',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
this.showCreateInEpic = false;
|
||||||
|
this.newIssueName = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openAddToEpic(): void {
|
||||||
|
this.selectedEpicCandidateId = null;
|
||||||
|
this.showAddToEpic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancelAddToEpic(): void {
|
||||||
|
this.showAddToEpic = false;
|
||||||
|
this.selectedEpicCandidateId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected confirmAddToEpic(): void {
|
||||||
|
if (this.selectedEpicCandidateId !== null) {
|
||||||
|
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
||||||
|
if (target) {
|
||||||
|
this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.showAddToEpic = false;
|
||||||
|
this.selectedEpicCandidateId = null;
|
||||||
|
}
|
||||||
|
|
||||||
protected get isEpicIssue(): boolean {
|
protected get isEpicIssue(): boolean {
|
||||||
return this.issueTypeValue === 'Epic';
|
return this.issueTypeValue === 'Epic';
|
||||||
}
|
}
|
||||||
@@ -117,6 +197,10 @@ export class IssueDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected get typeBadgeClass(): string {
|
protected get typeBadgeClass(): string {
|
||||||
|
return this.getBadgeClass(this.issueTypeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBadgeClass(type: IssueEntity['type']): string {
|
||||||
const map: Record<IssueEntity['type'], string> = {
|
const map: Record<IssueEntity['type'], string> = {
|
||||||
Bug: 'text-bg-danger',
|
Bug: 'text-bg-danger',
|
||||||
Study: 'text-bg-secondary',
|
Study: 'text-bg-secondary',
|
||||||
@@ -125,16 +209,37 @@ export class IssueDetail {
|
|||||||
'Technical Story': 'text-bg-warning',
|
'Technical Story': 'text-bg-warning',
|
||||||
Epic: 'text-bg-info',
|
Epic: 'text-bg-info',
|
||||||
};
|
};
|
||||||
return map[this.issueTypeValue] ?? 'text-bg-secondary';
|
return map[type] ?? 'text-bg-secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveIssue(): void {
|
protected openComposedIssue(id: number): void {
|
||||||
|
this.router.navigate(['/issues', id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get epicIssueId(): number | null {
|
||||||
|
const epic = this.epicIssues.find((e) => e.name === this.issue.epic);
|
||||||
|
return epic?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected navigateToEpic(): void {
|
||||||
|
if (this.epicIssueId !== null) {
|
||||||
|
this.router.navigate(['/issues', this.epicIssueId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected saveIssue(explicit = false): void {
|
||||||
|
if (this.isNewIssueRoute && !explicit) return;
|
||||||
|
if (!this.issue.name.trim()) return;
|
||||||
this.issuesStore.upsert(this.issue);
|
this.issuesStore.upsert(this.issue);
|
||||||
if (this.isNewIssueRoute) {
|
if (this.isNewIssueRoute) {
|
||||||
this.router.navigate(['/issues', this.issue.id]);
|
this.router.navigate(['/issues', this.issue.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected cancelCreation(): void {
|
||||||
|
this.router.navigate(['/issues']);
|
||||||
|
}
|
||||||
|
|
||||||
protected deleteIssue(): void {
|
protected deleteIssue(): void {
|
||||||
this.issuesStore.deleteById(this.issue.id);
|
this.issuesStore.deleteById(this.issue.id);
|
||||||
this.router.navigate(['/issues']);
|
this.router.navigate(['/issues']);
|
||||||
|
|||||||
Reference in New Issue
Block a user