From cd93533b7c97d21375bbd6229c34d5ae22c6aed9 Mon Sep 17 00:00:00 2001 From: Gato Date: Thu, 28 May 2026 18:50:36 +0200 Subject: [PATCH] Ajout issue dans les commentaires Signed-off-by: Gato --- .../issues/issue-comments/issue-comments.css | 149 ++++++++++ .../issues/issue-comments/issue-comments.html | 91 ++++++ .../issue-comments/issue-comments.spec.ts | 279 +++++++++++++++++- .../issues/issue-comments/issue-comments.ts | 142 ++++++++- src/app/issues/issues.store.ts | 22 +- 5 files changed, 674 insertions(+), 9 deletions(-) diff --git a/src/app/issues/issue-comments/issue-comments.css b/src/app/issues/issue-comments/issue-comments.css index fdef573..e6e7298 100644 --- a/src/app/issues/issue-comments/issue-comments.css +++ b/src/app/issues/issue-comments/issue-comments.css @@ -90,3 +90,152 @@ .comment-new { 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; +} diff --git a/src/app/issues/issue-comments/issue-comments.html b/src/app/issues/issue-comments/issue-comments.html index 70e8705..ac23325 100644 --- a/src/app/issues/issue-comments/issue-comments.html +++ b/src/app/issues/issue-comments/issue-comments.html @@ -31,6 +31,97 @@ } @else {
+ + + @if (getLinkedIssues(comment).length > 0) { +
+ @for (linked of getLinkedIssues(comment); track linked.id) { +
+ {{ typeIcon(linked.type).letter }} + {{ linked.name }} + {{ statusLabel(linked.status).label }} + +
+ } +
+ } + + + @if (creatingTaskForCommentId === comment.id) { +
+ +
+ + +
+
+ } @else if (linkingIssueForCommentId === comment.id) { + + } @else { + + } } } diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 3b7d8f3..5aeb88b 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { afterEach, vi } from 'vitest'; import { IssueComments } from './issue-comments'; import { IssueEntity, IssuesStore } from '../issues.store'; @@ -43,11 +44,18 @@ class FakeIssuesStore { upsert(issue: any): Promise { const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue; + const id = rest.id || this.getNextId(); const normalized: IssueEntity = { ...makeIssue(), ...rest, + id, 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, }; this._data.update((issues) => { @@ -78,7 +86,7 @@ describe('IssueComments', () => { beforeEach(async () => { store = new FakeIssuesStore(); await TestBed.configureTestingModule({ - imports: [IssueComments], + imports: [IssueComments, RouterTestingModule], providers: [{ provide: IssuesStore, useValue: store }], }).compileComponents(); @@ -166,6 +174,12 @@ describe('IssueComments', () => { (component as any).addComment(); 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', () => { @@ -179,6 +193,19 @@ describe('IssueComments', () => { expect((component as any).editingCommentId).toBe(comment.id); 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', () => { @@ -307,4 +334,252 @@ describe('IssueComments', () => { 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'); + }); + }); }); diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index 1408481..a4687b6 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -1,13 +1,14 @@ 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, IssuesStore } from '../issues.store'; +import { IssueComment, IssueEntity, IssuesStore } from '../issues.store'; @Component({ selector: 'app-issue-comments', - imports: [FormsModule], + imports: [FormsModule, RouterLink], templateUrl: './issue-comments.html', styleUrl: './issue-comments.css', }) @@ -21,10 +22,20 @@ export class IssueComments { () => 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); @@ -46,7 +57,13 @@ export class IssueComments { 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 }; + const comment: IssueComment = { + id: nextId, + text, + createdAt: new Date().toISOString(), + updatedAt: null, + linkedIssueIds: [], + }; await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] }); this.newCommentText = ''; } @@ -54,6 +71,8 @@ export class IssueComments { protected startEditComment(comment: IssueComment): void { this.editingCommentId = comment.id; this.editingCommentText = comment.text; + this.creatingTaskForCommentId = null; + this.linkingIssueForCommentId = null; } protected async saveEditComment(): Promise { @@ -92,4 +111,121 @@ export class IssueComments { 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 = { + 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 = { + 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 { + 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 { + 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 { + 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 }); + } } diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 464a6b8..4a72186 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -11,6 +11,7 @@ export type IssueComment = { text: string; createdAt: string; updatedAt: string | null; + linkedIssueIds: number[]; }; export type IssueEntity = { @@ -67,9 +68,17 @@ export class IssuesStore { this.data.update((issues) => [...issues, created]); return created; } else { - const updated = this.normalizeIssue( - await firstValueFrom(this.api.update(normalized.id, normalized)), - ); + const apiResult = 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) => { const idx = issues.findIndex((i) => i.id === normalized.id); if (idx === -1) return issues; @@ -103,7 +112,12 @@ export class IssuesStore { type: issue.type ?? 'Story', estimatedTime: issue.estimatedTime ?? null, 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; } }