320 lines
8.8 KiB
TypeScript
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,
|
|
}
|
|
);
|
|
}
|
|
}
|