Refacto issue detail

This commit is contained in:
2026-05-23 09:21:44 +02:00
parent 4960292ecf
commit 4a2be2758e
5 changed files with 321 additions and 444 deletions
+13
View File
@@ -15,6 +15,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"marked": "^18.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@@ -6181,6 +6182,18 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"marked": "^18.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
+111 -291
View File
@@ -2,352 +2,172 @@
display: block; display: block;
} }
.page-header { /* Section headers */
display: flex; .section-header {
justify-content: space-between; font-size: 0.7rem;
align-items: flex-start;
gap: 1rem;
}
.header-meta {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.status-inline {
display: flex;
align-items: center;
gap: 0.5rem;
background: #ffffff;
border: 1px solid #dbe4f0;
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
}
.status-label {
font-size: 0.875rem;
font-weight: 700; font-weight: 700;
color: #374151; text-transform: uppercase;
letter-spacing: 0.07em;
color: #6b7280;
background-color: #f9fafb;
} }
.status-select { /* Field labels */
min-width: 9rem; .field-label {
} display: block;
font-size: 0.78rem;
.header-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.page-header h1 {
margin: 0;
font-size: 2rem;
}
.page-header p {
margin: 0.5rem 0 1.5rem;
color: #4b5563;
}
.edit-button {
border: none;
border-radius: 0.5rem;
background-color: #2563eb;
color: #ffffff;
padding: 0.65rem 1rem;
font-weight: 600; font-weight: 600;
cursor: pointer; color: #374151;
margin-bottom: 0.3rem;
} }
.edit-button:hover { /* Title input */
background-color: #1d4ed8; .title-input::placeholder {
color: #9ca3af;
font-weight: 400;
} }
/* More menu */
.more-wrapper { .more-wrapper {
position: relative; position: relative;
} }
.more-button {
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background-color: #ffffff;
color: #374151;
padding: 0.65rem 1rem;
font-weight: 600;
cursor: pointer;
}
.more-button:hover {
background-color: #f9fafb;
}
.more-menu { .more-menu {
position: absolute; position: absolute;
right: 0; right: 0;
top: calc(100% + 0.5rem); top: calc(100% + 0.35rem);
min-width: 10rem; min-width: 10rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
padding: 0.25rem;
z-index: 10; z-index: 10;
} }
.more-menu-item { /* Dependency badges */
width: 100%; .dep-badge {
border: none; display: inline-flex;
background: transparent;
padding: 0.65rem 0.85rem;
border-radius: 0.5rem;
text-align: left;
font-weight: 600;
cursor: pointer;
}
.more-menu-item:hover {
background: #f3f4f6;
}
.delete-action {
color: #b91c1c;
}
.save-button {
border: none;
border-radius: 0.5rem;
background-color: #059669;
color: #ffffff;
padding: 0.65rem 1rem;
font-weight: 600;
cursor: pointer;
}
.save-button:hover {
background-color: #047857;
}
.cancel-button {
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background-color: #ffffff;
color: #374151;
padding: 0.65rem 1rem;
font-weight: 600;
cursor: pointer;
}
.cancel-button:hover {
background-color: #f3f4f6;
}
.dependency-list {
list-style: none;
margin: 0 0 0.5rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.dependency-item {
display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.4rem;
gap: 0.5rem;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
background: #f3f4f6;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 0.4rem; border-radius: 999px;
background: #f9fafb; font-size: 0.85rem;
}
.dependency-label {
font-size: 0.875rem;
color: #374151; color: #374151;
} }
.dependency-remove { .dep-id {
flex-shrink: 0; font-weight: 700;
color: #6b7280;
font-size: 0.78rem;
}
.dep-remove {
border: none; border: none;
background: transparent; background: transparent;
color: #9ca3af; color: #9ca3af;
font-size: 1.1rem; font-size: 1rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
padding: 0 0.15rem; padding: 0;
border-radius: 0.25rem; border-radius: 50%;
width: 1.1rem;
height: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
} }
.dependency-remove:hover { .dep-remove:hover {
color: #b91c1c; color: #b91c1c;
background: #fee2e2; background: #fee2e2;
} }
.dependency-add-btn { .dep-select {
margin-top: 0.25rem;
}
.dependency-add-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.dependency-select {
flex: 1; flex: 1;
min-width: 200px;
} }
.epic-issues-card { /* Description */
margin-top: 1rem; .description-textarea {
background-color: #ffffff; min-height: 40rem;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1rem;
}
.epic-issues-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.epic-issues-header h2 {
margin: 0;
font-size: 1.1rem;
}
.epic-issues-header span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
font-weight: 700;
}
.epic-empty {
margin: 0;
color: #6b7280;
}
.epic-issues-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.epic-issue-item {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
background: #f9fafb;
}
.epic-issue-item strong,
.epic-issue-item p {
display: block;
}
.epic-issue-item p {
margin: 0.25rem 0 0;
color: #6b7280;
}
.epic-issue-item span {
color: #374151;
font-weight: 600;
white-space: nowrap;
}
.detail-card {
background-color: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.9rem 1rem;
border-bottom: 1px solid #e5e7eb;
text-align: left;
vertical-align: top;
}
input,
select,
textarea {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font: inherit;
box-sizing: border-box;
}
textarea {
resize: vertical; resize: vertical;
} }
th { .description-preview {
width: 220px; min-height: 7rem;
background-color: #f9fafb; white-space: pre-wrap;
font-size: 0.9rem;
color: #374151;
cursor: text;
border-radius: 0.375rem;
padding: 0.25rem 0.35rem;
line-height: 1.6;
}
.description-preview:hover {
background: #f9fafb;
}
.description-placeholder {
color: #9ca3af;
}
.markdown-body {
font-size: 0.9rem;
line-height: 1.7;
color: #374151; color: #374151;
} }
tr:last-child th, .markdown-body :is(h1, h2, h3, h4, h5, h6) {
tr:last-child td {
border-bottom: none;
}
.form-actions {
margin-top: 1rem; margin-top: 1rem;
display: flex; margin-bottom: 0.4rem;
justify-content: flex-end; font-weight: 700;
gap: 0.75rem; line-height: 1.3;
} }
@media (max-width: 768px) { .markdown-body h1 { font-size: 1.4rem; }
.page-header { .markdown-body h2 { font-size: 1.2rem; }
flex-direction: column; .markdown-body h3 { font-size: 1rem; }
.markdown-body p {
margin-bottom: 0.6rem;
} }
.header-meta { .markdown-body ul,
width: 100%; .markdown-body ol {
justify-content: flex-start; padding-left: 1.4rem;
margin-bottom: 0.6rem;
} }
.status-inline { .markdown-body code {
width: 100%; background: #f3f4f6;
border-radius: 0.25rem;
padding: 0.1em 0.35em;
font-size: 0.85em;
} }
.status-select { .markdown-body pre {
width: 100%; background: #f3f4f6;
border-radius: 0.4rem;
padding: 0.75rem 1rem;
overflow-x: auto;
margin-bottom: 0.6rem;
} }
th { .markdown-body pre code {
width: 40%; background: none;
} padding: 0;
} }
.markdown-body blockquote {
border-left: 3px solid #d1d5db;
padding-left: 0.75rem;
color: #6b7280;
margin: 0 0 0.6rem;
}
.markdown-body a {
color: #2563eb;
}
.markdown-body > *:last-child {
margin-bottom: 0;
}
+153 -131
View File
@@ -1,15 +1,15 @@
<!-- suppress HtmlUnknownAttribute --> <!-- suppress HtmlUnknownAttribute -->
<header class="page-header mb-4">
<div> <!-- Top bar -->
<h1>Detail de l'issue</h1> <div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<p>Informations de creation et de suivi de l'issue.</p> <div class="d-flex align-items-center gap-2">
<span [class]="'badge ' + typeBadgeClass">{{ issue.type }}</span>
<span class="text-secondary fw-semibold small">#{{ issue.id }}</span>
</div> </div>
<div class="header-meta d-flex align-items-start gap-3 flex-wrap justify-content-end"> <div class="d-flex align-items-center gap-2">
<div class="status-inline d-flex align-items-center gap-2 bg-white border rounded-3 px-3 py-2 shadow-sm">
<span class="status-label">Status</span>
<select <select
aria-label="Status" aria-label="Status"
class="status-select form-select form-select-sm w-auto" class="form-select form-select-sm w-auto"
[ngModel]="issue.status" [ngModel]="issue.status"
(ngModelChange)="updateStatus($event)" (ngModelChange)="updateStatus($event)"
> >
@@ -17,156 +17,178 @@
<option [value]="status">{{ status }}</option> <option [value]="status">{{ status }}</option>
} }
</select> </select>
</div>
<div class="header-actions d-flex gap-2 flex-wrap justify-content-end">
<div class="more-wrapper"> <div class="more-wrapper">
<button type="button" class="more-button btn btn-outline-secondary btn-sm dropdown-toggle" (click)="toggleMoreMenu()">More</button> <button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
@if (moreMenuOpen) { @if (moreMenuOpen) {
<div class="more-menu dropdown-menu show position-absolute end-0 mt-2 p-1 shadow"> <div class="more-menu dropdown-menu show">
<button type="button" class="more-menu-item delete-action dropdown-item text-danger" (click)="deleteIssue()"> <button type="button" class="dropdown-item text-danger" (click)="deleteIssue()">Supprimer</button>
Supprimer
</button>
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>
</header>
<section class="detail-card card shadow-sm" aria-label="Informations de l'issue"> <!-- Titre -->
<table class="table table-borderless align-middle mb-0"> <div class="card shadow-sm mb-3">
<tbody> <div class="card-body py-2">
<tr> <input
<th>ID</th> aria-label="Titre"
<td>{{ issue.id }}</td> class="title-input form-control border-0 shadow-none p-0 fw-semibold fs-5"
</tr> type="text"
<tr> placeholder="Titre de l'issue..."
<th>Nom</th> [(ngModel)]="issue.name"
<td> (blur)="saveIssue()"
<input aria-label="Nom" class="form-control form-control-sm" type="text" [(ngModel)]="issue.name" (blur)="saveIssue()" /> />
</td> </div>
</tr> </div>
<tr>
<th>Type</th> <!-- Détails + Planning -->
<td> <div class="row g-3 mb-3">
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header section-header">Détails</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<label class="field-label">Type</label>
<select aria-label="Type" class="form-select form-select-sm" [(ngModel)]="issueTypeValue" (change)="saveIssue()"> <select aria-label="Type" class="form-select form-select-sm" [(ngModel)]="issueTypeValue" (change)="saveIssue()">
@for (type of typeOptions; track type) { @for (type of typeOptions; track type) {
<option [value]="type">{{ type }}</option> <option [value]="type">{{ type }}</option>
} }
</select> </select>
</td>
</tr>
@if (!isEpicIssue) {
<tr>
<th>Epic</th>
<td>
<select aria-label="Epic" class="form-select form-select-sm" [(ngModel)]="issue.epic" (change)="saveIssue()">
<option value="">-</option>
@for (epicIssue of epicIssues; track epicIssue.id) {
<option [value]="epicIssue.name">{{ epicIssue.name }}</option>
}
</select>
</td>
</tr>
}
<tr>
<th>Depend de</th>
<td>
@if (dependencyIds.length > 0) {
<ul class="dependency-list">
@for (depId of dependencyIds; track depId) {
<li class="dependency-item">
<span class="dependency-label">#{{ depId }} - {{ resolveDependency(depId)?.name || 'Sans nom' }}</span>
<button type="button" class="dependency-remove" (click)="removeDependency(depId)" title="Supprimer">×</button>
</li>
}
</ul>
}
@if (showAddDependency) {
<div class="dependency-add-row">
<select aria-label="Choisir une dépendance" class="form-select form-select-sm dependency-select" [(ngModel)]="selectedCandidateId">
<option [ngValue]="null">Choisir une issue...</option>
@for (candidate of availableCandidates; track candidate.id) {
<option [ngValue]="candidate.id">#{{ candidate.id }} - {{ candidate.name || 'Sans nom' }}</option>
}
</select>
<button type="button" class="btn btn-sm btn-primary" (click)="confirmAddDependency()" [disabled]="selectedCandidateId === null">Ajouter</button>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelAddDependency()">Annuler</button>
</div> </div>
} @else { <div>
<button type="button" class="btn btn-sm btn-outline-primary dependency-add-btn" (click)="openAddDependency()">+ Ajouter une dépendance</button> <label class="field-label">Priorité</label>
}
</td>
</tr>
<tr>
<th>Assignee</th>
<td>
<input aria-label="Assignee" class="form-control form-control-sm" type="text" [(ngModel)]="issue.assignee" (blur)="saveIssue()" />
</td>
</tr>
<tr>
<th>Date d'echeance</th>
<td>
<input aria-label="Date d'échéance" class="form-control form-control-sm" type="date" [(ngModel)]="issue.dueDate" (blur)="saveIssue()" />
</td>
</tr>
<tr>
<th>Temps estimé</th>
<td>
<input aria-label="Temps estimé" class="form-control form-control-sm" type="number" min="0" step="0.5" [(ngModel)]="estimatedTimeValue" (blur)="saveIssue()" />
</td>
</tr>
<tr>
<th>Description</th>
<td>
<textarea aria-label="Description" class="form-control form-control-sm" rows="4" [(ngModel)]="issue.description" (blur)="saveIssue()"></textarea>
</td>
</tr>
<tr>
<th>Priorite</th>
<td>
<select aria-label="Priorité" class="form-select form-select-sm" [(ngModel)]="issue.priority" (change)="saveIssue()"> <select aria-label="Priorité" class="form-select form-select-sm" [(ngModel)]="issue.priority" (change)="saveIssue()">
<option value="Basse">Basse</option> <option value="Basse">Basse</option>
<option value="Moyenne">Moyenne</option> <option value="Moyenne">Moyenne</option>
<option value="Haute">Haute</option> <option value="Haute">Haute</option>
</select> </select>
</td> </div>
</tr> @if (!isEpicIssue) {
<tr> <div>
<th>Progression</th> <label class="field-label">Epic</label>
<td> <select aria-label="Epic" class="form-select form-select-sm" [(ngModel)]="issue.epic" (change)="saveIssue()">
<input aria-label="Progression" class="form-control form-control-sm" type="number" min="0" max="100" [(ngModel)]="issue.progress" (blur)="saveIssue()" /> <option value=""></option>
</td> @for (epicIssue of epicIssues; track epicIssue.id) {
</tr> <option [value]="epicIssue.name">{{ epicIssue.name }}</option>
</tbody> }
</table> </select>
</section> </div>
}
@if (isEpicIssue) { </div>
<section class="epic-issues-card card shadow-sm mt-4" aria-label="Issues composant cet epic"> </div>
<div class="epic-issues-header card-header d-flex align-items-center justify-content-between">
<h2 class="h5 mb-0">Issues composant cet Epic</h2>
<span class="badge text-bg-primary">{{ composedIssues.length }}</span>
</div> </div>
@if (composedIssues.length === 0) { <div class="col-md-7">
<div class="card shadow-sm h-100">
<div class="card-header section-header">Planning</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<label class="field-label">Assignee</label>
<input aria-label="Assignee" class="form-control form-control-sm" type="text" [(ngModel)]="issue.assignee" (blur)="saveIssue()" />
</div>
<div>
<label class="field-label">Date d'échéance</label>
<input aria-label="Date d'échéance" class="form-control form-control-sm" type="date" [(ngModel)]="issue.dueDate" (blur)="saveIssue()" />
</div>
<div class="row g-2">
<div class="col-6">
<label class="field-label">Temps estimé (h)</label>
<input aria-label="Temps estimé" class="form-control form-control-sm" type="number" min="0" step="0.5" [(ngModel)]="estimatedTimeValue" (blur)="saveIssue()" />
</div>
<div class="col-6">
<label class="field-label">Progression (%)</label>
<input aria-label="Progression" class="form-control form-control-sm" type="number" min="0" max="100" [(ngModel)]="issue.progress" (blur)="saveIssue()" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Description</div>
<div class="card-body"> <div class="card-body">
<p class="epic-empty mb-0 text-secondary">Aucune issue ne compose encore cet Epic.</p> @if (editingDescription) {
<textarea
aria-label="Description"
class="form-control border-0 shadow-none p-0 description-textarea"
placeholder="Ajouter une description..."
[(ngModel)]="issue.description"
(blur)="editingDescription = false; saveIssue()"
></textarea>
} @else {
<div
class="description-preview"
(click)="editingDescription = true"
title="Cliquer pour éditer"
>
@if (issue.description) {
<div class="markdown-body" [innerHTML]="descriptionHtml"></div>
} @else {
<span class="description-placeholder">Ajouter une description...</span>
}
</div>
}
</div>
</div>
<!-- Dépendances -->
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Dépendances</div>
<div class="card-body">
@if (dependencyIds.length > 0) {
<div class="d-flex flex-wrap gap-2 mb-3">
@for (depId of dependencyIds; track depId) {
<span class="dep-badge">
<span class="dep-id">#{{ depId }}</span>
{{ resolveDependency(depId)?.name || 'Sans nom' }}
<button type="button" class="dep-remove" (click)="removeDependency(depId)" title="Supprimer">×</button>
</span>
}
</div>
}
@if (showAddDependency) {
<div class="d-flex gap-2 flex-wrap">
<select aria-label="Choisir une dépendance" class="form-select form-select-sm dep-select" [(ngModel)]="selectedCandidateId">
<option [ngValue]="null">Choisir une issue...</option>
@for (candidate of availableCandidates; track candidate.id) {
<option [ngValue]="candidate.id">#{{ candidate.id }} {{ candidate.name || 'Sans nom' }}</option>
}
</select>
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmAddDependency()" [disabled]="selectedCandidateId === null">Ajouter</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelAddDependency()">Annuler</button>
</div> </div>
} @else { } @else {
<ul class="epic-issues-list list-group list-group-flush"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="openAddDependency()">+ Ajouter une dépendance</button>
@for (composedIssue of composedIssues; track composedIssue.id) { }
<li class="epic-issue-item list-group-item d-flex justify-content-between align-items-start gap-3">
<div class="grow">
<strong>#{{ composedIssue.id }} - {{ composedIssue.name || 'Sans nom' }}</strong>
<p class="mb-0 text-secondary">{{ composedIssue.type }} · {{ composedIssue.status }}</p>
</div> </div>
<span class="text-secondary fw-semibold text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span> </div>
<!-- Issues de l'epic -->
@if (isEpicIssue) {
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between section-header">
<span>Issues composant cet Epic</span>
<span class="badge text-bg-primary">{{ composedIssues.length }}</span>
</div>
@if (composedIssues.length === 0) {
<div class="card-body">
<p class="text-secondary mb-0 small">Aucune issue ne compose encore cet Epic.</p>
</div>
} @else {
<ul class="list-group list-group-flush">
@for (composedIssue of composedIssues; track composedIssue.id) {
<li class="list-group-item d-flex justify-content-between align-items-center gap-3 py-3">
<div>
<p class="mb-0 fw-semibold">#{{ composedIssue.id }} {{ composedIssue.name || 'Sans nom' }}</p>
<p class="mb-0 text-secondary small">{{ composedIssue.type }} · {{ composedIssue.status }}</p>
</div>
<span class="text-secondary small text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span>
</li> </li>
} }
</ul> </ul>
} }
</section> </div>
} }
@@ -1,6 +1,8 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { marked } from 'marked';
import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueEntity, IssuesStore } from '../issues.store';
@Component({ @Component({
@@ -13,6 +15,7 @@ export class IssueDetail {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly sanitizer = inject(DomSanitizer);
private readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; private readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
protected issue: IssueEntity = this.buildIssue(); protected issue: IssueEntity = this.buildIssue();
@@ -20,6 +23,7 @@ export class IssueDetail {
protected moreMenuOpen = false; protected moreMenuOpen = false;
protected showAddDependency = false; protected showAddDependency = false;
protected selectedCandidateId: number | null = null; protected selectedCandidateId: number | null = null;
protected editingDescription = false;
protected readonly statusOptions: IssueEntity['status'][] = [ protected readonly statusOptions: IssueEntity['status'][] = [
'draft', 'draft',
@@ -107,6 +111,23 @@ export class IssueDetail {
return this.issueTypeValue === 'Epic'; return this.issueTypeValue === 'Epic';
} }
protected get descriptionHtml(): SafeHtml {
const html = marked.parse(this.issue.description || '') as string;
return this.sanitizer.bypassSecurityTrustHtml(html);
}
protected get typeBadgeClass(): string {
const map: Record<IssueEntity['type'], string> = {
Bug: 'text-bg-danger',
Study: 'text-bg-secondary',
Story: 'text-bg-success',
Task: 'text-bg-primary',
'Technical Story': 'text-bg-warning',
Epic: 'text-bg-info',
};
return map[this.issueTypeValue] ?? 'text-bg-secondary';
}
protected saveIssue(): void { protected saveIssue(): void {
this.issuesStore.upsert(this.issue); this.issuesStore.upsert(this.issue);
if (this.isNewIssueRoute) { if (this.isNewIssueRoute) {