Modification visuel status et type issue

This commit is contained in:
2026-05-26 07:54:10 +02:00
parent a7e18e5807
commit 258fb1cd80
9 changed files with 220 additions and 37 deletions
+9 -1
View File
@@ -8,7 +8,15 @@
"Bash(npx ng *)",
"Bash(npm start *)",
"Bash(xargs cat -n)",
"Bash(xargs ls -la)"
"Bash(xargs ls -la)",
"Read(//home/Gato/IdeaProjects/Bonsai-webapp/src/**)",
"Read(//var/home/Gato/IdeaProjects/Bonsai-webapp/src/**)"
],
"additionalDirectories": [
"/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues",
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/issues",
"/home/Gato/IdeaProjects/Bonsai-webapp/src",
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src"
]
}
}
@@ -27,6 +27,49 @@
font-weight: 400;
}
/* Status split button */
.status-split-wrapper {
position: relative;
}
.status-main-btn {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.28rem 0.6rem;
border: 1px solid;
border-right: none;
border-radius: 3px 0 0 3px;
white-space: nowrap;
cursor: default;
}
.status-toggle-btn {
padding: 0.28rem 0.45rem;
border: 1px solid;
border-radius: 0 3px 3px 0;
cursor: pointer;
transition: filter 0.1s;
}
.status-toggle-btn:hover {
filter: brightness(0.92);
}
.status-backdrop {
position: fixed;
inset: 0;
z-index: 9;
}
.status-dropdown {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
min-width: 10rem;
z-index: 10;
}
/* More menu */
.more-wrapper {
position: relative;
+42 -11
View File
@@ -3,20 +3,51 @@
<!-- Top bar -->
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div class="d-flex align-items-center gap-2">
<span [class]="'badge ' + typeBadgeClass">{{ issue.type }}</span>
<span class="type-icon" [style.background]="typeIcon(issue.type).bg" [title]="issue.type">{{ typeIcon(issue.type).letter }}</span>
<span class="text-secondary fw-semibold small">#{{ issue.id }}</span>
</div>
<div class="d-flex align-items-center gap-2">
<select
aria-label="Status"
class="form-select form-select-sm w-auto"
[ngModel]="issue.status"
(ngModelChange)="updateStatus($event)"
>
@for (status of statusOptions; track status) {
<option [value]="status">{{ status }}</option>
<div class="status-split-wrapper">
<div class="btn-group">
<button
type="button"
class="status-main-btn"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
[style.border-color]="statusBadge(issue.status).color"
>{{ statusBadge(issue.status).label }}</button>
<button
type="button"
class="status-toggle-btn dropdown-toggle dropdown-toggle-split"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
[style.border-color]="statusBadge(issue.status).color"
(click)="toggleStatusMenu()"
aria-label="Changer le statut"
><span class="visually-hidden">Changer le statut</span></button>
</div>
@if (statusMenuOpen) {
<div class="status-backdrop" (click)="closeStatusMenu()"></div>
<ul class="status-dropdown dropdown-menu show">
@for (status of statusOptions; track status) {
<li>
<button
type="button"
class="dropdown-item d-flex align-items-center gap-2"
[class.active]="issue.status === status"
(click)="selectStatus(status)"
>
<span
class="status-badge"
[style.background]="statusBadge(status).bg"
[style.color]="statusBadge(status).color"
>{{ statusBadge(status).label }}</span>
</button>
</li>
}
</ul>
}
</select>
</div>
@if (!isNewIssueRoute) {
<div class="more-wrapper">
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
@@ -202,7 +233,7 @@
(click)="openComposedIssue(composedIssue.id)"
>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span [class]="'badge ' + getBadgeClass(composedIssue.type)">{{ composedIssue.type }}</span>
<span class="type-icon" [style.background]="typeIcon(composedIssue.type).bg" [title]="composedIssue.type">{{ typeIcon(composedIssue.type).letter }}</span>
<span class="fw-semibold">#{{ composedIssue.id }} {{ composedIssue.name || 'Sans nom' }}</span>
</div>
<div class="d-flex align-items-center gap-3 flex-shrink-0">
@@ -287,34 +287,30 @@ describe('IssueDetail — existing issue', () => {
});
});
describe('getBadgeClass / typeBadgeClass', () => {
it('typeBadgeClass returns class for current issue type', () => {
describe('typeIcon', () => {
it('typeIcon returns correct icon for current issue type', () => {
(component as any).issue.type = 'Bug';
expect((component as any).typeBadgeClass).toBe('text-bg-danger');
expect((component as any).typeIcon('Bug')).toEqual({ letter: 'B', bg: '#dc2626' });
});
it('getBadgeClass maps Bug to text-bg-danger', () => {
expect((component as any).getBadgeClass('Bug')).toBe('text-bg-danger');
it('typeIcon maps Epic correctly', () => {
expect((component as any).typeIcon('Epic')).toEqual({ letter: 'E', bg: '#7c3aed' });
});
it('getBadgeClass maps Study to text-bg-secondary', () => {
expect((component as any).getBadgeClass('Study')).toBe('text-bg-secondary');
it('typeIcon maps Story correctly', () => {
expect((component as any).typeIcon('Story')).toEqual({ letter: 'S', bg: '#16a34a' });
});
it('getBadgeClass maps Story to text-bg-success', () => {
expect((component as any).getBadgeClass('Story')).toBe('text-bg-success');
it('typeIcon maps Task correctly', () => {
expect((component as any).typeIcon('Task')).toEqual({ letter: 'T', bg: '#2563eb' });
});
it('getBadgeClass maps Task to text-bg-primary', () => {
expect((component as any).getBadgeClass('Task')).toBe('text-bg-primary');
it('typeIcon maps Technical Story correctly', () => {
expect((component as any).typeIcon('Technical Story')).toEqual({ letter: 'TS', bg: '#d97706' });
});
it('getBadgeClass maps Technical Story to text-bg-warning', () => {
expect((component as any).getBadgeClass('Technical Story')).toBe('text-bg-warning');
});
it('getBadgeClass maps Epic to text-bg-info', () => {
expect((component as any).getBadgeClass('Epic')).toBe('text-bg-info');
it('typeIcon maps Study correctly', () => {
expect((component as any).typeIcon('Study')).toEqual({ letter: 'St', bg: '#6b7280' });
});
});
+34 -2
View File
@@ -23,6 +23,7 @@ export class IssueDetail {
protected issue: IssueEntity = this.buildIssue();
protected readonly issues = this.issuesStore.issues;
protected moreMenuOpen = false;
protected statusMenuOpen = false;
constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
@@ -209,8 +210,26 @@ export class IssueDetail {
return this.sanitizer.bypassSecurityTrustHtml(html);
}
protected get typeBadgeClass(): string {
return this.getBadgeClass(this.issueTypeValue);
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 statusBadge(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 priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } {
@@ -284,6 +303,19 @@ export class IssueDetail {
this.moreMenuOpen = false;
}
protected toggleStatusMenu(): void {
this.statusMenuOpen = !this.statusMenuOpen;
}
protected closeStatusMenu(): void {
this.statusMenuOpen = false;
}
protected async selectStatus(status: IssueEntity['status']): Promise<void> {
this.statusMenuOpen = false;
await this.updateStatus(status);
}
private buildIssue(): IssueEntity {
const idParam = this.route.snapshot.paramMap.get('id');
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
+1
View File
@@ -25,3 +25,4 @@
}
+23 -6
View File
@@ -18,11 +18,16 @@
@for (type of typeOptions; track type) {
<button
type="button"
class="filter-btn btn btn-sm"
[class.active]="selectedType === type"
[class]="'filter-btn btn btn-sm ' + (selectedType === type ? typeBadgeClass(type).replace('text-bg-', 'btn-') : 'btn-outline-secondary')"
class="filter-btn btn btn-sm d-flex align-items-center gap-2"
[class.btn-outline-secondary]="selectedType !== type"
[style.border-color]="selectedType === type ? typeIcon(type).bg : null"
[style.background]="selectedType === type ? typeIcon(type).bg : null"
[style.color]="selectedType === type ? '#fff' : null"
(click)="selectType(type)"
>{{ type }}</button>
>
<span class="type-icon" [style.background]="selectedType === type ? 'rgba(255,255,255,0.3)' : typeIcon(type).bg">{{ typeIcon(type).letter }}</span>
{{ type }}
</button>
}
</div>
@@ -50,7 +55,13 @@
>
<td class="text-secondary small">#{{ issue.id }}</td>
<td>{{ issue.name }}</td>
<td><span [class]="'badge ' + typeBadgeClass(issue.type)">{{ issue.type }}</span></td>
<td>
<span
class="type-icon"
[style.background]="typeIcon(issue.type).bg"
[title]="issue.type"
>{{ typeIcon(issue.type).letter }}</span>
</td>
<td>
<span
[style.color]="priorityDisplay(issue.priority).color"
@@ -58,7 +69,13 @@
style="font-weight:700; font-size:1rem; letter-spacing:-1px;"
>{{ priorityDisplay(issue.priority).symbol }}</span>
</td>
<td>{{ issue.status }}</td>
<td>
<span
class="status-badge"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
>{{ statusBadge(issue.status).label }}</span>
</td>
<td>{{ issue.assignee }}</td>
<td class="progress-cell">
<div class="d-flex align-items-center gap-2">
+22
View File
@@ -63,6 +63,18 @@ export class Issues {
return map[priority] ?? { symbol: '?', color: '#6c757d', label: priority };
}
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 typeBadgeClass(type: IssueEntity['type']): string {
const map: Record<IssueEntity['type'], string> = {
Bug: 'text-bg-danger',
@@ -74,4 +86,14 @@ export class Issues {
};
return map[type] ?? 'text-bg-secondary';
}
protected statusBadge(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' };
}
}
+33
View File
@@ -9,3 +9,36 @@ body {
margin: 0;
background-color: #f8f9fa;
}
.markdown-body li {
margin-top: 0;
margin-bottom: 0.15rem;
}
.markdown-body li > p {
margin: 0;
}
.type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
}
.status-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
}