Files
Bonsai-webapp/src/app/issues/issue-comments/issue-comments.ts
T
2026-05-30 14:07:16 +02:00

239 lines
8.3 KiB
TypeScript

import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
import { MilestonesStore } from '../../milestones/milestones.store';
@Component({
selector: 'app-issue-comments',
imports: [FormsModule, RouterLink],
templateUrl: './issue-comments.html',
styleUrl: './issue-comments.css',
})
export class IssueComments {
private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore);
private readonly sanitizer = inject(DomSanitizer);
private readonly statusesStore = inject(StatusesStore);
readonly issueId = input.required<number>();
protected readonly comments = computed(
() => this.issuesStore.issues().find((i) => i.id === this.issueId())?.comments ?? [],
);
protected readonly allOtherIssues = computed(() =>
this.issuesStore.issues().filter((i) => i.id !== this.issueId()),
);
protected newCommentText = '';
protected editingCommentId: number | null = null;
protected editingCommentText = '';
protected creatingTaskForCommentId: number | null = null;
protected newTaskName = '';
protected linkingIssueForCommentId: number | null = null;
protected issueSearchText = '';
protected parseMarkdown(text: string): SafeHtml {
const html = marked.parse(text) as string;
return this.sanitizer.bypassSecurityTrustHtml(html);
}
protected formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
protected async addComment(): Promise<void> {
const text = this.newCommentText.trim();
if (!text) return;
const issue = this.issuesStore.getById(this.issueId());
if (!issue) return;
const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1;
const comment: IssueComment = {
id: nextId,
text,
createdAt: new Date().toISOString(),
updatedAt: null,
linkedIssueIds: [],
};
await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
this.newCommentText = '';
}
protected startEditComment(comment: IssueComment): void {
this.editingCommentId = comment.id;
this.editingCommentText = comment.text;
this.creatingTaskForCommentId = null;
this.linkingIssueForCommentId = null;
}
protected async saveEditComment(): Promise<void> {
const text = this.editingCommentText.trim();
if (!text || this.editingCommentId === null) return;
const issue = this.issuesStore.getById(this.issueId());
if (!issue) return;
const updatedComments = issue.comments.map((c) =>
c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c,
);
await this.issuesStore.upsert({ ...issue, comments: updatedComments });
this.editingCommentId = null;
this.editingCommentText = '';
}
protected onPaste(event: ClipboardEvent, field: 'new' | 'edit'): void {
const ta = event.target as HTMLTextAreaElement;
const start = ta.selectionStart;
const end = ta.selectionEnd;
handleImagePaste(event, (md) => {
if (field === 'new') {
this.newCommentText = insertAtSelection(ta, this.newCommentText, start, end, md);
} else {
this.editingCommentText = insertAtSelection(ta, this.editingCommentText, start, end, md);
}
});
}
protected cancelEditComment(): void {
this.editingCommentId = null;
this.editingCommentText = '';
}
protected async deleteComment(id: number): Promise<void> {
const issue = this.issuesStore.getById(this.issueId());
if (!issue) return;
await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
}
protected getLinkedIssues(comment: IssueComment): IssueEntity[] {
if (!comment.linkedIssueIds.length) return [];
return comment.linkedIssueIds
.map((id) => this.issuesStore.getById(id))
.filter((i): i is IssueEntity => i !== undefined);
}
protected filteredIssuesForLink(commentId: number): IssueEntity[] {
const comment = this.comments().find((c) => c.id === commentId);
const linkedIds = new Set(comment?.linkedIssueIds ?? []);
const search = this.issueSearchText.toLowerCase();
return this.allOtherIssues().filter(
(i) => !linkedIds.has(i.id) && (!search || i.name.toLowerCase().includes(search)),
);
}
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 statusLabel(status: IssueEntity['status']): StatusEntity {
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
}
protected startCreateTask(commentId: number): void {
this.creatingTaskForCommentId = commentId;
this.newTaskName = '';
this.linkingIssueForCommentId = null;
this.issueSearchText = '';
}
protected cancelCreateTask(): void {
this.creatingTaskForCommentId = null;
this.newTaskName = '';
}
protected async createTaskForComment(commentId: number): Promise<void> {
const name = this.newTaskName.trim();
if (!name) return;
const issue = this.issuesStore.getById(this.issueId());
if (!issue) return;
const newTask: IssueEntity = {
id: 0,
type: 'Task',
name,
assignee: '',
epic: issue.epic,
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'MOYENNE',
status: 'draft',
progress: 0,
};
const created = await this.issuesStore.upsert(newTask);
const updatedComments = issue.comments.map((c) => {
if (c.id !== commentId) return c;
return { ...c, linkedIssueIds: [...c.linkedIssueIds, created.id] };
});
await this.issuesStore.upsert({ ...issue, comments: updatedComments });
const milestone = this.milestonesStore.milestones().find((m) => m.issueIds.includes(issue.id));
if (milestone) {
await this.milestonesStore.upsert({ ...milestone, issueIds: [...milestone.issueIds, created.id] });
}
this.creatingTaskForCommentId = null;
this.newTaskName = '';
}
protected startLinkIssue(commentId: number): void {
this.linkingIssueForCommentId = commentId;
this.issueSearchText = '';
this.creatingTaskForCommentId = null;
this.newTaskName = '';
}
protected cancelLinkIssue(): void {
this.linkingIssueForCommentId = null;
this.issueSearchText = '';
}
protected async linkIssueToComment(commentId: number, issueId: number): Promise<void> {
const issue = this.issuesStore.getById(this.issueId());
if (!issue) return;
const updatedComments = issue.comments.map((c) => {
if (c.id !== commentId) return c;
if (c.linkedIssueIds.includes(issueId)) return c;
return { ...c, linkedIssueIds: [...c.linkedIssueIds, issueId] };
});
await this.issuesStore.upsert({ ...issue, comments: updatedComments });
this.linkingIssueForCommentId = null;
this.issueSearchText = '';
}
protected async unlinkIssueFromComment(commentId: number, issueId: number): Promise<void> {
const issue = this.issuesStore.getById(this.issueId());
if (!issue) return;
const updatedComments = issue.comments.map((c) => {
if (c.id !== commentId) return c;
return { ...c, linkedIssueIds: c.linkedIssueIds.filter((id) => id !== issueId) };
});
await this.issuesStore.upsert({ ...issue, comments: updatedComments });
}
}