Merge pull request 'Modification visuel status et type issue' (#19) from feat/visibilite-statut into develop
Reviewed-on: Bonsai/Bonsai-webapp#19
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<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) {
|
||||
<option [value]="status">{{ status }}</option>
|
||||
<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>
|
||||
}
|
||||
</select>
|
||||
</ul>
|
||||
}
|
||||
</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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -25,3 +25,4 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user