Ajout commentaire + icone menu
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
.section-header {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #6b7280;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edited {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-btn:hover {
|
||||||
|
color: #111827;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-delete:hover {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text :is(h1, h2, h3, h4) {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text p {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-new {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!-- suppress HtmlUnknownAttribute -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header section-header">Commentaires</div>
|
||||||
|
<div class="card-body d-flex flex-column gap-3">
|
||||||
|
|
||||||
|
@for (comment of comments(); track comment.id) {
|
||||||
|
<div class="comment-item">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||||
|
@if (comment.updatedAt) {
|
||||||
|
<span class="comment-edited">(modifié le {{ formatDate(comment.updatedAt) }})</span>
|
||||||
|
}
|
||||||
|
<div class="comment-actions">
|
||||||
|
<button type="button" class="comment-action-btn" (click)="startEditComment(comment)">Modifier</button>
|
||||||
|
<button type="button" class="comment-action-btn comment-action-delete" (click)="deleteComment(comment.id)">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editingCommentId === comment.id) {
|
||||||
|
<textarea
|
||||||
|
aria-label="Modifier le commentaire"
|
||||||
|
class="form-control form-control-sm mt-2"
|
||||||
|
rows="3"
|
||||||
|
[(ngModel)]="editingCommentText"
|
||||||
|
(keydown.escape)="cancelEditComment()"
|
||||||
|
></textarea>
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" (click)="saveEditComment()" [disabled]="!editingCommentText.trim()">Enregistrer</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelEditComment()">Annuler</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="comment-text markdown-body mt-1" [innerHTML]="parseMarkdown(comment.text)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="comment-new">
|
||||||
|
<textarea
|
||||||
|
aria-label="Nouveau commentaire"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Ajouter un commentaire... (Markdown supporté)"
|
||||||
|
[(ngModel)]="newCommentText"
|
||||||
|
></textarea>
|
||||||
|
<div class="d-flex justify-content-end mt-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" (click)="addComment()" [disabled]="!newCommentText.trim()">Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { IssueComment, IssuesStore } from '../issues.store';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-issue-comments',
|
||||||
|
imports: [FormsModule],
|
||||||
|
templateUrl: './issue-comments.html',
|
||||||
|
styleUrl: './issue-comments.css',
|
||||||
|
})
|
||||||
|
export class IssueComments {
|
||||||
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
|
|
||||||
|
readonly issueId = input.required<number>();
|
||||||
|
|
||||||
|
protected readonly comments = computed(
|
||||||
|
() => this.issuesStore.issues().find((i) => i.id === this.issueId())?.comments ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
protected newCommentText = '';
|
||||||
|
protected editingCommentId: number | null = null;
|
||||||
|
protected editingCommentText = '';
|
||||||
|
|
||||||
|
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 addComment(): 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 };
|
||||||
|
this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
|
||||||
|
this.newCommentText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startEditComment(comment: IssueComment): void {
|
||||||
|
this.editingCommentId = comment.id;
|
||||||
|
this.editingCommentText = comment.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected saveEditComment(): 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,
|
||||||
|
);
|
||||||
|
this.issuesStore.upsert({ ...issue, comments: updatedComments });
|
||||||
|
this.editingCommentId = null;
|
||||||
|
this.editingCommentText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancelEditComment(): void {
|
||||||
|
this.editingCommentId = null;
|
||||||
|
this.editingCommentText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected deleteComment(id: number): void {
|
||||||
|
const issue = this.issuesStore.getById(this.issueId());
|
||||||
|
if (!issue) return;
|
||||||
|
this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -247,6 +247,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Commentaires -->
|
||||||
|
@if (!isNewIssueRoute) {
|
||||||
|
<app-issue-comments [issueId]="issue.id" />
|
||||||
|
}
|
||||||
|
|
||||||
@if (isNewIssueRoute) {
|
@if (isNewIssueRoute) {
|
||||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancelCreation()">Annuler</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="cancelCreation()">Annuler</button>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { IssueEntity, IssuesStore } from '../issues.store';
|
import { IssueEntity, IssuesStore } from '../issues.store';
|
||||||
|
import { IssueComments } from '../issue-comments/issue-comments';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-issue-detail',
|
selector: 'app-issue-detail',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, IssueComments],
|
||||||
templateUrl: './issue-detail.html',
|
templateUrl: './issue-detail.html',
|
||||||
styleUrl: './issue-detail.css',
|
styleUrl: './issue-detail.css',
|
||||||
})
|
})
|
||||||
@@ -158,6 +159,7 @@ export class IssueDetail {
|
|||||||
description: '',
|
description: '',
|
||||||
estimatedTime: null,
|
estimatedTime: null,
|
||||||
dependsOnIds: [],
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
priority: 'Moyenne',
|
priority: 'Moyenne',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -277,6 +279,7 @@ export class IssueDetail {
|
|||||||
description: '',
|
description: '',
|
||||||
estimatedTime: null,
|
estimatedTime: null,
|
||||||
dependsOnIds: [],
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
priority: 'Moyenne',
|
priority: 'Moyenne',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -296,6 +299,7 @@ export class IssueDetail {
|
|||||||
description: '',
|
description: '',
|
||||||
estimatedTime: null,
|
estimatedTime: null,
|
||||||
dependsOnIds: [],
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
priority: 'Moyenne',
|
priority: 'Moyenne',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress';
|
|||||||
export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute';
|
export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute';
|
||||||
export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story';
|
export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story';
|
||||||
|
|
||||||
|
export type IssueComment = {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type IssueEntity = {
|
export type IssueEntity = {
|
||||||
id: number;
|
id: number;
|
||||||
type: IssueType;
|
type: IssueType;
|
||||||
@@ -16,6 +23,7 @@ export type IssueEntity = {
|
|||||||
description: string;
|
description: string;
|
||||||
estimatedTime: number | null;
|
estimatedTime: number | null;
|
||||||
dependsOnIds: number[];
|
dependsOnIds: number[];
|
||||||
|
comments: IssueComment[];
|
||||||
priority: IssuePriority;
|
priority: IssuePriority;
|
||||||
status: IssueStatus;
|
status: IssueStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -32,6 +40,7 @@ const DEFAULT_ISSUES: IssueEntity[] = [
|
|||||||
description: 'Corriger le comportement du menu sur petits ecrans.',
|
description: 'Corriger le comportement du menu sur petits ecrans.',
|
||||||
estimatedTime: 8,
|
estimatedTime: 8,
|
||||||
dependsOnIds: [],
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
priority: 'Haute',
|
priority: 'Haute',
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
progress: 35,
|
progress: 35,
|
||||||
@@ -46,6 +55,7 @@ const DEFAULT_ISSUES: IssueEntity[] = [
|
|||||||
description: 'Fiabiliser les regles de validation du formulaire projet.',
|
description: 'Fiabiliser les regles de validation du formulaire projet.',
|
||||||
estimatedTime: 16,
|
estimatedTime: 16,
|
||||||
dependsOnIds: [],
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
priority: 'Moyenne',
|
priority: 'Moyenne',
|
||||||
status: 'todo',
|
status: 'todo',
|
||||||
progress: 20,
|
progress: 20,
|
||||||
@@ -60,6 +70,7 @@ const DEFAULT_ISSUES: IssueEntity[] = [
|
|||||||
description: 'Mettre a jour le wording d accueil selon la charte produit.',
|
description: 'Mettre a jour le wording d accueil selon la charte produit.',
|
||||||
estimatedTime: 4,
|
estimatedTime: 4,
|
||||||
dependsOnIds: [],
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
priority: 'Basse',
|
priority: 'Basse',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
progress: 100,
|
progress: 100,
|
||||||
@@ -135,6 +146,7 @@ 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 : [],
|
||||||
} as IssueEntity;
|
} as IssueEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user