Ajout issue dans les commentaires
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,97 @@
|
||||
</div>
|
||||
} @else {
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -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<IssueEntity> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user