Files
Bonsai-webapp/src/app/issues/issue-detail/issue-detail.ts
T
2026-05-24 09:27:01 +02:00

320 lines
8.8 KiB
TypeScript

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';
@Component({
selector: 'app-issue-detail',
imports: [FormsModule, IssueComments],
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 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 moreMenuOpen = false;
constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
const safeId = Number(idParam ?? 0);
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;
protected showAddToEpic = false;
protected selectedEpicCandidateId: number | null = null;
protected showCreateInEpic = false;
protected newIssueName = '';
protected readonly statusOptions: IssueEntity['status'][] = [
'draft',
'todo',
'in-progress',
'done',
];
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);
}
protected get issueTypeValue(): IssueEntity['type'] {
return this.issue.type;
}
protected set issueTypeValue(value: IssueEntity['type']) {
this.issue.type = value;
}
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;
await this.issuesStore.upsert({
id: 0,
type: 'Story',
assignee: '',
epic: this.issue.name,
name,
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
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 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 });
}
}
this.showAddToEpic = false;
this.selectedEpicCandidateId = null;
}
protected get isEpicIssue(): boolean {
return this.issueTypeValue === 'Epic';
}
protected get descriptionHtml(): SafeHtml {
const html = marked.parse(this.issue.description || '') as string;
return this.sanitizer.bypassSecurityTrustHtml(html);
}
protected get typeBadgeClass(): string {
return this.getBadgeClass(this.issueTypeValue);
}
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 async saveIssue(explicit = false): Promise<void> {
if (this.isNewIssueRoute && !explicit) return;
if (!this.issue.name.trim()) 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;
}
private buildIssue(): IssueEntity {
const idParam = this.route.snapshot.paramMap.get('id');
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
if (isNewIssueRoute) {
return {
id: 0,
type: 'Story',
assignee: '',
epic: '',
name: '',
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: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'Moyenne',
status: 'draft',
progress: 0,
}
);
}
}