Merge pull request 'Gestion des status' (#30) from feat/11-configuration-status into develop

Reviewed-on: Bonsai/Bonsai-webapp#30
This commit is contained in:
2026-05-28 20:21:21 +02:00
23 changed files with 775 additions and 55 deletions
+2
View File
@@ -6,6 +6,7 @@ import { Issues } from './issues/issues';
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail'; import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
import { Milestones } from './milestones/milestones'; import { Milestones } from './milestones/milestones';
import { Projects } from './projects/projects'; import { Projects } from './projects/projects';
import { Settings } from './settings/settings';
import { authGuard } from './auth/auth.guard'; import { authGuard } from './auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
@@ -20,5 +21,6 @@ export const routes: Routes = [
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] }, { path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] }, { path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
{ path: 'milestones', component: Milestones, canActivate: [authGuard] }, { path: 'milestones', component: Milestones, canActivate: [authGuard] },
{ path: 'settings', component: Settings, canActivate: [authGuard] },
{ path: '**', redirectTo: 'home' }, { path: '**', redirectTo: 'home' },
]; ];
@@ -578,7 +578,7 @@ describe('IssueComments', () => {
describe('statusLabel', () => { describe('statusLabel', () => {
it('returns label and colors for done status', () => { it('returns label and colors for done status', () => {
const s = (component as any).statusLabel('done'); const s = (component as any).statusLabel('done');
expect(s.label).toBe('Terminé'); expect(s.label).toBe('TERMINÉ');
expect(s.color).toBe('#166534'); expect(s.color).toBe('#166534');
}); });
}); });
@@ -5,6 +5,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked'; import { marked } from 'marked';
import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { IssueComment, IssueEntity, IssuesStore } from '../issues.store'; import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
@Component({ @Component({
selector: 'app-issue-comments', selector: 'app-issue-comments',
@@ -15,6 +16,7 @@ import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
export class IssueComments { export class IssueComments {
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
private readonly statusesStore = inject(StatusesStore);
readonly issueId = input.required<number>(); readonly issueId = input.required<number>();
@@ -140,14 +142,8 @@ export class IssueComments {
return map[type] ?? { letter: '?', bg: '#6b7280' }; return map[type] ?? { letter: '?', bg: '#6b7280' };
} }
protected statusLabel(status: IssueEntity['status']): { label: string; bg: string; color: string } { protected statusLabel(status: IssueEntity['status']): StatusEntity {
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = { return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
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 { protected startCreateTask(commentId: number): void {
@@ -29,19 +29,19 @@
@if (statusMenuOpen) { @if (statusMenuOpen) {
<div class="status-backdrop" (click)="closeStatusMenu()"></div> <div class="status-backdrop" (click)="closeStatusMenu()"></div>
<ul class="status-dropdown dropdown-menu show"> <ul class="status-dropdown dropdown-menu show">
@for (status of statusOptions; track status) { @for (status of statusOptions; track status.id) {
<li> <li>
<button <button
type="button" type="button"
class="dropdown-item d-flex align-items-center gap-2" class="dropdown-item d-flex align-items-center gap-2"
[class.active]="issue.status === status" [class.active]="issue.status === status.id"
(click)="selectStatus(status)" (click)="selectStatus(status.id)"
> >
<span <span
class="status-badge" class="status-badge"
[style.background]="statusBadge(status).bg" [style.background]="status.bg"
[style.color]="statusBadge(status).color" [style.color]="status.color"
>{{ statusBadge(status).label }}</span> >{{ status.label }}</span>
</button> </button>
</li> </li>
} }
+7 -14
View File
@@ -8,6 +8,7 @@ import { IssueEntity, IssuesStore } from '../issues.store';
import { IssueComments } from '../issue-comments/issue-comments'; import { IssueComments } from '../issue-comments/issue-comments';
import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { handleImagePaste, insertAtSelection } from '../paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
@Component({ @Component({
selector: 'app-issue-detail', selector: 'app-issue-detail',
@@ -21,6 +22,7 @@ export class IssueDetail {
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
protected readonly statusesStore = inject(StatusesStore);
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
protected issue: IssueEntity = this.buildIssue(); protected issue: IssueEntity = this.buildIssue();
@@ -64,12 +66,9 @@ export class IssueDetail {
protected showCreateInEpic = false; protected showCreateInEpic = false;
protected newIssueName = ''; protected newIssueName = '';
protected readonly statusOptions: IssueEntity['status'][] = [ protected get statusOptions(): StatusEntity[] {
'draft', return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
'todo', }
'in-progress',
'done',
];
protected readonly typeOptions: IssueEntity['type'][] = [ protected readonly typeOptions: IssueEntity['type'][] = [
'Epic', 'Epic',
@@ -279,14 +278,8 @@ export class IssueDetail {
return map[type] ?? { letter: '?', bg: '#6b7280' }; return map[type] ?? { letter: '?', bg: '#6b7280' };
} }
protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } { protected statusBadge(status: IssueEntity['status']): StatusEntity {
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = { return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
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 } { protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } {
+5 -5
View File
@@ -69,12 +69,12 @@
</button> </button>
</li> </li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@for (status of statusOptions; track status) { @for (status of statusOptions; track status.id) {
<li> <li>
<button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status, $event)"> <button class="dropdown-item d-flex align-items-center gap-2" (click)="toggleStatus(status.id, $event)">
<span class="filter-check">@if (selectedStatuses.has(status)) { ✓ }</span> <span class="filter-check">@if (selectedStatuses.has(status.id)) { ✓ }</span>
<span class="status-badge" [style.background]="statusBadge(status).bg" [style.color]="statusBadge(status).color"> <span class="status-badge" [style.background]="status.bg" [style.color]="status.color">
{{ statusBadge(status).label }} {{ status.label }}
</span> </span>
</button> </button>
</li> </li>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { IssuesApiService } from './issues-api.service'; import { IssuesApiService } from './issues-api.service';
export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress'; export type IssueStatus = string;
export type IssuePriority = 'TRES_FAIBLE' | 'BASSE' | 'MOYENNE' | 'HAUTE' | 'TRES_HAUTE'; export type IssuePriority = 'TRES_FAIBLE' | 'BASSE' | 'MOYENNE' | 'HAUTE' | 'TRES_HAUTE';
export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story'; export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story';
+7 -11
View File
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store'; import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
import { StatusEntity, StatusesStore } from '../settings/statuses/statuses.store';
@Component({ @Component({
selector: 'app-issues', selector: 'app-issues',
@@ -14,6 +15,7 @@ export class Issues {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
protected readonly statusesStore = inject(StatusesStore);
constructor() { constructor() {
this.issuesStore.load(); this.issuesStore.load();
@@ -33,9 +35,9 @@ export class Issues {
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story', 'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
]; ];
protected readonly statusOptions: IssueStatus[] = [ protected get statusOptions(): StatusEntity[] {
'draft', 'todo', 'in-progress', 'done', return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
]; }
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined { protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
return this.milestones().find((m) => m.issueIds.includes(issueId)); return this.milestones().find((m) => m.issueIds.includes(issueId));
@@ -192,13 +194,7 @@ export class Issues {
return map[type] ?? 'text-bg-secondary'; return map[type] ?? 'text-bg-secondary';
} }
protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } { protected statusBadge(status: IssueStatus): StatusEntity {
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = { return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
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' };
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ describe('Menu', () => {
it('should have five menu items', () => { it('should have five menu items', () => {
const items = (component as any).menuItems as { label: string; path: string }[]; const items = (component as any).menuItems as { label: string; path: string }[];
expect(items.length).toBe(5); expect(items.length).toBe(6);
}); });
it('should contain Issues link', () => { it('should contain Issues link', () => {
+1
View File
@@ -19,6 +19,7 @@ export class Menu {
{ label: 'Projet', path: '/project' }, { label: 'Projet', path: '/project' },
{ label: 'Issues', path: '/issues' }, { label: 'Issues', path: '/issues' },
{ label: 'Milestones', path: '/milestones' }, { label: 'Milestones', path: '/milestones' },
{ label: 'Paramètres', path: '/settings' },
]; ];
protected logout(): void { protected logout(): void {
@@ -7,6 +7,7 @@ import { marked } from 'marked';
import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { IssueEntity, IssuesStore } from '../../issues/issues.store';
import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util'; import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util';
import { MilestoneEntity, MilestonesStore } from '../milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones.store';
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
@Component({ @Component({
selector: 'app-milestone-detail', selector: 'app-milestone-detail',
@@ -20,6 +21,7 @@ export class MilestoneDetail {
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
private readonly statusesStore = inject(StatusesStore);
protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new'; protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new';
protected milestone: MilestoneEntity = this.buildMilestone(); protected milestone: MilestoneEntity = this.buildMilestone();
@@ -179,14 +181,8 @@ export class MilestoneDetail {
setTimeout(() => { this.showIssueSuggestions = false; }, 150); setTimeout(() => { this.showIssueSuggestions = false; }, 150);
} }
protected statusBadge(status: IssueEntity['status']): { label: string; bg: string; color: string } { protected statusBadge(status: IssueEntity['status']): StatusEntity {
const map: Record<IssueEntity['status'], { label: string; bg: string; color: string }> = { return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
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 } { protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } {
+10
View File
@@ -0,0 +1,10 @@
.settings-page {
padding: 2rem;
max-width: 700px;
}
.settings-page-title {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 1.5rem;
}
+19
View File
@@ -0,0 +1,19 @@
<div class="settings-page">
<h1 class="settings-page-title">Paramètres</h1>
<ul class="nav nav-tabs mb-4">
@for (tab of tabs; track tab.id) {
<li class="nav-item">
<button
class="nav-link"
[class.active]="activeTab() === tab.id"
(click)="selectTab(tab.id)"
>{{ tab.label }}</button>
</li>
}
</ul>
@if (activeTab() === 'statuses') {
<app-statuses />
}
</div>
+48
View File
@@ -0,0 +1,48 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { Settings } from './settings';
import { StatusesStore } from './statuses/statuses.store';
describe('Settings', () => {
let fixture: ComponentFixture<Settings>;
let component: Settings;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Settings],
providers: [
{
provide: StatusesStore,
useValue: {
statuses: vi.fn().mockReturnValue([]),
getById: vi.fn().mockReturnValue(undefined),
create: vi.fn(),
update: vi.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(Settings);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders the settings page title', () => {
expect(fixture.nativeElement.textContent).toContain('Paramètres');
});
it('renders the Statuts tab', () => {
const tabs = fixture.nativeElement.querySelectorAll('.nav-link');
const labels = [...tabs].map((t: Element) => t.textContent?.trim());
expect(labels).toContain('Statuts');
});
it('shows the statuses component by default', () => {
expect(fixture.nativeElement.querySelector('app-statuses')).not.toBeNull();
});
it('activates the clicked tab', () => {
component['selectTab']('statuses');
expect(component['activeTab']()).toBe('statuses');
});
});
+22
View File
@@ -0,0 +1,22 @@
import { Component, signal } from '@angular/core';
import { Statuses } from './statuses/statuses';
type SettingsTab = 'statuses';
@Component({
selector: 'app-settings',
imports: [Statuses],
templateUrl: './settings.html',
styleUrl: './settings.css',
})
export class Settings {
protected readonly activeTab = signal<SettingsTab>('statuses');
protected readonly tabs: { id: SettingsTab; label: string }[] = [
{ id: 'statuses', label: 'Statuts' },
];
protected selectTab(tab: SettingsTab): void {
this.activeTab.set(tab);
}
}
@@ -0,0 +1,67 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { StatusesApiService } from './statuses-api.service';
import { StatusEntity } from './statuses.store';
const API = '/api';
const makeStatus = (overrides: Partial<StatusEntity> = {}): StatusEntity => ({
id: 'draft',
label: 'BROUILLON',
bg: '#e2e8f0',
color: '#475569',
order: 0,
...overrides,
});
describe('StatusesApiService', () => {
let service: StatusesApiService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(StatusesApiService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getAll', () => {
it('sends GET /api/statuses', () => {
service.getAll().subscribe();
const req = httpMock.expectOne(`${API}/statuses`);
expect(req.request.method).toBe('GET');
req.flush([makeStatus()]);
});
});
describe('create', () => {
it('sends POST /api/statuses with the status payload', () => {
const payload = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
service.create(payload).subscribe();
const req = httpMock.expectOne(`${API}/statuses`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(payload);
req.flush(makeStatus({ ...payload, order: 4 }));
});
});
describe('update', () => {
it('sends PUT /api/statuses/:id with the changes', () => {
const changes = { label: 'MODIFIÉ', bg: '#abc', color: '#def' };
service.update('draft', changes).subscribe();
const req = httpMock.expectOne(`${API}/statuses/draft`);
expect(req.request.method).toBe('PUT');
expect(req.request.body).toEqual(changes);
req.flush(makeStatus({ label: 'MODIFIÉ' }));
});
});
});
@@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { API_BASE_URL } from '../../issues/issues-api.service';
import { StatusEntity } from './statuses.store';
// Ce service appellera l'API quand les endpoints /api/statuses seront disponibles.
// Voir api-issues/gestion-statuts.md
@Injectable({ providedIn: 'root' })
export class StatusesApiService {
private readonly http = inject(HttpClient);
getAll(): Observable<StatusEntity[]> {
return this.http.get<StatusEntity[]>(`${API_BASE_URL}/statuses`);
}
create(status: Omit<StatusEntity, 'order'>): Observable<StatusEntity> {
return this.http.post<StatusEntity>(`${API_BASE_URL}/statuses`, status);
}
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): Observable<StatusEntity> {
return this.http.put<StatusEntity>(`${API_BASE_URL}/statuses/${id}`, changes);
}
}
+85
View File
@@ -0,0 +1,85 @@
.statuses-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.statuses-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.statuses-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.status-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: #fff;
}
.status-badge {
display: inline-block;
padding: 0.2em 0.7em;
border-radius: 9999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
}
.status-id {
font-size: 0.8rem;
flex: 1;
}
.status-row-actions {
margin-left: auto;
}
.status-form-colors {
display: flex;
gap: 1.5rem;
align-items: flex-end;
flex-wrap: wrap;
}
.color-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-control-color {
width: 3rem;
height: 2rem;
padding: 0.1rem;
cursor: pointer;
}
.color-hex {
font-size: 0.8rem;
color: #64748b;
font-family: monospace;
}
.status-form-preview {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
+98
View File
@@ -0,0 +1,98 @@
<div class="statuses-header">
<h2 class="statuses-title">Statuts</h2>
@if (formMode() === null) {
<button class="btn btn-primary btn-sm" (click)="openCreate()">+ Nouveau statut</button>
}
</div>
@if (formMode() !== null) {
<div class="status-form card mb-4">
<div class="card-body">
<h3 class="card-title h6 mb-3">
{{ formMode() === 'create' ? 'Créer un statut' : 'Modifier le statut' }}
</h3>
@if (formMode() === 'create') {
<div class="mb-3">
<label class="form-label form-label-sm" for="status-id">Identifiant</label>
<input
id="status-id"
class="form-control form-control-sm"
type="text"
placeholder="ex: en-attente"
[(ngModel)]="form.id"
/>
<div class="form-text">Généré automatiquement à partir du libellé si vide.</div>
@if (idError()) {
<div class="text-danger small mt-1">{{ idError() }}</div>
}
</div>
}
<div class="mb-3">
<label class="form-label form-label-sm" for="status-label">Libellé</label>
<input
id="status-label"
class="form-control form-control-sm"
type="text"
placeholder="ex: EN ATTENTE"
[(ngModel)]="form.label"
/>
</div>
<div class="status-form-colors mb-3">
<div>
<label class="form-label form-label-sm" for="status-bg">Couleur de fond</label>
<div class="color-input-row">
<input id="status-bg" class="form-control form-control-color" type="color" [(ngModel)]="form.bg" />
<span class="color-hex">{{ form.bg }}</span>
</div>
</div>
<div>
<label class="form-label form-label-sm" for="status-color">Couleur du texte</label>
<div class="color-input-row">
<input id="status-color" class="form-control form-control-color" type="color" [(ngModel)]="form.color" />
<span class="color-hex">{{ form.color }}</span>
</div>
</div>
<div class="status-form-preview">
<label class="form-label form-label-sm">Aperçu</label>
<span
class="status-badge"
[style.background]="form.bg"
[style.color]="form.color"
>{{ form.label || 'LIBELLÉ' }}</span>
</div>
</div>
<div class="status-form-actions">
<button class="btn btn-secondary btn-sm" (click)="cancel()">Annuler</button>
<button class="btn btn-primary btn-sm" [disabled]="!isFormValid()" (click)="save()">
{{ formMode() === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
}
<div class="statuses-list">
@for (status of statuses; track status.id) {
<div class="status-row">
<span
class="status-badge"
[style.background]="status.bg"
[style.color]="status.color"
>{{ status.label }}</span>
<span class="status-id text-muted">{{ status.id }}</span>
<div class="status-row-actions">
<button
class="btn btn-outline-secondary btn-sm"
(click)="openEdit(status)"
[disabled]="formMode() !== null"
>Modifier</button>
</div>
</div>
} @empty {
<p class="text-muted">Aucun statut configuré.</p>
}
</div>
@@ -0,0 +1,96 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { Statuses } from './statuses';
import { StatusesStore } from './statuses.store';
const makeStoreMock = () => ({
statuses: vi.fn().mockReturnValue([
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
]),
getById: vi.fn((id: string) =>
[
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
].find((s) => s.id === id),
),
create: vi.fn(),
update: vi.fn(),
});
describe('Statuses', () => {
let fixture: ComponentFixture<Statuses>;
let component: Statuses;
let storeMock: ReturnType<typeof makeStoreMock>;
beforeEach(async () => {
storeMock = makeStoreMock();
await TestBed.configureTestingModule({
imports: [Statuses, FormsModule],
providers: [{ provide: StatusesStore, useValue: storeMock }],
}).compileComponents();
fixture = TestBed.createComponent(Statuses);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders the statuses list', () => {
const badges = fixture.nativeElement.querySelectorAll('.status-badge');
expect(badges.length).toBeGreaterThanOrEqual(2);
});
it('shows the create form when clicking "Nouveau statut"', () => {
const btn = fixture.nativeElement.querySelector('.btn-primary');
btn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.status-form')).not.toBeNull();
});
it('shows the edit form when clicking "Modifier"', () => {
const editBtn = fixture.nativeElement.querySelector('.btn-outline-secondary');
editBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.status-form')).not.toBeNull();
});
it('calls store.create when saving a new status', async () => {
component['openCreate']();
component['form'] = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
component['save']();
expect(storeMock.create).toHaveBeenCalledWith(
expect.objectContaining({ label: 'EN REVUE' }),
);
});
it('calls store.update when saving an edited status', () => {
component['openEdit']({ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 });
component['form'] = { id: 'draft', label: 'MODIFIÉ', bg: '#e2e8f0', color: '#475569' };
component['save']();
expect(storeMock.update).toHaveBeenCalledWith('draft', expect.objectContaining({ label: 'MODIFIÉ' }));
});
it('cancels and hides the form', () => {
component['openCreate']();
component['cancel']();
expect(component['formMode']()).toBeNull();
});
it('shows an error when creating a duplicate id', () => {
component['openCreate']();
component['form'] = { id: 'draft', label: 'BROUILLON', bg: '#fff', color: '#000' };
component['save']();
expect(component['idError']()).not.toBeNull();
expect(storeMock.create).not.toHaveBeenCalled();
});
it('isFormValid returns false when label is empty', () => {
component['form'] = { id: '', label: '', bg: '#fff', color: '#000' };
expect(component['isFormValid']()).toBe(false);
});
it('isFormValid returns true when label is set', () => {
component['form'] = { id: '', label: 'TEST', bg: '#fff', color: '#000' };
expect(component['isFormValid']()).toBe(true);
});
});
@@ -0,0 +1,115 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_STATUSES, StatusesStore } from './statuses.store';
const STORAGE_KEY = 'bonsai_statuses';
describe('StatusesStore', () => {
let store: StatusesStore;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({});
store = TestBed.inject(StatusesStore);
});
afterEach(() => {
localStorage.clear();
});
describe('initialization', () => {
it('loads default statuses when localStorage is empty', () => {
expect(store.statuses().length).toBe(DEFAULT_STATUSES.length);
expect(store.statuses()[0].id).toBe('draft');
});
it('loads statuses from localStorage when available', () => {
const saved = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0 }];
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(StatusesStore);
expect(freshStore.statuses().length).toBe(1);
expect(freshStore.statuses()[0].id).toBe('custom');
});
it('falls back to defaults when localStorage contains invalid JSON', () => {
localStorage.setItem(STORAGE_KEY, 'not-json');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(StatusesStore);
expect(freshStore.statuses().length).toBe(DEFAULT_STATUSES.length);
});
it('falls back to defaults when localStorage contains empty array', () => {
localStorage.setItem(STORAGE_KEY, '[]');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(StatusesStore);
expect(freshStore.statuses().length).toBe(DEFAULT_STATUSES.length);
});
});
describe('getById', () => {
it('returns the matching status', () => {
const result = store.getById('draft');
expect(result?.id).toBe('draft');
expect(result?.label).toBe('BROUILLON');
});
it('returns undefined for an unknown id', () => {
expect(store.getById('unknown')).toBeUndefined();
});
});
describe('create', () => {
it('adds a new status to the list', () => {
store.create({ id: 'review', label: 'EN REVUE', bg: '#fef9c3', color: '#854d0e' });
const found = store.getById('review');
expect(found).toBeDefined();
expect(found?.label).toBe('EN REVUE');
});
it('assigns an order greater than existing max', () => {
const maxBefore = Math.max(...store.statuses().map((s) => s.order));
store.create({ id: 'new-one', label: 'NOUVEAU', bg: '#fff', color: '#000' });
const created = store.getById('new-one');
expect(created?.order).toBeGreaterThan(maxBefore);
});
it('persists to localStorage', () => {
store.create({ id: 'saved', label: 'SAUVEGARDÉ', bg: '#fff', color: '#000' });
const raw = localStorage.getItem(STORAGE_KEY);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.some((s: { id: string }) => s.id === 'saved')).toBe(true);
});
});
describe('update', () => {
it('updates the label of an existing status', () => {
store.update('draft', { label: 'NOUVEAU LABEL' });
expect(store.getById('draft')?.label).toBe('NOUVEAU LABEL');
});
it('updates the colors of an existing status', () => {
store.update('todo', { bg: '#fff000', color: '#123456' });
const updated = store.getById('todo');
expect(updated?.bg).toBe('#fff000');
expect(updated?.color).toBe('#123456');
});
it('does not affect other statuses', () => {
store.update('draft', { label: 'MODIFIÉ' });
expect(store.getById('todo')?.label).toBe('À FAIRE');
});
it('persists changes to localStorage', () => {
store.update('draft', { label: 'UPDATED' });
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = JSON.parse(raw!);
const draft = parsed.find((s: { id: string }) => s.id === 'draft');
expect(draft?.label).toBe('UPDATED');
});
});
});
@@ -0,0 +1,66 @@
import { Injectable, signal } from '@angular/core';
export type StatusEntity = {
id: string;
label: string;
bg: string;
color: string;
order: number;
};
export const DEFAULT_STATUSES: StatusEntity[] = [
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2 },
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3 },
];
const STORAGE_KEY = 'bonsai_statuses';
@Injectable({ providedIn: 'root' })
export class StatusesStore {
private readonly data = signal<StatusEntity[]>([]);
readonly statuses = this.data.asReadonly();
constructor() {
this.loadFromStorage();
}
private loadFromStorage(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StatusEntity[];
if (Array.isArray(parsed) && parsed.length > 0) {
this.data.set(parsed);
return;
}
}
} catch {
// ignore parse errors
}
this.data.set([...DEFAULT_STATUSES]);
}
private saveToStorage(): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data()));
}
getById(id: string): StatusEntity | undefined {
return this.data().find((s) => s.id === id);
}
create(status: Omit<StatusEntity, 'order'>): void {
const maxOrder = this.data().reduce((max, s) => Math.max(max, s.order), -1);
this.data.update((statuses) => [...statuses, { ...status, order: maxOrder + 1 }]);
this.saveToStorage();
}
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
this.data.update((statuses) =>
statuses.map((s) => (s.id === id ? { ...s, ...changes } : s)),
);
this.saveToStorage();
}
}
+86
View File
@@ -0,0 +1,86 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { StatusEntity, StatusesStore } from './statuses.store';
type FormMode = 'create' | 'edit';
type StatusForm = {
id: string;
label: string;
bg: string;
color: string;
};
@Component({
selector: 'app-statuses',
imports: [FormsModule],
templateUrl: './statuses.html',
styleUrl: './statuses.css',
})
export class Statuses {
protected readonly statusesStore = inject(StatusesStore);
protected formMode = signal<FormMode | null>(null);
protected editingId = signal<string | null>(null);
protected form: StatusForm = this.emptyForm();
protected idError = signal<string | null>(null);
protected get statuses(): StatusEntity[] {
return [...this.statusesStore.statuses()].sort((a, b) => a.order - b.order);
}
protected openCreate(): void {
this.form = this.emptyForm();
this.idError.set(null);
this.editingId.set(null);
this.formMode.set('create');
}
protected openEdit(status: StatusEntity): void {
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
this.idError.set(null);
this.editingId.set(status.id);
this.formMode.set('edit');
}
protected cancel(): void {
this.formMode.set(null);
this.editingId.set(null);
}
protected save(): void {
if (this.formMode() === 'create') {
const id = this.slugify(this.form.id || this.form.label);
if (!id) return;
if (this.statusesStore.getById(id)) {
this.idError.set('Un statut avec cet identifiant existe déjà.');
return;
}
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
} else {
const id = this.editingId();
if (!id) return;
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
}
this.formMode.set(null);
this.editingId.set(null);
}
protected isFormValid(): boolean {
return this.form.label.trim().length > 0;
}
private slugify(value: string): string {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
private emptyForm(): StatusForm {
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
}
}