Ajout issue dans les commentaires

Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
2026-05-28 18:50:36 +02:00
parent 081b48841a
commit cd93533b7c
5 changed files with 674 additions and 9 deletions
@@ -90,3 +90,152 @@
.comment-new { .comment-new {
padding-top: 0.5rem; padding-top: 0.5rem;
} }
/* --- Issues liées --- */
.linked-issues {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.linked-issue-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.2rem 0.5rem;
max-width: fit-content;
}
.linked-issue-type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.linked-issue-name {
font-size: 0.8rem;
color: #1d4ed8;
text-decoration: none;
font-weight: 500;
}
.linked-issue-name:hover {
text-decoration: underline;
}
.linked-issue-status {
font-size: 0.68rem;
font-weight: 600;
padding: 0.1em 0.4em;
border-radius: 4px;
}
.linked-issue-unlink {
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
margin-left: 0.1rem;
}
.linked-issue-unlink:hover {
color: #b91c1c;
}
/* --- Boutons d'action liée --- */
.comment-link-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-link-btn {
border: none;
background: none;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
padding: 0;
text-decoration: none;
}
.comment-link-btn:hover {
color: #1d4ed8;
text-decoration: underline;
}
/* --- Formulaire création tâche --- */
.comment-task-form {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.5rem;
}
/* --- Dropdown lier issue existante --- */
.comment-link-form {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.5rem;
}
.link-issue-list {
margin-top: 0.4rem;
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.link-issue-option {
display: flex;
align-items: center;
gap: 0.4rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 0.3rem 0.5rem;
cursor: pointer;
text-align: left;
width: 100%;
}
.link-issue-option:hover {
background: #eff6ff;
border-color: #bfdbfe;
}
.link-issue-option-name {
font-size: 0.8rem;
color: #111827;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-issue-empty {
font-size: 0.78rem;
color: #9ca3af;
padding: 0.4rem 0;
text-align: center;
}
@@ -31,6 +31,97 @@
</div> </div>
} @else { } @else {
<div class="comment-text markdown-body mt-1" [innerHTML]="parseMarkdown(comment.text)"></div> <div class="comment-text markdown-body mt-1" [innerHTML]="parseMarkdown(comment.text)"></div>
<!-- Issues liées -->
@if (getLinkedIssues(comment).length > 0) {
<div class="linked-issues mt-2">
@for (linked of getLinkedIssues(comment); track linked.id) {
<div class="linked-issue-chip">
<span
class="linked-issue-type-icon"
[style.background]="typeIcon(linked.type).bg"
[title]="linked.type"
>{{ typeIcon(linked.type).letter }}</span>
<a [routerLink]="['/issues', linked.id]" class="linked-issue-name">{{ linked.name }}</a>
<span
class="linked-issue-status"
[style.background]="statusLabel(linked.status).bg"
[style.color]="statusLabel(linked.status).color"
>{{ statusLabel(linked.status).label }}</span>
<button
type="button"
class="linked-issue-unlink"
title="Délier"
(click)="unlinkIssueFromComment(comment.id, linked.id)"
>×</button>
</div>
}
</div>
}
<!-- Actions : créer tâche / lier issue -->
@if (creatingTaskForCommentId === comment.id) {
<div class="comment-task-form mt-2">
<input
type="text"
class="form-control form-control-sm"
placeholder="Nom de la tâche..."
[(ngModel)]="newTaskName"
(keydown.enter)="createTaskForComment(comment.id)"
(keydown.escape)="cancelCreateTask()"
autofocus
/>
<div class="d-flex gap-2 mt-1">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="createTaskForComment(comment.id)"
[disabled]="!newTaskName.trim()"
>Créer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelCreateTask()">Annuler</button>
</div>
</div>
} @else if (linkingIssueForCommentId === comment.id) {
<div class="comment-link-form mt-2">
<input
type="text"
class="form-control form-control-sm"
placeholder="Rechercher une issue..."
[(ngModel)]="issueSearchText"
(keydown.escape)="cancelLinkIssue()"
autofocus
/>
<div class="link-issue-list">
@for (candidate of filteredIssuesForLink(comment.id); track candidate.id) {
<button
type="button"
class="link-issue-option"
(click)="linkIssueToComment(comment.id, candidate.id)"
>
<span
class="linked-issue-type-icon"
[style.background]="typeIcon(candidate.type).bg"
>{{ typeIcon(candidate.type).letter }}</span>
<span class="link-issue-option-name">{{ candidate.name }}</span>
<span
class="linked-issue-status"
[style.background]="statusLabel(candidate.status).bg"
[style.color]="statusLabel(candidate.status).color"
>{{ statusLabel(candidate.status).label }}</span>
</button>
}
@if (filteredIssuesForLink(comment.id).length === 0) {
<span class="link-issue-empty">Aucune issue trouvée</span>
}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" (click)="cancelLinkIssue()">Annuler</button>
</div>
} @else {
<div class="comment-link-actions mt-2">
<button type="button" class="comment-link-btn" (click)="startCreateTask(comment.id)">+ Créer une tâche</button>
<button type="button" class="comment-link-btn" (click)="startLinkIssue(comment.id)">+ Lier une issue</button>
</div>
}
} }
</div> </div>
} }
@@ -1,5 +1,6 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { afterEach, vi } from 'vitest'; import { afterEach, vi } from 'vitest';
import { IssueComments } from './issue-comments'; import { IssueComments } from './issue-comments';
import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueEntity, IssuesStore } from '../issues.store';
@@ -43,11 +44,18 @@ class FakeIssuesStore {
upsert(issue: any): Promise<IssueEntity> { upsert(issue: any): Promise<IssueEntity> {
const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue; const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue;
const id = rest.id || this.getNextId();
const normalized: IssueEntity = { const normalized: IssueEntity = {
...makeIssue(), ...makeIssue(),
...rest, ...rest,
id,
dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : [], dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : [],
comments: Array.isArray(c) ? c : [], comments: Array.isArray(c)
? c.map((comment: any) => ({
...comment,
linkedIssueIds: Array.isArray(comment.linkedIssueIds) ? comment.linkedIssueIds : [],
}))
: [],
estimatedTime: et ?? null, estimatedTime: et ?? null,
}; };
this._data.update((issues) => { this._data.update((issues) => {
@@ -78,7 +86,7 @@ describe('IssueComments', () => {
beforeEach(async () => { beforeEach(async () => {
store = new FakeIssuesStore(); store = new FakeIssuesStore();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [IssueComments], imports: [IssueComments, RouterTestingModule],
providers: [{ provide: IssuesStore, useValue: store }], providers: [{ provide: IssuesStore, useValue: store }],
}).compileComponents(); }).compileComponents();
@@ -166,6 +174,12 @@ describe('IssueComments', () => {
(component as any).addComment(); (component as any).addComment();
expect(store.getById(1)?.comments[0].updatedAt).toBeNull(); expect(store.getById(1)?.comments[0].updatedAt).toBeNull();
}); });
it('initialises linkedIssueIds as empty array', () => {
(component as any).newCommentText = 'New comment';
(component as any).addComment();
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([]);
});
}); });
describe('startEditComment', () => { describe('startEditComment', () => {
@@ -179,6 +193,19 @@ describe('IssueComments', () => {
expect((component as any).editingCommentId).toBe(comment.id); expect((component as any).editingCommentId).toBe(comment.id);
expect((component as any).editingCommentText).toBe('Editable'); expect((component as any).editingCommentText).toBe('Editable');
}); });
it('cancels any active task creation or link mode', () => {
(component as any).newCommentText = 'X';
(component as any).addComment();
const comment = store.getById(1)!.comments[0];
(component as any).creatingTaskForCommentId = comment.id;
(component as any).linkingIssueForCommentId = comment.id;
(component as any).startEditComment(comment);
expect((component as any).creatingTaskForCommentId).toBeNull();
expect((component as any).linkingIssueForCommentId).toBeNull();
});
}); });
describe('cancelEditComment', () => { describe('cancelEditComment', () => {
@@ -307,4 +334,252 @@ describe('IssueComments', () => {
expect(store.getById(1)?.comments[0].text).toBe('Keep me'); expect(store.getById(1)?.comments[0].text).toBe('Keep me');
}); });
}); });
describe('createTaskForComment', () => {
beforeEach(async () => {
(component as any).newCommentText = 'CR réunion';
await (component as any).addComment();
});
it('creates a Task issue in the store', async () => {
const commentId = store.getById(1)!.comments[0].id;
(component as any).newTaskName = 'Action item';
await (component as any).createTaskForComment(commentId);
const tasks = store.issues().filter((i) => i.type === 'Task');
expect(tasks.length).toBe(1);
expect(tasks[0].name).toBe('Action item');
});
it('links the created task to the comment', async () => {
const commentId = store.getById(1)!.comments[0].id;
(component as any).newTaskName = 'Action item';
await (component as any).createTaskForComment(commentId);
const tasks = store.issues().filter((i) => i.type === 'Task');
const comment = store.getById(1)!.comments[0];
expect(comment.linkedIssueIds).toContain(tasks[0].id);
});
it('resets task form state after creation', async () => {
const commentId = store.getById(1)!.comments[0].id;
(component as any).newTaskName = 'Task';
await (component as any).createTaskForComment(commentId);
expect((component as any).creatingTaskForCommentId).toBeNull();
expect((component as any).newTaskName).toBe('');
});
it('only adds the task link to the target comment when multiple exist', async () => {
(component as any).newCommentText = 'Second comment';
await (component as any).addComment();
const comments = store.getById(1)!.comments;
const firstId = comments[0].id;
const secondId = comments[1].id;
(component as any).newTaskName = 'Task for first';
await (component as any).createTaskForComment(firstId);
expect(store.getById(1)!.comments.find((c: any) => c.id === firstId)!.linkedIssueIds.length).toBe(1);
expect(store.getById(1)!.comments.find((c: any) => c.id === secondId)!.linkedIssueIds.length).toBe(0);
});
it('does nothing when newTaskName is empty', async () => {
const commentId = store.getById(1)!.comments[0].id;
(component as any).newTaskName = ' ';
await (component as any).createTaskForComment(commentId);
expect(store.issues().filter((i) => i.type === 'Task').length).toBe(0);
});
});
describe('linkIssueToComment', () => {
beforeEach(async () => {
await store.upsert(makeIssue({ id: 2, name: 'Other issue' }));
(component as any).newCommentText = 'Un commentaire';
await (component as any).addComment();
});
it('adds the issue id to the comment linkedIssueIds', async () => {
const commentId = store.getById(1)!.comments[0].id;
await (component as any).linkIssueToComment(commentId, 2);
expect(store.getById(1)!.comments[0].linkedIssueIds).toContain(2);
});
it('does not add the same issue twice', async () => {
const commentId = store.getById(1)!.comments[0].id;
await (component as any).linkIssueToComment(commentId, 2);
await (component as any).linkIssueToComment(commentId, 2);
expect(store.getById(1)!.comments[0].linkedIssueIds.filter((id: number) => id === 2).length).toBe(1);
});
it('resets link form state after linking', async () => {
const commentId = store.getById(1)!.comments[0].id;
(component as any).issueSearchText = 'Other';
await (component as any).linkIssueToComment(commentId, 2);
expect((component as any).linkingIssueForCommentId).toBeNull();
expect((component as any).issueSearchText).toBe('');
});
it('only modifies the target comment when multiple exist', async () => {
(component as any).newCommentText = 'Second';
await (component as any).addComment();
const comments = store.getById(1)!.comments;
const firstId = comments[0].id;
const secondId = comments[1].id;
await (component as any).linkIssueToComment(firstId, 2);
expect(store.getById(1)!.comments.find((c: any) => c.id === firstId)!.linkedIssueIds).toContain(2);
expect(store.getById(1)!.comments.find((c: any) => c.id === secondId)!.linkedIssueIds).toEqual([]);
});
});
describe('unlinkIssueFromComment', () => {
beforeEach(async () => {
await store.upsert(makeIssue({ id: 2, name: 'Linked issue' }));
(component as any).newCommentText = 'Commentaire avec lien';
await (component as any).addComment();
const commentId = store.getById(1)!.comments[0].id;
await (component as any).linkIssueToComment(commentId, 2);
});
it('removes the issue id from linkedIssueIds', async () => {
const commentId = store.getById(1)!.comments[0].id;
await (component as any).unlinkIssueFromComment(commentId, 2);
expect(store.getById(1)!.comments[0].linkedIssueIds).not.toContain(2);
});
it('does not modify other comments when unlinking', async () => {
(component as any).newCommentText = 'Second comment';
await (component as any).addComment();
const comments = store.getById(1)!.comments;
const firstId = comments[0].id;
await (component as any).unlinkIssueFromComment(firstId, 2);
expect(store.getById(1)!.comments.find((c: any) => c.id !== firstId)!.linkedIssueIds).toEqual([]);
});
});
describe('getLinkedIssues', () => {
it('returns empty array when no linked issues', async () => {
(component as any).newCommentText = 'Test';
await (component as any).addComment();
const comment = store.getById(1)!.comments[0];
expect((component as any).getLinkedIssues(comment)).toEqual([]);
});
it('returns resolved IssueEntity objects for linked ids', async () => {
await store.upsert(makeIssue({ id: 2, name: 'Linked' }));
(component as any).newCommentText = 'Test';
await (component as any).addComment();
const commentId = store.getById(1)!.comments[0].id;
await (component as any).linkIssueToComment(commentId, 2);
const comment = store.getById(1)!.comments[0];
const linked = (component as any).getLinkedIssues(comment);
expect(linked.length).toBe(1);
expect(linked[0].name).toBe('Linked');
});
});
describe('filteredIssuesForLink', () => {
beforeEach(async () => {
await store.upsert(makeIssue({ id: 2, name: 'Alpha issue' }));
await store.upsert(makeIssue({ id: 3, name: 'Beta issue' }));
(component as any).newCommentText = 'Commentaire';
await (component as any).addComment();
});
it('excludes the current issue from results', () => {
const commentId = store.getById(1)!.comments[0].id;
const results = (component as any).filteredIssuesForLink(commentId);
expect(results.find((i: IssueEntity) => i.id === 1)).toBeUndefined();
});
it('filters by search text', () => {
(component as any).issueSearchText = 'alpha';
const commentId = store.getById(1)!.comments[0].id;
const results = (component as any).filteredIssuesForLink(commentId);
expect(results.length).toBe(1);
expect(results[0].name).toBe('Alpha issue');
});
it('excludes already linked issues', async () => {
const commentId = store.getById(1)!.comments[0].id;
await (component as any).linkIssueToComment(commentId, 2);
const results = (component as any).filteredIssuesForLink(commentId);
expect(results.find((i: IssueEntity) => i.id === 2)).toBeUndefined();
});
});
describe('startCreateTask', () => {
it('sets creatingTaskForCommentId and clears other modes', () => {
(component as any).linkingIssueForCommentId = 99;
(component as any).issueSearchText = 'foo';
(component as any).startCreateTask(5);
expect((component as any).creatingTaskForCommentId).toBe(5);
expect((component as any).newTaskName).toBe('');
expect((component as any).linkingIssueForCommentId).toBeNull();
expect((component as any).issueSearchText).toBe('');
});
});
describe('cancelCreateTask', () => {
it('resets creatingTaskForCommentId and newTaskName', () => {
(component as any).creatingTaskForCommentId = 3;
(component as any).newTaskName = 'Unfinished task';
(component as any).cancelCreateTask();
expect((component as any).creatingTaskForCommentId).toBeNull();
expect((component as any).newTaskName).toBe('');
});
});
describe('startLinkIssue', () => {
it('sets linkingIssueForCommentId and clears other modes', () => {
(component as any).creatingTaskForCommentId = 7;
(component as any).newTaskName = 'in progress';
(component as any).startLinkIssue(4);
expect((component as any).linkingIssueForCommentId).toBe(4);
expect((component as any).issueSearchText).toBe('');
expect((component as any).creatingTaskForCommentId).toBeNull();
expect((component as any).newTaskName).toBe('');
});
});
describe('cancelLinkIssue', () => {
it('resets linkingIssueForCommentId and issueSearchText', () => {
(component as any).linkingIssueForCommentId = 2;
(component as any).issueSearchText = 'something';
(component as any).cancelLinkIssue();
expect((component as any).linkingIssueForCommentId).toBeNull();
expect((component as any).issueSearchText).toBe('');
});
});
describe('typeIcon', () => {
it('returns the correct letter and bg for Task', () => {
const icon = (component as any).typeIcon('Task');
expect(icon.letter).toBe('T');
expect(icon.bg).toBe('#2563eb');
});
});
describe('statusLabel', () => {
it('returns label and colors for done status', () => {
const s = (component as any).statusLabel('done');
expect(s.label).toBe('Terminé');
expect(s.color).toBe('#166534');
});
});
}); });
+139 -3
View File
@@ -1,13 +1,14 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked'; import { marked } from 'marked';
import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { IssueComment, IssuesStore } from '../issues.store'; import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
@Component({ @Component({
selector: 'app-issue-comments', selector: 'app-issue-comments',
imports: [FormsModule], imports: [FormsModule, RouterLink],
templateUrl: './issue-comments.html', templateUrl: './issue-comments.html',
styleUrl: './issue-comments.css', styleUrl: './issue-comments.css',
}) })
@@ -21,10 +22,20 @@ export class IssueComments {
() => this.issuesStore.issues().find((i) => i.id === this.issueId())?.comments ?? [], () => 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 newCommentText = '';
protected editingCommentId: number | null = null; protected editingCommentId: number | null = null;
protected editingCommentText = ''; protected editingCommentText = '';
protected creatingTaskForCommentId: number | null = null;
protected newTaskName = '';
protected linkingIssueForCommentId: number | null = null;
protected issueSearchText = '';
protected parseMarkdown(text: string): SafeHtml { protected parseMarkdown(text: string): SafeHtml {
const html = marked.parse(text) as string; const html = marked.parse(text) as string;
return this.sanitizer.bypassSecurityTrustHtml(html); return this.sanitizer.bypassSecurityTrustHtml(html);
@@ -46,7 +57,13 @@ export class IssueComments {
const issue = this.issuesStore.getById(this.issueId()); const issue = this.issuesStore.getById(this.issueId());
if (!issue) return; if (!issue) return;
const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1; const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1;
const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null }; const comment: IssueComment = {
id: nextId,
text,
createdAt: new Date().toISOString(),
updatedAt: null,
linkedIssueIds: [],
};
await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] }); await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
this.newCommentText = ''; this.newCommentText = '';
} }
@@ -54,6 +71,8 @@ export class IssueComments {
protected startEditComment(comment: IssueComment): void { protected startEditComment(comment: IssueComment): void {
this.editingCommentId = comment.id; this.editingCommentId = comment.id;
this.editingCommentText = comment.text; this.editingCommentText = comment.text;
this.creatingTaskForCommentId = null;
this.linkingIssueForCommentId = null;
} }
protected async saveEditComment(): Promise<void> { protected async saveEditComment(): Promise<void> {
@@ -92,4 +111,121 @@ export class IssueComments {
if (!issue) return; if (!issue) return;
await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) }); 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']): { label: string; bg: string; color: string } {
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = {
draft: { label: 'Brouillon', bg: '#e2e8f0', color: '#475569' },
todo: { label: 'À faire', bg: '#dbeafe', color: '#1d4ed8' },
'in-progress': { label: 'En cours', bg: '#ffedd5', color: '#9a3412' },
done: { label: 'Terminé', bg: '#dcfce7', color: '#166534' },
};
return map[status] ?? { label: status, bg: '#e2e8f0', color: '#475569' };
}
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: '',
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 });
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 });
}
} }
+18 -4
View File
@@ -11,6 +11,7 @@ export type IssueComment = {
text: string; text: string;
createdAt: string; createdAt: string;
updatedAt: string | null; updatedAt: string | null;
linkedIssueIds: number[];
}; };
export type IssueEntity = { export type IssueEntity = {
@@ -67,9 +68,17 @@ export class IssuesStore {
this.data.update((issues) => [...issues, created]); this.data.update((issues) => [...issues, created]);
return created; return created;
} else { } else {
const updated = this.normalizeIssue( const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized));
await firstValueFrom(this.api.update(normalized.id, normalized)), // L'API ne retourne pas linkedIssueIds dans les commentaires : on le restaure
); // depuis les données envoyées pour ne pas perdre les liens.
if (Array.isArray(apiResult.comments) && Array.isArray(normalized.comments)) {
apiResult.comments = apiResult.comments.map((c: IssueComment) => {
if (Array.isArray(c.linkedIssueIds)) return c;
const sent = normalized.comments.find((nc) => nc.id === c.id);
return { ...c, linkedIssueIds: sent?.linkedIssueIds ?? [] };
});
}
const updated = this.normalizeIssue(apiResult);
this.data.update((issues) => { this.data.update((issues) => {
const idx = issues.findIndex((i) => i.id === normalized.id); const idx = issues.findIndex((i) => i.id === normalized.id);
if (idx === -1) return issues; if (idx === -1) return issues;
@@ -103,7 +112,12 @@ export class IssuesStore {
type: issue.type ?? 'Story', type: issue.type ?? 'Story',
estimatedTime: issue.estimatedTime ?? null, estimatedTime: issue.estimatedTime ?? null,
dependsOnIds: normalizedDependencies, dependsOnIds: normalizedDependencies,
comments: Array.isArray(issue.comments) ? issue.comments : [], comments: Array.isArray(issue.comments)
? issue.comments.map((c) => ({
...c,
linkedIssueIds: Array.isArray(c.linkedIssueIds) ? c.linkedIssueIds : [],
}))
: [],
} as IssueEntity; } as IssueEntity;
} }
} }