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 { 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 { 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 { 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 { 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 = { 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 { 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 { await this.issuesStore.deleteById(this.issue.id); this.router.navigate(['/issues']); } protected async updateStatus(status: IssueEntity['status']): Promise { 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, } ); } }