Files
Bonsai-webapp/src/app/issues/issue-detail/issue-detail.ts
T

531 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { marked } from 'marked';
import { IssueEntity, IssuesStore } from '../issues.store';
import { IssueComments } from '../issue-comments/issue-comments';
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
@Component({
selector: 'app-issue-detail',
imports: [FormsModule, IssueComments, GanttDiagram],
templateUrl: './issue-detail.html',
styleUrl: './issue-detail.css',
})
export class IssueDetail {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore);
private readonly statusesStore = inject(StatusesStore);
private readonly sanitizer = inject(DomSanitizer);
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
protected issue: IssueEntity = this.buildIssue();
protected readonly issues = this.issuesStore.issues;
protected readonly milestones = this.milestonesStore.milestones;
protected moreMenuOpen = false;
protected statusMenuOpen = false;
constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
const safeId = Number(idParam ?? 0);
this.milestonesStore.load();
this.issuesStore.load().then(() => {
if (safeId) {
const found = this.issuesStore.getById(safeId);
if (found) this.issue = { ...found };
}
});
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 selectedCandidateId: number | null = null;
protected editingDescription = false;
private _descriptionBeforeEdit = '';
protected showAddToEpic = false;
protected selectedEpicCandidateId: number | null = null;
protected showCreateInEpic = false;
protected newIssueName = '';
protected readonly statusOptions = this.statusesStore.statuses;
protected readonly typeOptions: IssueEntity['type'][] = [
'Epic',
'Bug',
'Study',
'Story',
'Task',
'Technical Story',
];
protected get dependencyIds(): number[] {
return this.issue.dependsOnIds;
}
protected get availableCandidates(): IssueEntity[] {
return this.issues().filter(
(issue) => issue.id !== this.issue.id && !this.issue.dependsOnIds.includes(issue.id),
);
}
protected resolveDependency(id: number): IssueEntity | undefined {
return this.issues().find((issue) => issue.id === id);
}
protected openAddDependency(): void {
this.selectedCandidateId = null;
this.showAddDependency = true;
}
protected cancelAddDependency(): void {
this.showAddDependency = false;
this.selectedCandidateId = null;
}
protected async confirmAddDependency(): Promise<void> {
if (this.selectedCandidateId !== null) {
this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId];
await this.saveIssue();
}
this.showAddDependency = false;
this.selectedCandidateId = null;
}
protected async removeDependency(id: number): Promise<void> {
this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id);
await this.saveIssue();
}
protected get estimatedTimeValue(): number | null {
return this.issue.estimatedTime;
}
protected set estimatedTimeValue(value: number | null) {
this.issue.estimatedTime = value === null || value === undefined ? null : Number(value);
this.recalculateEndDate();
}
private recalculateEndDate(): void {
const { startDate, estimatedTime } = this.issue;
if (!startDate || estimatedTime === null || estimatedTime <= 0) {
this.issue.endDate = '';
return;
}
const start = new Date(startDate);
const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1);
start.setDate(start.getDate() + extraDays);
this.issue.endDate = start.toISOString().split('T')[0];
}
protected onStartDateBlur(): void {
this.recalculateEndDate();
this.saveIssue();
}
protected get issueTypeValue(): IssueEntity['type'] {
return this.issue.type;
}
protected set issueTypeValue(value: IssueEntity['type']) {
this.issue.type = value;
}
protected get epicEstimatedTime(): number | null {
const times = this.composedIssues
.filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null)
.map((i) => i.estimatedTime);
return times.length === 0 ? null : times.reduce((a, b) => a + b, 0);
}
protected get epicIssues(): IssueEntity[] {
return this.issues().filter((issue) => issue.type === 'Epic');
}
protected get composedIssues(): IssueEntity[] {
return this.issues().filter(
(issue) =>
issue.id !== this.issue.id &&
(issue.dependsOnIds.includes(this.issue.id) || issue.epic === this.issue.name),
);
}
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 async confirmCreateInEpic(): Promise<void> {
const name = this.newIssueName.trim();
if (!name) return;
const created = await this.issuesStore.upsert({
id: 0,
type: 'Story',
assignee: '',
epic: this.issue.name,
name,
startDate: '',
endDate: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'MOYENNE',
status: 'draft',
progress: 0,
});
const epicMilestone = this.currentMilestone;
if (epicMilestone) {
await this.milestonesStore.upsert({
...epicMilestone,
issueIds: [...epicMilestone.issueIds, created.id],
});
}
this.showCreateInEpic = false;
this.newIssueName = '';
}
protected openAddToEpic(): void {
this.selectedEpicCandidateId = null;
this.showAddToEpic = true;
}
protected cancelAddToEpic(): void {
this.showAddToEpic = false;
this.selectedEpicCandidateId = null;
}
protected async confirmAddToEpic(): Promise<void> {
if (this.selectedEpicCandidateId !== null) {
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
if (target) {
await this.issuesStore.upsert({ ...target, epic: this.issue.name });
const epicMilestone = this.currentMilestone;
if (epicMilestone) {
const prevMilestone = this.milestones().find((m) => m.issueIds.includes(target.id));
if (prevMilestone && prevMilestone.id !== epicMilestone.id) {
await this.milestonesStore.upsert({
...prevMilestone,
issueIds: prevMilestone.issueIds.filter((id) => id !== target.id),
});
}
if (!epicMilestone.issueIds.includes(target.id)) {
await this.milestonesStore.upsert({
...epicMilestone,
issueIds: [...epicMilestone.issueIds, target.id],
});
}
}
}
}
this.showAddToEpic = false;
this.selectedEpicCandidateId = null;
}
protected get isEpicIssue(): boolean {
return this.issueTypeValue === 'Epic';
}
protected get epicGanttTasks(): GanttTask[] {
const tasks: GanttTask[] = [];
if (this.issue.startDate && this.issue.endDate) {
tasks.push({
id: `issue-${this.issue.id}`,
name: this.issue.name || 'Epic',
start: this.issue.startDate,
end: this.issue.endDate,
progress: this.composedIssues.length === 0
? this.issue.progress
: Math.round(
(this.composedIssues.filter((i) => i.status === 'done').length /
this.composedIssues.length) * 100,
),
custom_class: 'bar-epic',
});
}
for (const child of this.composedIssues) {
if (!child.startDate || !child.endDate) continue;
tasks.push({
id: `issue-${child.id}`,
name: `#${child.id} ${child.name}`,
start: child.startDate,
end: child.endDate,
progress: child.progress,
});
}
return tasks;
}
protected get isChildOfEpic(): boolean {
return !!this.issue.epic;
}
protected get dateValidationError(): string | null {
const { startDate, endDate } = this.issue;
if (startDate && endDate && startDate > endDate) {
return 'La date de début ne peut pas être supérieure à la date de fin.';
}
if (startDate && this.issue.dependsOnIds.length > 0) {
for (const depId of this.issue.dependsOnIds) {
const dep = this.issuesStore.getById(depId);
if (dep?.endDate && startDate < dep.endDate) {
return `La date de début ne peut pas être antérieure à la date de fin de la dépendance #${depId}.`;
}
}
}
return null;
}
protected startEditDescription(): void {
this._descriptionBeforeEdit = this.issue.description;
this.editingDescription = true;
}
protected async saveDescription(): Promise<void> {
this.editingDescription = false;
await this.saveIssue();
}
protected cancelEditDescription(): void {
this.issue.description = this._descriptionBeforeEdit;
this.editingDescription = false;
}
protected onDescriptionPaste(event: ClipboardEvent): void {
const ta = event.target as HTMLTextAreaElement;
const start = ta.selectionStart;
const end = ta.selectionEnd;
handleImagePaste(event, (md) => {
this.issue.description = insertAtSelection(ta, this.issue.description, start, end, md);
});
}
protected get descriptionHtml(): SafeHtml {
const html = marked.parse(this.issue.description || '') as string;
return this.sanitizer.bypassSecurityTrustHtml(html);
}
protected typeIcon(type: IssueEntity['type']): { letter: string; bg: string } {
const map: Record<IssueEntity['type'], { letter: string; bg: string }> = {
Epic: { letter: 'E', bg: '#7c3aed' },
Bug: { letter: 'B', bg: '#dc2626' },
Story: { letter: 'S', bg: '#16a34a' },
Task: { letter: 'T', bg: '#2563eb' },
Study: { letter: 'St', bg: '#6b7280' },
'Technical Story':{ letter: 'TS', bg: '#d97706' },
};
return map[type] ?? { letter: '?', bg: '#6b7280' };
}
protected statusBadge(status: IssueEntity['status']): StatusEntity {
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
}
protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } {
const map: Record<IssueEntity['priority'], { symbol: string; color: string; label: string }> = {
'TRES_HAUTE': { symbol: '↑↑', color: '#dc3545', label: 'Très haute' },
'HAUTE': { symbol: '↑', color: '#fd7e14', label: 'Haute' },
'MOYENNE': { symbol: '', color: '#ffc107', label: 'Moyenne' },
'BASSE': { symbol: '↓', color: '#0d6efd', label: 'Basse' },
'TRES_FAIBLE':{ symbol: '↓↓', color: '#6c757d', label: 'Très faible'},
};
return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority };
}
protected getBadgeClass(type: IssueEntity['type']): string {
const map: Record<IssueEntity['type'], string> = {
Bug: 'text-bg-danger',
Study: 'text-bg-secondary',
Story: 'text-bg-success',
Task: 'text-bg-primary',
'Technical Story': 'text-bg-warning',
Epic: 'text-bg-info',
};
return map[type] ?? 'text-bg-secondary';
}
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 get currentMilestone(): MilestoneEntity | undefined {
return this.milestones().find((m) => m.issueIds.includes(this.issue.id));
}
protected get currentMilestoneId(): number | null {
return this.currentMilestone?.id ?? null;
}
protected async onMilestoneChange(newMilestoneId: number | null): Promise<void> {
if (this.isNewIssueRoute) return;
const childIds = this.isEpicIssue
? this.issues().filter((i) => i.epic === this.issue.name).map((i) => i.id)
: [];
const allIds = [this.issue.id, ...childIds];
const previous = this.currentMilestone;
if (previous) {
await this.milestonesStore.upsert({
...previous,
issueIds: previous.issueIds.filter((id) => !allIds.includes(id)),
});
}
if (newMilestoneId !== null) {
const target = this.milestones().find((m) => m.id === newMilestoneId);
if (target) {
const toAdd = allIds.filter((id) => !target.issueIds.includes(id));
await this.milestonesStore.upsert({ ...target, issueIds: [...target.issueIds, ...toAdd] });
}
}
}
protected navigateToMilestone(): void {
if (this.currentMilestoneId !== null) {
this.router.navigate(['/milestones', this.currentMilestoneId]);
}
}
protected async saveIssue(explicit = false): Promise<void> {
if (this.isNewIssueRoute && !explicit) return;
if (!this.issue.name.trim()) return;
if (this.dateValidationError) return;
const saved = await this.issuesStore.upsert(this.issue);
this.issue = { ...saved };
if (this.isNewIssueRoute) {
this.router.navigate(['/issues', saved.id]);
}
}
protected cancelCreation(): void {
this.router.navigate(['/issues']);
}
protected async deleteIssue(): Promise<void> {
await this.issuesStore.deleteById(this.issue.id);
this.router.navigate(['/issues']);
}
protected async updateStatus(status: IssueEntity['status']): Promise<void> {
this.issue.status = status;
const saved = await this.issuesStore.upsert(this.issue);
this.issue = { ...saved };
}
protected toggleMoreMenu(): void {
this.moreMenuOpen = !this.moreMenuOpen;
}
protected closeMoreMenu(): void {
this.moreMenuOpen = false;
}
protected toggleStatusMenu(): void {
this.statusMenuOpen = !this.statusMenuOpen;
}
protected closeStatusMenu(): void {
this.statusMenuOpen = false;
}
protected async selectStatus(status: IssueEntity['status']): Promise<void> {
this.statusMenuOpen = false;
await this.updateStatus(status);
}
private buildIssue(): IssueEntity {
const idParam = this.route.snapshot.paramMap.get('id');
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
if (isNewIssueRoute) {
const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0);
return {
id: draftId,
type: 'Story',
assignee: '',
epic: '',
name: '',
startDate: '',
endDate: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'MOYENNE',
status: 'draft',
progress: 0,
};
}
const safeId = Number(idParam ?? 0);
const existingIssue = this.issuesStore.getById(safeId);
return (
existingIssue ?? {
id: safeId,
type: 'Story',
assignee: '',
epic: '',
name: '',
startDate: '',
endDate: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'MOYENNE',
status: 'draft',
progress: 0,
}
);
}
}