#62 Ajoute d'une configuration pour les status à considérer comme terminé
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
@@ -5,6 +5,7 @@ import { vi } from 'vitest';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { IssueEntity, IssuesStore } from '../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 1,
|
||||
@@ -43,6 +44,16 @@ class FakeIssuesStore {
|
||||
deleteById = vi.fn();
|
||||
}
|
||||
|
||||
class FakeStatusesStore {
|
||||
private completedIds = new Set<string>(['done']);
|
||||
|
||||
setCompleted(ids: string[]): void {
|
||||
this.completedIds = new Set(ids);
|
||||
}
|
||||
|
||||
isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId));
|
||||
}
|
||||
|
||||
class FakeMilestonesStore {
|
||||
private _data = signal<MilestoneEntity[]>([]);
|
||||
readonly milestones = this._data.asReadonly();
|
||||
@@ -63,11 +74,13 @@ describe('Dashboard', () => {
|
||||
let fixture: ComponentFixture<Dashboard>;
|
||||
let issuesStore: FakeIssuesStore;
|
||||
let milestonesStore: FakeMilestonesStore;
|
||||
let statusesStore: FakeStatusesStore;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
issuesStore = new FakeIssuesStore();
|
||||
milestonesStore = new FakeMilestonesStore();
|
||||
statusesStore = new FakeStatusesStore();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
@@ -75,6 +88,7 @@ describe('Dashboard', () => {
|
||||
provideRouter([]),
|
||||
{ provide: IssuesStore, useValue: issuesStore },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: StatusesStore, useValue: statusesStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -123,6 +137,17 @@ describe('Dashboard', () => {
|
||||
]);
|
||||
expect((component as any).completionRate()).toBe(50);
|
||||
});
|
||||
|
||||
it('compte un statut personnalisé countsAsCompleted comme terminé', () => {
|
||||
statusesStore.setCompleted(['done', 'abandoned']);
|
||||
issuesStore.seed([
|
||||
makeIssue({ id: 1, status: 'done' }),
|
||||
makeIssue({ id: 2, status: 'abandoned' }),
|
||||
makeIssue({ id: 3, status: 'todo' }),
|
||||
makeIssue({ id: 4, status: 'todo' }),
|
||||
]);
|
||||
expect((component as any).completionRate()).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusCounts', () => {
|
||||
@@ -180,6 +205,12 @@ describe('Dashboard', () => {
|
||||
expect((component as any).highPriorityIssues().length).toBe(0);
|
||||
});
|
||||
|
||||
it('exclut les issues avec un statut personnalisé countsAsCompleted', () => {
|
||||
statusesStore.setCompleted(['done', 'abandoned']);
|
||||
issuesStore.seed([makeIssue({ priority: 'TRES_HAUTE', status: 'abandoned' })]);
|
||||
expect((component as any).highPriorityIssues().length).toBe(0);
|
||||
});
|
||||
|
||||
it('limite à 6 résultats', () => {
|
||||
issuesStore.seed(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { IssueEntity, IssuesStore } from '../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@@ -13,6 +14,7 @@ export class Dashboard {
|
||||
private readonly router = inject(Router);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
|
||||
constructor() {
|
||||
this.issuesStore.load();
|
||||
@@ -31,7 +33,8 @@ export class Dashboard {
|
||||
protected readonly completionRate = computed(() => {
|
||||
const total = this.totalIssues();
|
||||
if (total === 0) return 0;
|
||||
return Math.round((this.statusCounts().done / total) * 100);
|
||||
const done = this.issuesStore.issues().filter((i) => this.statusesStore.isCompleted(i.status)).length;
|
||||
return Math.round((done / total) * 100);
|
||||
});
|
||||
|
||||
protected readonly totalMilestones = computed(() => this.milestonesStore.milestones().length);
|
||||
@@ -57,7 +60,7 @@ export class Dashboard {
|
||||
protected readonly highPriorityIssues = computed(() =>
|
||||
this.issuesStore
|
||||
.issues()
|
||||
.filter((i) => (i.priority === 'HAUTE' || i.priority === 'TRES_HAUTE') && i.status !== 'done')
|
||||
.filter((i) => (i.priority === 'HAUTE' || i.priority === 'TRES_HAUTE') && !this.statusesStore.isCompleted(i.status))
|
||||
.slice(0, 6),
|
||||
);
|
||||
|
||||
@@ -75,7 +78,7 @@ export class Dashboard {
|
||||
return this.issuesStore
|
||||
.issues()
|
||||
.filter((i) => {
|
||||
if (!i.dueDate || i.status === 'done') return false;
|
||||
if (!i.dueDate || this.statusesStore.isCompleted(i.status)) return false;
|
||||
const due = new Date(i.dueDate);
|
||||
return due >= now && due <= twoWeeks;
|
||||
})
|
||||
@@ -86,7 +89,7 @@ export class Dashboard {
|
||||
protected readonly overdueCount = computed(() => {
|
||||
const now = new Date();
|
||||
return this.issuesStore.issues().filter((i) => {
|
||||
if (!i.dueDate || i.status === 'done') return false;
|
||||
if (!i.dueDate || this.statusesStore.isCompleted(i.status)) return false;
|
||||
return new Date(i.dueDate) < now;
|
||||
}).length;
|
||||
});
|
||||
@@ -95,7 +98,7 @@ export class Dashboard {
|
||||
if (milestone.issueIds.length === 0) return 0;
|
||||
const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id));
|
||||
if (linked.length === 0) return 0;
|
||||
return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100);
|
||||
return Math.round((linked.filter((i) => this.statusesStore.isCompleted(i.status)).length / linked.length) * 100);
|
||||
}
|
||||
|
||||
protected formatDate(iso: string): string {
|
||||
|
||||
@@ -145,7 +145,7 @@ export class IssueComments {
|
||||
}
|
||||
|
||||
protected statusLabel(status: IssueEntity['status']): StatusEntity {
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false };
|
||||
}
|
||||
|
||||
protected startCreateTask(commentId: number): void {
|
||||
|
||||
@@ -446,7 +446,7 @@ export class IssueDetail {
|
||||
}
|
||||
|
||||
protected statusBadge(status: IssueEntity['status']): StatusEntity {
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false };
|
||||
}
|
||||
|
||||
protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { vi } from 'vitest';
|
||||
import { Issues } from './issues';
|
||||
import { IssueEntity, IssuesStore } from './issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 99,
|
||||
@@ -104,6 +105,24 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
|
||||
...overrides,
|
||||
});
|
||||
|
||||
class FakeStatusesStore {
|
||||
private completedIds = new Set<string>(['done']);
|
||||
|
||||
setCompleted(ids: string[]): void {
|
||||
this.completedIds = new Set(ids);
|
||||
}
|
||||
|
||||
isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId));
|
||||
getById = vi.fn((id: string) =>
|
||||
id === 'done'
|
||||
? { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true }
|
||||
: undefined,
|
||||
);
|
||||
readonly statuses = signal([
|
||||
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
|
||||
]);
|
||||
}
|
||||
|
||||
class FakeMilestonesStore {
|
||||
private _data = signal<MilestoneEntity[]>([]);
|
||||
|
||||
@@ -141,17 +160,20 @@ describe('Issues', () => {
|
||||
let fixture: ComponentFixture<Issues>;
|
||||
let store: FakeIssuesStore;
|
||||
let milestonesStore: FakeMilestonesStore;
|
||||
let statusesStore: FakeStatusesStore;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new FakeIssuesStore();
|
||||
milestonesStore = new FakeMilestonesStore();
|
||||
statusesStore = new FakeStatusesStore();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Issues],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: StatusesStore, useValue: statusesStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -308,6 +330,15 @@ describe('Issues', () => {
|
||||
store.upsert(makeIssue({ id: 58, name: 'DepChild', dependsOnIds: [57], status: 'done' }));
|
||||
expect((component as any).getProgress(epic)).toBe(100);
|
||||
});
|
||||
|
||||
it('counts a custom countsAsCompleted status as done for an Epic', () => {
|
||||
statusesStore.setCompleted(['done', 'abandoned']);
|
||||
const epic = makeIssue({ id: 60, type: 'Epic', name: 'Custom Epic', progress: 0 });
|
||||
store.upsert(epic);
|
||||
store.upsert(makeIssue({ id: 61, name: 'Done Child', epic: 'Custom Epic', status: 'abandoned' }));
|
||||
store.upsert(makeIssue({ id: 62, name: 'Todo Child', epic: 'Custom Epic', status: 'todo' }));
|
||||
expect((component as any).getProgress(epic)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMilestoneForIssue', () => {
|
||||
|
||||
@@ -153,7 +153,7 @@ export class Issues {
|
||||
(i) => i.id !== issue.id && (i.epic === issue.name || i.dependsOnIds.includes(issue.id)),
|
||||
);
|
||||
if (children.length === 0) return 0;
|
||||
const done = children.filter((i) => i.status === 'done').length;
|
||||
const done = children.filter((i) => this.statusesStore.isCompleted(i.status)).length;
|
||||
return Math.round((done / children.length) * 100);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,6 @@ export class Issues {
|
||||
}
|
||||
|
||||
protected statusBadge(status: IssueStatus): StatusEntity {
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, vi } from 'vitest';
|
||||
import { MilestoneDetail } from './milestone-detail';
|
||||
import { IssueEntity, IssuesStore } from '../../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones.store';
|
||||
import { StatusesStore } from '../../settings/statuses/statuses.store';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 99,
|
||||
@@ -72,6 +73,24 @@ class FakeIssuesStore {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS_MAP: Record<string, { id: string; label: string; bg: string; color: string; order: number; countsAsCompleted: boolean }> = {
|
||||
draft: { id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
|
||||
todo: { id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
|
||||
'in-progress':{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false },
|
||||
done: { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
|
||||
};
|
||||
|
||||
class FakeStatusesStore {
|
||||
private completedIds = new Set<string>(['done']);
|
||||
|
||||
setCompleted(ids: string[]): void {
|
||||
this.completedIds = new Set(ids);
|
||||
}
|
||||
|
||||
isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId));
|
||||
getById = vi.fn((id: string) => DEFAULT_STATUS_MAP[id]);
|
||||
}
|
||||
|
||||
class FakeMilestonesStore {
|
||||
private _data = signal<MilestoneEntity[]>([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
|
||||
|
||||
@@ -123,11 +142,13 @@ describe('MilestoneDetail', () => {
|
||||
let fixture: ComponentFixture<MilestoneDetail>;
|
||||
let issuesStore: FakeIssuesStore;
|
||||
let milestonesStore: FakeMilestonesStore;
|
||||
let statusesStore: FakeStatusesStore;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
issuesStore = new FakeIssuesStore();
|
||||
milestonesStore = new FakeMilestonesStore();
|
||||
statusesStore = new FakeStatusesStore();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MilestoneDetail],
|
||||
providers: [
|
||||
@@ -135,6 +156,7 @@ describe('MilestoneDetail', () => {
|
||||
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
||||
{ provide: IssuesStore, useValue: issuesStore },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: StatusesStore, useValue: statusesStore },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -277,6 +299,16 @@ describe('MilestoneDetail', () => {
|
||||
(component as any).milestone.issueIds = [1, 2];
|
||||
expect((component as any).progress).toBe(50);
|
||||
});
|
||||
|
||||
it('counts a custom countsAsCompleted status as done', () => {
|
||||
statusesStore.setCompleted(['done', 'abandoned']);
|
||||
issuesStore.seed([
|
||||
makeIssue({ id: 1, status: 'abandoned' }),
|
||||
makeIssue({ id: 2, status: 'todo' }),
|
||||
]);
|
||||
(component as any).milestone.issueIds = [1, 2];
|
||||
expect((component as any).progress).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('milestoneGanttTasks', () => {
|
||||
|
||||
@@ -185,7 +185,7 @@ export class MilestoneDetail {
|
||||
protected get progress(): number {
|
||||
if (this.linkedIssues.length === 0) return 0;
|
||||
return Math.round(
|
||||
(this.linkedIssues.filter((i) => i.status === 'done').length / this.linkedIssues.length) * 100,
|
||||
(this.linkedIssues.filter((i) => this.statusesStore.isCompleted(i.status)).length / this.linkedIssues.length) * 100,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ export class MilestoneDetail {
|
||||
}
|
||||
|
||||
protected statusBadge(status: IssueEntity['status']): StatusEntity {
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 };
|
||||
return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false };
|
||||
}
|
||||
|
||||
protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { IssuesStore } from '../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from './milestones.store';
|
||||
import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-milestones',
|
||||
@@ -15,6 +16,7 @@ export class Milestones {
|
||||
private readonly router = inject(Router);
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
|
||||
constructor() {
|
||||
this.milestonesStore.load();
|
||||
@@ -88,7 +90,7 @@ export class Milestones {
|
||||
if (milestone.issueIds.length === 0) return 0;
|
||||
const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id));
|
||||
if (linked.length === 0) return 0;
|
||||
return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100);
|
||||
return Math.round((linked.filter((i) => this.statusesStore.isCompleted(i.status)).length / linked.length) * 100);
|
||||
}
|
||||
|
||||
protected formatDate(iso: string): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ const makeStatus = (overrides: Partial<StatusEntity> = {}): StatusEntity => ({
|
||||
bg: '#e2e8f0',
|
||||
color: '#475569',
|
||||
order: 0,
|
||||
countsAsCompleted: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -45,7 +46,7 @@ describe('StatusesApiService', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('sends POST /api/statuses with the status payload', () => {
|
||||
const payload = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
|
||||
const payload = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
service.create(payload).subscribe();
|
||||
const req = httpMock.expectOne(`${API}/statuses`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
|
||||
@@ -18,7 +18,7 @@ export class StatusesApiService {
|
||||
return this.http.post<StatusEntity>(`${API_BASE_URL}/statuses`, status);
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): Observable<StatusEntity> {
|
||||
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color' | 'countsAsCompleted'>>): Observable<StatusEntity> {
|
||||
return this.http.put<StatusEntity>(`${API_BASE_URL}/statuses/${id}`, changes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input
|
||||
id="status-counts-as-completed"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.countsAsCompleted"
|
||||
/>
|
||||
<label class="form-check-label" for="status-counts-as-completed">
|
||||
Compte comme terminé (inclus dans le calcul d'avancement)
|
||||
</label>
|
||||
</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()">
|
||||
@@ -84,6 +96,9 @@
|
||||
[style.color]="status.color"
|
||||
>{{ status.label }}</span>
|
||||
<span class="status-id text-muted">{{ status.id }}</span>
|
||||
@if (status.countsAsCompleted) {
|
||||
<span class="status-completed-badge text-success small">✓ compte comme terminé</span>
|
||||
}
|
||||
<div class="status-row-actions">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
|
||||
@@ -6,15 +6,16 @@ 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 },
|
||||
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
|
||||
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
|
||||
]),
|
||||
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 },
|
||||
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
|
||||
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
|
||||
].find((s) => s.id === id),
|
||||
),
|
||||
isCompleted: vi.fn((id: string) => id === 'done'),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
});
|
||||
@@ -56,7 +57,7 @@ describe('Statuses', () => {
|
||||
|
||||
it('calls store.create when saving a new status', async () => {
|
||||
component['openCreate']();
|
||||
component['form'] = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
|
||||
component['form'] = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
component['save']();
|
||||
expect(storeMock.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: 'EN REVUE' }),
|
||||
@@ -64,8 +65,8 @@ describe('Statuses', () => {
|
||||
});
|
||||
|
||||
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['openEdit']({ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false });
|
||||
component['form'] = { id: 'draft', label: 'MODIFIÉ', bg: '#e2e8f0', color: '#475569', countsAsCompleted: false };
|
||||
component['save']();
|
||||
expect(storeMock.update).toHaveBeenCalledWith('draft', expect.objectContaining({ label: 'MODIFIÉ' }));
|
||||
});
|
||||
@@ -76,21 +77,30 @@ describe('Statuses', () => {
|
||||
expect(component['formMode']()).toBeNull();
|
||||
});
|
||||
|
||||
it('calls store.create with countsAsCompleted true when checked', () => {
|
||||
component['openCreate']();
|
||||
component['form'] = { id: 'abandoned', label: 'ABANDONNÉE', bg: '#f1f5f9', color: '#64748b', countsAsCompleted: true };
|
||||
component['save']();
|
||||
expect(storeMock.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ countsAsCompleted: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error when creating a duplicate id', () => {
|
||||
component['openCreate']();
|
||||
component['form'] = { id: 'draft', label: 'BROUILLON', bg: '#fff', color: '#000' };
|
||||
component['form'] = { id: 'draft', label: 'BROUILLON', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
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' };
|
||||
component['form'] = { id: '', label: '', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
expect(component['isFormValid']()).toBe(false);
|
||||
});
|
||||
|
||||
it('isFormValid returns true when label is set', () => {
|
||||
component['form'] = { id: '', label: 'TEST', bg: '#fff', color: '#000' };
|
||||
component['form'] = { id: '', label: 'TEST', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
expect(component['isFormValid']()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('StatusesStore', () => {
|
||||
});
|
||||
|
||||
it('loads statuses from localStorage when available', () => {
|
||||
const saved = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0 }];
|
||||
const saved = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0, countsAsCompleted: false }];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
@@ -62,9 +62,30 @@ describe('StatusesStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompleted', () => {
|
||||
it('returns true for the done status', () => {
|
||||
expect(store.isCompleted('done')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-completed statuses', () => {
|
||||
expect(store.isCompleted('draft')).toBe(false);
|
||||
expect(store.isCompleted('todo')).toBe(false);
|
||||
expect(store.isCompleted('in-progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an unknown status', () => {
|
||||
expect(store.isCompleted('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a custom status with countsAsCompleted true', () => {
|
||||
store.create({ id: 'abandoned', label: 'ABANDONNÉE', bg: '#f1f5f9', color: '#64748b', countsAsCompleted: true });
|
||||
expect(store.isCompleted('abandoned')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('adds a new status to the list', () => {
|
||||
store.create({ id: 'review', label: 'EN REVUE', bg: '#fef9c3', color: '#854d0e' });
|
||||
store.create({ id: 'review', label: 'EN REVUE', bg: '#fef9c3', color: '#854d0e', countsAsCompleted: false });
|
||||
const found = store.getById('review');
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.label).toBe('EN REVUE');
|
||||
@@ -72,13 +93,13 @@ describe('StatusesStore', () => {
|
||||
|
||||
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' });
|
||||
store.create({ id: 'new-one', label: 'NOUVEAU', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
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' });
|
||||
store.create({ id: 'saved', label: 'SAUVEGARDÉ', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw!);
|
||||
@@ -104,6 +125,11 @@ describe('StatusesStore', () => {
|
||||
expect(store.getById('todo')?.label).toBe('À FAIRE');
|
||||
});
|
||||
|
||||
it('updates countsAsCompleted', () => {
|
||||
store.update('draft', { countsAsCompleted: true });
|
||||
expect(store.getById('draft')?.countsAsCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it('persists changes to localStorage', () => {
|
||||
store.update('draft', { label: 'UPDATED' });
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
@@ -6,13 +6,14 @@ export type StatusEntity = {
|
||||
bg: string;
|
||||
color: string;
|
||||
order: number;
|
||||
countsAsCompleted: boolean;
|
||||
};
|
||||
|
||||
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 },
|
||||
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
|
||||
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
|
||||
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false },
|
||||
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'bonsai_statuses';
|
||||
@@ -51,13 +52,17 @@ export class StatusesStore {
|
||||
return this.data().find((s) => s.id === id);
|
||||
}
|
||||
|
||||
isCompleted(statusId: string): boolean {
|
||||
return this.data().find((s) => s.id === statusId)?.countsAsCompleted ?? false;
|
||||
}
|
||||
|
||||
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 {
|
||||
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color' | 'countsAsCompleted'>>): void {
|
||||
this.data.update((statuses) =>
|
||||
statuses.map((s) => (s.id === id ? { ...s, ...changes } : s)),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ type StatusForm = {
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
countsAsCompleted: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -37,7 +38,7 @@ export class Statuses {
|
||||
}
|
||||
|
||||
protected openEdit(status: StatusEntity): void {
|
||||
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
|
||||
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color, countsAsCompleted: status.countsAsCompleted };
|
||||
this.idError.set(null);
|
||||
this.editingId.set(status.id);
|
||||
this.formMode.set('edit');
|
||||
@@ -56,11 +57,11 @@ export class Statuses {
|
||||
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 });
|
||||
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
|
||||
} 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.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
|
||||
}
|
||||
this.formMode.set(null);
|
||||
this.editingId.set(null);
|
||||
@@ -81,6 +82,6 @@ export class Statuses {
|
||||
}
|
||||
|
||||
private emptyForm(): StatusForm {
|
||||
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
|
||||
return { id: '', label: '', bg: '#e2e8f0', color: '#475569', countsAsCompleted: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input
|
||||
id="status-counts-as-completed"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[(ngModel)]="form.countsAsCompleted"
|
||||
/>
|
||||
<label class="form-check-label" for="status-counts-as-completed">
|
||||
Compte comme terminé (inclus dans le calcul d'avancement)
|
||||
</label>
|
||||
</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()">
|
||||
@@ -93,6 +105,9 @@
|
||||
<span class="status-item__position">{{ i + 1 }}</span>
|
||||
<span class="status-badge" [style.background]="status.bg" [style.color]="status.color">{{ status.label }}</span>
|
||||
<span class="status-id text-muted">{{ status.id }}</span>
|
||||
@if (status.countsAsCompleted) {
|
||||
<span class="status-completed-badge text-success small">✓ compte comme terminé</span>
|
||||
}
|
||||
<div class="status-item__actions">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
|
||||
@@ -81,14 +81,14 @@ describe('Statuses', () => {
|
||||
|
||||
it('saves a new status', () => {
|
||||
(component as any).openCreate();
|
||||
(component as any).form = { id: 'test-id', label: 'TEST', bg: '#fff', color: '#000' };
|
||||
(component as any).form = { id: 'test-id', label: 'TEST', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
(component as any).save();
|
||||
expect(store.getById('test-id')?.label).toBe('TEST');
|
||||
});
|
||||
|
||||
it('reports duplicate id error', () => {
|
||||
(component as any).openCreate();
|
||||
(component as any).form = { id: 'draft', label: 'DRAFT', bg: '#fff', color: '#000' };
|
||||
(component as any).form = { id: 'draft', label: 'DRAFT', bg: '#fff', color: '#000', countsAsCompleted: false };
|
||||
(component as any).save();
|
||||
expect((component as any).idError()).toBeTruthy();
|
||||
});
|
||||
@@ -105,7 +105,7 @@ describe('Statuses', () => {
|
||||
it('saves updated label', () => {
|
||||
const status = DEFAULT_STATUSES[0];
|
||||
(component as any).openEdit(status);
|
||||
(component as any).form = { ...status, label: 'MODIFIÉ' };
|
||||
(component as any).form = { ...status, label: 'MODIFIÉ', countsAsCompleted: false };
|
||||
(component as any).save();
|
||||
expect(store.getById(status.id)?.label).toBe('MODIFIÉ');
|
||||
});
|
||||
|
||||
@@ -80,16 +80,37 @@ describe('StatusesStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompleted', () => {
|
||||
it('returns true for done status', () => {
|
||||
expect(store.isCompleted('done')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-completed statuses', () => {
|
||||
expect(store.isCompleted('draft')).toBe(false);
|
||||
expect(store.isCompleted('todo')).toBe(false);
|
||||
expect(store.isCompleted('in-progress')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown status', () => {
|
||||
expect(store.isCompleted('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a custom status with countsAsCompleted true', () => {
|
||||
store.create({ id: 'abandoned', label: 'ABANDONNÉE', bg: '#f1f5f9', color: '#64748b', countsAsCompleted: true });
|
||||
expect(store.isCompleted('abandoned')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('adds a new status with the next order value', () => {
|
||||
store.create({ id: 'blocked', label: 'BLOQUÉ', bg: '#fee2e2', color: '#991b1b' });
|
||||
store.create({ id: 'blocked', label: 'BLOQUÉ', bg: '#fee2e2', color: '#991b1b', countsAsCompleted: false });
|
||||
const added = store.getById('blocked');
|
||||
expect(added?.label).toBe('BLOQUÉ');
|
||||
expect(added?.order).toBe(DEFAULT_STATUSES.length);
|
||||
});
|
||||
|
||||
it('persists the new status to localStorage', () => {
|
||||
store.create({ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000' });
|
||||
store.create({ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(stored.some((s: { id: string }) => s.id === 'custom')).toBe(true);
|
||||
});
|
||||
@@ -97,7 +118,7 @@ describe('StatusesStore', () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('updates label, bg and color of an existing status', () => {
|
||||
store.update('draft', { label: 'NOUVEAU', bg: '#fff', color: '#000' });
|
||||
store.update('draft', { label: 'NOUVEAU', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
const updated = store.getById('draft');
|
||||
expect(updated?.label).toBe('NOUVEAU');
|
||||
expect(updated?.bg).toBe('#fff');
|
||||
|
||||
@@ -6,13 +6,14 @@ export type StatusEntity = {
|
||||
bg: string;
|
||||
color: string;
|
||||
order: number;
|
||||
countsAsCompleted: boolean;
|
||||
};
|
||||
|
||||
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 },
|
||||
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
|
||||
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
|
||||
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false },
|
||||
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'bonsai_statuses';
|
||||
@@ -27,13 +28,17 @@ export class StatusesStore {
|
||||
return this.data().find((s) => s.id === id);
|
||||
}
|
||||
|
||||
isCompleted(statusId: string): boolean {
|
||||
return this.data().find((s) => s.id === statusId)?.countsAsCompleted ?? false;
|
||||
}
|
||||
|
||||
create(status: Omit<StatusEntity, 'order'>): void {
|
||||
const maxOrder = this.data().reduce((max, s) => Math.max(max, s.order), -1);
|
||||
this.data.update((list) => [...list, { ...status, order: maxOrder + 1 }]);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
|
||||
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color' | 'countsAsCompleted'>>): void {
|
||||
this.data.update((list) =>
|
||||
list.map((s) => (s.id === id ? { ...s, ...changes } : s)),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 };
|
||||
type StatusForm = { id: string; label: string; bg: string; color: string; countsAsCompleted: boolean };
|
||||
|
||||
@Component({
|
||||
selector: 'app-statuses',
|
||||
@@ -31,7 +31,7 @@ export class Statuses {
|
||||
}
|
||||
|
||||
protected openEdit(status: StatusEntity): void {
|
||||
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
|
||||
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color, countsAsCompleted: status.countsAsCompleted };
|
||||
this.idError.set(null);
|
||||
this.editingId.set(status.id);
|
||||
this.formMode.set('edit');
|
||||
@@ -50,11 +50,11 @@ export class Statuses {
|
||||
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 });
|
||||
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
|
||||
} 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.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
|
||||
}
|
||||
this.formMode.set(null);
|
||||
this.editingId.set(null);
|
||||
@@ -104,6 +104,6 @@ export class Statuses {
|
||||
}
|
||||
|
||||
private emptyForm(): StatusForm {
|
||||
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
|
||||
return { id: '', label: '', bg: '#e2e8f0', color: '#475569', countsAsCompleted: false };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user