e2abbbb68c
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
239 lines
8.3 KiB
TypeScript
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 });
|
|
}
|
|
}
|