Ajoute projet et migration milestone

Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
2026-05-31 10:00:36 +02:00
parent 401da09f8f
commit 54d1534d4d
39 changed files with 1565 additions and 288 deletions
+25 -12
View File
@@ -6,21 +6,34 @@ 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 { ProjectWorkspace } from './projects/project-workspace/project-workspace';
import { Statuses } from './statuses/statuses'; import { Statuses } from './statuses/statuses';
import { authGuard } from './auth/auth.guard'; import { authGuard } from './auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' }, { path: '', pathMatch: 'full', redirectTo: 'projects' },
{ path: 'home', component: Home }, { path: 'home', component: Home },
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, {
{ path: 'project', component: Projects, canActivate: [authGuard] }, path: 'projects',
{ path: 'projects', redirectTo: 'project' }, canActivate: [authGuard],
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] }, children: [
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] }, { path: '', component: Projects, pathMatch: 'full' },
{ path: 'issues', component: Issues, canActivate: [authGuard] }, {
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] }, path: ':projectId',
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] }, component: ProjectWorkspace,
{ path: 'milestones', component: Milestones, canActivate: [authGuard] }, children: [
{ path: 'statuses', component: Statuses, canActivate: [authGuard] }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: '**', redirectTo: 'home' }, { path: 'dashboard', component: Dashboard },
{ path: 'issues', component: Issues },
{ path: 'issues/new', component: IssueDetail },
{ path: 'issues/:id', component: IssueDetail },
{ path: 'milestones', component: Milestones },
{ path: 'milestones/new', component: MilestoneDetail },
{ path: 'milestones/:id', component: MilestoneDetail },
{ path: 'statuses', component: Statuses },
],
},
],
},
{ path: '**', redirectTo: 'projects' },
]; ];
+10 -8
View File
@@ -1,6 +1,7 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { ProjectContextService } from '../projects/project-context.service';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { Dashboard } from './dashboard'; import { Dashboard } from './dashboard';
import { IssueEntity, IssuesStore } from '../issues/issues.store'; import { IssueEntity, IssuesStore } from '../issues/issues.store';
@@ -89,6 +90,7 @@ describe('Dashboard', () => {
{ provide: IssuesStore, useValue: issuesStore }, { provide: IssuesStore, useValue: issuesStore },
{ provide: MilestonesStore, useValue: milestonesStore }, { provide: MilestonesStore, useValue: milestonesStore },
{ provide: StatusesStore, useValue: statusesStore }, { provide: StatusesStore, useValue: statusesStore },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
], ],
}).compileComponents(); }).compileComponents();
@@ -296,28 +298,28 @@ describe('Dashboard', () => {
}); });
describe('navigation', () => { describe('navigation', () => {
it('navigue vers /issues/:id via openIssue', () => { it('navigue vers /projects/:pid/issues/:id via openIssue', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).openIssue(42); (component as any).openIssue(42);
expect(spy).toHaveBeenCalledWith(['/issues', 42]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
}); });
it('navigue vers /milestones/:id via openMilestone', () => { it('navigue vers /projects/:pid/milestones/:id via openMilestone', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).openMilestone(7); (component as any).openMilestone(7);
expect(spy).toHaveBeenCalledWith(['/milestones', 7]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 7]);
}); });
it('navigue vers /issues via navigateToIssues', () => { it('navigue vers /projects/:pid/issues via navigateToIssues', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).navigateToIssues(); (component as any).navigateToIssues();
expect(spy).toHaveBeenCalledWith(['/issues']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
}); });
it('navigue vers /milestones via navigateToMilestones', () => { it('navigue vers /projects/:pid/milestones via navigateToMilestones', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).navigateToMilestones(); (component as any).navigateToMilestones();
expect(spy).toHaveBeenCalledWith(['/milestones']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
}); });
}); });
}); });
+13 -6
View File
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
import { IssueEntity, IssuesStore } from '../issues/issues.store'; import { IssueEntity, IssuesStore } from '../issues/issues.store';
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
import { StatusesStore } from '../statuses/statuses.store'; import { StatusesStore } from '../statuses/statuses.store';
import { ProjectContextService } from '../projects/project-context.service';
@Component({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
@@ -15,10 +16,12 @@ export class Dashboard {
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly statusesStore = inject(StatusesStore); private readonly statusesStore = inject(StatusesStore);
private readonly projectContext = inject(ProjectContextService);
constructor() { constructor() {
this.issuesStore.load(); const projectId = this.projectContext.projectId()!;
this.milestonesStore.load(); this.issuesStore.load(projectId);
this.milestonesStore.load(projectId);
} }
protected readonly totalIssues = computed(() => this.issuesStore.issues().length); protected readonly totalIssues = computed(() => this.issuesStore.issues().length);
@@ -152,18 +155,22 @@ export class Dashboard {
} }
protected openIssue(id: number): void { protected openIssue(id: number): void {
this.router.navigate(['/issues', id]); const pid = this.projectContext.projectId();
this.router.navigate(['/projects', pid, 'issues', id]);
} }
protected openMilestone(id: number): void { protected openMilestone(id: number): void {
this.router.navigate(['/milestones', id]); const pid = this.projectContext.projectId();
this.router.navigate(['/projects', pid, 'milestones', id]);
} }
protected navigateToIssues(): void { protected navigateToIssues(): void {
this.router.navigate(['/issues']); const pid = this.projectContext.projectId();
this.router.navigate(['/projects', pid, 'issues']);
} }
protected navigateToMilestones(): void { protected navigateToMilestones(): void {
this.router.navigate(['/milestones']); const pid = this.projectContext.projectId();
this.router.navigate(['/projects', pid, 'milestones']);
} }
} }
@@ -5,7 +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'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
import { MilestonesStore } from '../../milestones/milestones.store'; import { MilestonesStore } from '../../milestones/milestones.store';
@Component({ @Component({
@@ -7,6 +7,7 @@ import { vi } from 'vitest';
import { IssueDetail } from './issue-detail'; import { IssueDetail } from './issue-detail';
import { IssueEntity, IssuesStore } from '../issues.store'; import { IssueEntity, IssuesStore } from '../issues.store';
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
import { ProjectContextService } from '../../projects/project-context.service';
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
id: 99, id: 99,
@@ -161,6 +162,7 @@ describe('IssueDetail — existing issue', () => {
{ provide: ActivatedRoute, useValue: makeRoute('1') }, { provide: ActivatedRoute, useValue: makeRoute('1') },
{ provide: IssuesStore, useValue: store }, { provide: IssuesStore, useValue: store },
{ provide: MilestonesStore, useValue: milestonesStore }, { provide: MilestonesStore, useValue: milestonesStore },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
], ],
}).compileComponents(); }).compileComponents();
@@ -469,19 +471,19 @@ describe('IssueDetail — existing issue', () => {
}); });
describe('deleteIssue', () => { describe('deleteIssue', () => {
it('removes the issue and navigates to /issues', async () => { it('removes the issue and navigates to /projects/:pid/issues', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
await (component as any).deleteIssue(); await (component as any).deleteIssue();
expect(store.getById(1)).toBeUndefined(); expect(store.getById(1)).toBeUndefined();
expect(spy).toHaveBeenCalledWith(['/issues']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
}); });
}); });
describe('cancelCreation', () => { describe('cancelCreation', () => {
it('navigates to /issues', async () => { it('navigates to /projects/:pid/issues', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).cancelCreation(); (component as any).cancelCreation();
expect(spy).toHaveBeenCalledWith(['/issues']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
}); });
}); });
@@ -642,7 +644,7 @@ describe('IssueDetail — existing issue', () => {
(component as any).issue.epic = 'Nav Epic'; (component as any).issue.epic = 'Nav Epic';
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).navigateToEpic(); (component as any).navigateToEpic();
expect(spy).toHaveBeenCalledWith(['/issues', 100]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 100]);
}); });
it('does nothing when no matching epic is found', () => { it('does nothing when no matching epic is found', () => {
@@ -657,7 +659,7 @@ describe('IssueDetail — existing issue', () => {
it('navigates to the composed issue detail', async () => { it('navigates to the composed issue detail', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).openComposedIssue(42); (component as any).openComposedIssue(42);
expect(spy).toHaveBeenCalledWith(['/issues', 42]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
}); });
}); });
@@ -943,7 +945,7 @@ describe('IssueDetail — existing issue', () => {
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]);
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).navigateToMilestone(); (component as any).navigateToMilestone();
expect(spy).toHaveBeenCalledWith(['/milestones', 10]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 10]);
}); });
it('does nothing when no milestone is linked', () => { it('does nothing when no milestone is linked', () => {
@@ -1081,6 +1083,7 @@ describe('IssueDetail — new issue route', () => {
}, },
{ provide: IssuesStore, useValue: store }, { provide: IssuesStore, useValue: store },
{ provide: MilestonesStore, useValue: new FakeMilestonesStore() }, { provide: MilestonesStore, useValue: new FakeMilestonesStore() },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
], ],
}).compileComponents(); }).compileComponents();
@@ -1115,6 +1118,6 @@ describe('IssueDetail — new issue route', () => {
(component as any).issue.name = 'Brand New Issue'; (component as any).issue.name = 'Brand New Issue';
await (component as any).saveIssue(true); await (component as any).saveIssue(true);
expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true); expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true);
expect(spy).toHaveBeenCalledWith(['/issues', 10]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 10]);
}); });
}); });
+15 -8
View File
@@ -10,6 +10,7 @@ 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 '../../statuses/statuses.store'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram'; import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
import { ProjectContextService } from '../../projects/project-context.service';
@Component({ @Component({
selector: 'app-issue-detail', selector: 'app-issue-detail',
@@ -24,6 +25,7 @@ export class IssueDetail {
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly statusesStore = inject(StatusesStore); private readonly statusesStore = inject(StatusesStore);
private readonly sanitizer = inject(DomSanitizer); private readonly sanitizer = inject(DomSanitizer);
private readonly projectContext = inject(ProjectContextService);
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();
@@ -32,12 +34,17 @@ export class IssueDetail {
protected moreMenuOpen = false; protected moreMenuOpen = false;
protected statusMenuOpen = false; protected statusMenuOpen = false;
private get projectId(): number {
return this.projectContext.projectId()!;
}
constructor() { constructor() {
const idParam = this.route.snapshot.paramMap.get('id'); const idParam = this.route.snapshot.paramMap.get('id');
const safeId = Number(idParam ?? 0); const safeId = Number(idParam ?? 0);
const projectId = this.projectId;
this.milestonesStore.load(); this.milestonesStore.load(projectId);
this.issuesStore.load().then(() => { this.issuesStore.load(projectId).then(() => {
if (safeId) { if (safeId) {
const found = this.issuesStore.getById(safeId); const found = this.issuesStore.getById(safeId);
if (found) this.issue = { ...found }; if (found) this.issue = { ...found };
@@ -473,7 +480,7 @@ export class IssueDetail {
} }
protected openComposedIssue(id: number): void { protected openComposedIssue(id: number): void {
this.router.navigate(['/issues', id]); this.router.navigate(['/projects', this.projectId, 'issues', id]);
} }
protected get epicIssueId(): number | null { protected get epicIssueId(): number | null {
@@ -483,7 +490,7 @@ export class IssueDetail {
protected navigateToEpic(): void { protected navigateToEpic(): void {
if (this.epicIssueId !== null) { if (this.epicIssueId !== null) {
this.router.navigate(['/issues', this.epicIssueId]); this.router.navigate(['/projects', this.projectId, 'issues', this.epicIssueId]);
} }
} }
@@ -520,7 +527,7 @@ export class IssueDetail {
protected navigateToMilestone(): void { protected navigateToMilestone(): void {
if (this.currentMilestoneId !== null) { if (this.currentMilestoneId !== null) {
this.router.navigate(['/milestones', this.currentMilestoneId]); this.router.navigate(['/projects', this.projectId, 'milestones', this.currentMilestoneId]);
} }
} }
@@ -531,17 +538,17 @@ export class IssueDetail {
const saved = await this.issuesStore.upsert(this.issue); const saved = await this.issuesStore.upsert(this.issue);
this.issue = { ...saved }; this.issue = { ...saved };
if (this.isNewIssueRoute) { if (this.isNewIssueRoute) {
this.router.navigate(['/issues', saved.id]); this.router.navigate(['/projects', this.projectId, 'issues', saved.id]);
} }
} }
protected cancelCreation(): void { protected cancelCreation(): void {
this.router.navigate(['/issues']); this.router.navigate(['/projects', this.projectId, 'issues']);
} }
protected async deleteIssue(): Promise<void> { protected async deleteIssue(): Promise<void> {
await this.issuesStore.deleteById(this.issue.id); await this.issuesStore.deleteById(this.issue.id);
this.router.navigate(['/issues']); this.router.navigate(['/projects', this.projectId, 'issues']);
} }
protected async updateStatus(status: IssueEntity['status']): Promise<void> { protected async updateStatus(status: IssueEntity['status']): Promise<void> {
+4 -2
View File
@@ -9,8 +9,10 @@ export const API_BASE_URL = '/api';
export class IssuesApiService { export class IssuesApiService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
getAll(): Observable<IssueEntity[]> { getAll(projectId: number): Observable<IssueEntity[]> {
return this.http.get<IssueEntity[]>(`${API_BASE_URL}/issues`); return this.http.get<IssueEntity[]>(`${API_BASE_URL}/issues`, {
params: { projectId: projectId.toString() },
});
} }
create(issue: Omit<IssueEntity, 'id'>): Observable<IssueEntity> { create(issue: Omit<IssueEntity, 'id'>): Observable<IssueEntity> {
+5 -3
View File
@@ -7,6 +7,7 @@ import { Issues } from './issues';
import { IssueEntity, IssuesStore } from './issues.store'; import { IssueEntity, IssuesStore } from './issues.store';
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
import { StatusesStore } from '../statuses/statuses.store'; import { StatusesStore } from '../statuses/statuses.store';
import { ProjectContextService } from '../projects/project-context.service';
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
id: 99, id: 99,
@@ -174,6 +175,7 @@ describe('Issues', () => {
{ provide: IssuesStore, useValue: store }, { provide: IssuesStore, useValue: store },
{ provide: MilestonesStore, useValue: milestonesStore }, { provide: MilestonesStore, useValue: milestonesStore },
{ provide: StatusesStore, useValue: statusesStore }, { provide: StatusesStore, useValue: statusesStore },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
], ],
}).compileComponents(); }).compileComponents();
@@ -281,10 +283,10 @@ describe('Issues', () => {
}); });
describe('createIssue', () => { describe('createIssue', () => {
it('navigates to /issues/new', () => { it('navigates to /projects/:pid/issues/new', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).createIssue(); (component as any).createIssue();
expect(spy).toHaveBeenCalledWith(['/issues/new']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 'new']);
}); });
}); });
@@ -292,7 +294,7 @@ describe('Issues', () => {
it('navigates to the issue detail page', async () => { it('navigates to the issue detail page', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).openIssue(42); (component as any).openIssue(42);
expect(spy).toHaveBeenCalledWith(['/issues', 42]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
}); });
}); });
+20 -7
View File
@@ -28,9 +28,12 @@ describe('IssuesStore', () => {
let store: IssuesStore; let store: IssuesStore;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
const PROJECT_ID = 1;
const ISSUES_URL = `${API_BASE_URL}/issues?projectId=${PROJECT_ID}`;
const loadWith = async (issues: IssueEntity[]) => { const loadWith = async (issues: IssueEntity[]) => {
const p = store.load(); const p = store.load(PROJECT_ID);
httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues); httpMock.expectOne(ISSUES_URL).flush(issues);
await p; await p;
}; };
@@ -57,18 +60,28 @@ describe('IssuesStore', () => {
}); });
it('sets loading to true during load and false after', async () => { it('sets loading to true during load and false after', async () => {
const p = store.load(); const p = store.load(PROJECT_ID);
expect(store.loading()).toBe(true); expect(store.loading()).toBe(true);
httpMock.expectOne(`${API_BASE_URL}/issues`).flush([]); httpMock.expectOne(ISSUES_URL).flush([]);
await p; await p;
expect(store.loading()).toBe(false); expect(store.loading()).toBe(false);
expect(store.loaded()).toBe(true); expect(store.loaded()).toBe(true);
}); });
it('does not reload if already loaded', async () => { it('does not reload if already loaded for the same project', async () => {
await loadWith([]); await loadWith([]);
await store.load(); await store.load(PROJECT_ID);
httpMock.expectNone(`${API_BASE_URL}/issues`); httpMock.expectNone(ISSUES_URL);
});
it('reloads when projectId changes', async () => {
await loadWith([makeIssue({ id: 1 })]);
const url2 = `${API_BASE_URL}/issues?projectId=2`;
const p = store.load(2);
httpMock.expectOne(url2).flush([makeIssue({ id: 2 })]);
await p;
expect(store.issues().length).toBe(1);
expect(store.issues()[0].id).toBe(2);
}); });
}); });
+6 -3
View File
@@ -37,6 +37,7 @@ export type IssueEntity = {
export class IssuesStore { export class IssuesStore {
private readonly api = inject(IssuesApiService); private readonly api = inject(IssuesApiService);
private readonly data = signal<IssueEntity[]>([]); private readonly data = signal<IssueEntity[]>([]);
private currentProjectId: number | null = null;
readonly loading = signal(false); readonly loading = signal(false);
readonly loaded = signal(false); readonly loaded = signal(false);
@@ -51,11 +52,13 @@ export class IssuesStore {
return ids.length === 0 ? 1 : Math.max(...ids) + 1; return ids.length === 0 ? 1 : Math.max(...ids) + 1;
} }
async load(): Promise<void> { async load(projectId: number): Promise<void> {
if (this.loaded()) return; if (this.loaded() && this.currentProjectId === projectId) return;
this.currentProjectId = projectId;
this.loaded.set(false);
this.loading.set(true); this.loading.set(true);
try { try {
const issues = await firstValueFrom(this.api.getAll()); const issues = await firstValueFrom(this.api.getAll(projectId));
this.data.set(issues.map((i) => this.normalizeIssue(i))); this.data.set(issues.map((i) => this.normalizeIssue(i)));
this.loaded.set(true); this.loaded.set(true);
} finally { } finally {
+7 -4
View File
@@ -4,6 +4,7 @@ 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 '../statuses/statuses.store'; import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
import { ProjectContextService } from '../projects/project-context.service';
@Component({ @Component({
selector: 'app-issues', selector: 'app-issues',
@@ -16,10 +17,12 @@ export class Issues {
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly statusesStore = inject(StatusesStore); private readonly statusesStore = inject(StatusesStore);
private readonly projectContext = inject(ProjectContextService);
constructor() { constructor() {
this.issuesStore.load(); const projectId = this.projectContext.projectId()!;
this.milestonesStore.load(); this.issuesStore.load(projectId);
this.milestonesStore.load(projectId);
} }
protected readonly issues = this.issuesStore.issues; protected readonly issues = this.issuesStore.issues;
@@ -138,11 +141,11 @@ export class Issues {
} }
protected createIssue(): void { protected createIssue(): void {
this.router.navigate(['/issues/new']); this.router.navigate(['/projects', this.projectContext.projectId(), 'issues', 'new']);
} }
protected openIssue(issueId: number): void { protected openIssue(issueId: number): void {
this.router.navigate(['/issues', issueId]); this.router.navigate(['/projects', this.projectContext.projectId(), 'issues', issueId]);
} }
protected getProgress(issue: IssueEntity): number { protected getProgress(issue: IssueEntity): number {
+26
View File
@@ -124,3 +124,29 @@
border-color: #fca5a5; border-color: #fca5a5;
} }
.sidebar-project-section {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.5rem;
}
.sidebar-project-name {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9ca3af;
padding: 0.4rem 0.75rem 0.2rem;
}
.sidebar-link--project {
padding-left: 1.25rem;
}
.sidebar-divider {
height: 1px;
background: #e5e7eb;
margin: 0.5rem 0.25rem;
}
+24 -9
View File
@@ -30,16 +30,31 @@
@if (keycloak.isAuthenticated()) { @if (keycloak.isAuthenticated()) {
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@for (item of menuItems; track item.path) { <a
<a class="sidebar-link"
class="sidebar-link" routerLink="/projects"
[routerLink]="item.path" routerLinkActive="sidebar-link--active"
routerLinkActive="sidebar-link--active" [routerLinkActiveOptions]="{ exact: true }"
[routerLinkActiveOptions]="{ exact: true }" >
> Projets
{{ item.label }} </a>
</a>
@if (projectContext.project(); as project) {
<div class="sidebar-project-section">
<div class="sidebar-project-name">{{ project.name }}</div>
@for (item of projectMenuItems(); track item.path) {
<a
class="sidebar-link sidebar-link--project"
[routerLink]="item.path"
routerLinkActive="sidebar-link--active"
[routerLinkActiveOptions]="{ exact: false }"
>
{{ item.label }}
</a>
}
</div>
} }
</nav> </nav>
<div class="sidebar-user"> <div class="sidebar-user">
+37 -23
View File
@@ -3,26 +3,35 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { KeycloakService } from '../auth/keycloak.service'; import { KeycloakService } from '../auth/keycloak.service';
import { ProjectContextService } from '../projects/project-context.service';
import { ProjectEntity } from '../projects/projects.store';
import { Menu } from './menu'; import { Menu } from './menu';
describe('Menu', () => { describe('Menu', () => {
let component: Menu; let component: Menu;
let fixture: ComponentFixture<Menu>; let fixture: ComponentFixture<Menu>;
const keycloakMock = {
isAuthenticated: signal(false), const isAuthenticated = signal(false);
username: signal<string | undefined>(undefined), const username = signal<string | undefined>(undefined);
logout: vi.fn(), const projectId = signal<number | null>(null);
login: vi.fn(), const project = signal<ProjectEntity | null>(null);
};
const keycloakMock = { isAuthenticated, username, logout: vi.fn(), login: vi.fn() };
const projectContextMock = { projectId, project };
beforeEach(async () => { beforeEach(async () => {
isAuthenticated.set(false);
projectId.set(null);
project.set(null);
keycloakMock.logout = vi.fn(); keycloakMock.logout = vi.fn();
keycloakMock.login = vi.fn(); keycloakMock.login = vi.fn();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Menu], imports: [Menu],
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: KeycloakService, useValue: keycloakMock }, { provide: KeycloakService, useValue: keycloakMock },
{ provide: ProjectContextService, useValue: projectContextMock },
], ],
}).compileComponents(); }).compileComponents();
@@ -35,28 +44,33 @@ describe('Menu', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('should have five menu items', () => { describe('projectMenuItems', () => {
const items = (component as any).menuItems as { label: string; path: string }[]; it('returns empty array when no project is selected', () => {
expect(items.length).toBe(6); expect((component as any).projectMenuItems()).toEqual([]);
}); });
it('should contain Issues link', () => { it('returns dashboard, issues, milestones and statuts items when a project is selected', () => {
const items = (component as any).menuItems as { label: string; path: string }[]; projectId.set(5);
expect(items.some((i) => i.path === '/issues')).toBe(true); const items = (component as any).projectMenuItems();
}); expect(items.length).toBe(4);
expect(items[0].path).toBe('/projects/5/dashboard');
it('should contain Milestones link', () => { expect(items[1].path).toBe('/projects/5/issues');
const items = (component as any).menuItems as { label: string; path: string }[]; expect(items[2].path).toBe('/projects/5/milestones');
expect(items.some((i) => i.path === '/milestones')).toBe(true); expect(items[3].path).toBe('/projects/5/statuses');
}); });
it('should contain Dashboard link', () => {
const items = (component as any).menuItems as { label: string; path: string }[];
expect(items.some((i) => i.path === '/dashboard')).toBe(true);
}); });
it('logout calls keycloak.logout()', () => { it('logout calls keycloak.logout()', () => {
(component as any).logout(); (component as any).logout();
expect(keycloakMock.logout).toHaveBeenCalled(); expect(keycloakMock.logout).toHaveBeenCalled();
}); });
it('shows Projets link when authenticated', async () => {
isAuthenticated.set(true);
fixture.detectChanges();
await fixture.whenStable();
const links = fixture.nativeElement.querySelectorAll('a.sidebar-link');
const hrefs = Array.from(links).map((l: any) => l.getAttribute('href') ?? '');
expect(hrefs.some((h) => h === '/projects')).toBe(true);
});
}); });
+13 -9
View File
@@ -1,7 +1,8 @@
import { Component, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../package.json'; import { version } from '../../../package.json';
import { KeycloakService } from '../auth/keycloak.service'; import { KeycloakService } from '../auth/keycloak.service';
import { ProjectContextService } from '../projects/project-context.service';
@Component({ @Component({
selector: 'app-menu', selector: 'app-menu',
@@ -12,15 +13,18 @@ import { KeycloakService } from '../auth/keycloak.service';
export class Menu { export class Menu {
protected readonly version = version; protected readonly version = version;
protected readonly keycloak = inject(KeycloakService); protected readonly keycloak = inject(KeycloakService);
protected readonly projectContext = inject(ProjectContextService);
protected readonly menuItems = [ protected readonly projectMenuItems = computed(() => {
{ label: 'Accueil', path: '/home' }, const pid = this.projectContext.projectId();
{ label: 'Tableau de bord', path: '/dashboard' }, if (pid === null) return [];
{ label: 'Projet', path: '/project' }, return [
{ label: 'Issues', path: '/issues' }, { label: 'Tableau de bord', path: `/projects/${pid}/dashboard` },
{ label: 'Milestones', path: '/milestones' }, { label: 'Issues', path: `/projects/${pid}/issues` },
{ label: 'Statuts', path: '/statuses' }, { label: 'Milestones', path: `/projects/${pid}/milestones` },
]; { label: 'Statuts', path: `/projects/${pid}/statuses` },
];
});
protected logout(): void { protected logout(): void {
this.keycloak.logout(); this.keycloak.logout();
@@ -255,3 +255,35 @@
.markdown-body > *:last-child { .markdown-body > *:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
/* Migration panel */
.migrate-panel {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.migrate-title {
font-size: 0.9rem;
font-weight: 600;
color: #1e40af;
}
.migrate-select {
max-width: 320px;
}
.migrate-empty {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
.migrate-actions {
display: flex;
gap: 0.5rem;
}
@@ -11,6 +11,8 @@
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button> <button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
@if (moreMenuOpen) { @if (moreMenuOpen) {
<div class="more-menu dropdown-menu show"> <div class="more-menu dropdown-menu show">
<button type="button" class="dropdown-item" (click)="openMigrate()">Migrer vers un autre projet</button>
<div class="dropdown-divider"></div>
<button type="button" class="dropdown-item text-danger" (click)="deleteMilestone()">Supprimer</button> <button type="button" class="dropdown-item text-danger" (click)="deleteMilestone()">Supprimer</button>
</div> </div>
} }
@@ -22,6 +24,40 @@
</div> </div>
</div> </div>
<!-- Panneau de migration -->
@if (showMigratePanel) {
<div class="migrate-panel mb-3">
<div class="migrate-title">Migrer « {{ milestone.name }} » vers un autre projet</div>
@if (availableProjects.length === 0) {
<p class="migrate-empty">Aucun autre projet disponible.</p>
} @else {
<select
class="migrate-select form-select form-select-sm"
[(ngModel)]="selectedMigrateProjectId"
name="migrateProject"
>
<option [ngValue]="null" disabled>Sélectionner un projet…</option>
@for (project of availableProjects; track project.id) {
<option [ngValue]="project.id">{{ project.name }}</option>
}
</select>
}
<div class="migrate-actions">
<button
type="button"
class="btn btn-sm btn-primary"
(click)="confirmMigrate()"
[disabled]="selectedMigrateProjectId === null"
>
Confirmer la migration
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelMigrate()">
Annuler
</button>
</div>
</div>
}
<!-- Titre --> <!-- Titre -->
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-body py-2"> <div class="card-body py-2">
@@ -8,6 +8,8 @@ import { MilestoneDetail } from './milestone-detail';
import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { IssueEntity, IssuesStore } from '../../issues/issues.store';
import { MilestoneEntity, MilestonesStore } from '../milestones.store'; import { MilestoneEntity, MilestonesStore } from '../milestones.store';
import { StatusesStore } from '../../settings/statuses/statuses.store'; import { StatusesStore } from '../../settings/statuses/statuses.store';
import { ProjectContextService } from '../../projects/project-context.service';
import { ProjectEntity, ProjectsStore } from '../../projects/projects.store';
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
id: 99, id: 99,
@@ -125,6 +127,11 @@ class FakeMilestonesStore {
this._data.update((list) => list.filter((m) => m.id !== id)); this._data.update((list) => list.filter((m) => m.id !== id));
return Promise.resolve(); return Promise.resolve();
} }
migrate(id: number, _targetProjectId: number): Promise<void> {
this._data.update((list) => list.filter((m) => m.id !== id));
return Promise.resolve();
}
} }
function makeRoute(id = '1', path = 'milestones/:id') { function makeRoute(id = '1', path = 'milestones/:id') {
@@ -149,6 +156,13 @@ describe('MilestoneDetail', () => {
issuesStore = new FakeIssuesStore(); issuesStore = new FakeIssuesStore();
milestonesStore = new FakeMilestonesStore(); milestonesStore = new FakeMilestonesStore();
statusesStore = new FakeStatusesStore(); statusesStore = new FakeStatusesStore();
const projectsStoreMock = {
projects: signal<ProjectEntity[]>([
{ id: 1, name: 'Projet A', owner: 'Alice', status: 'Actif' as const, progress: 50 },
{ id: 2, name: 'Projet B', owner: 'Bob', status: 'Actif' as const, progress: 30 },
]).asReadonly(),
load: vi.fn().mockResolvedValue(undefined),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [MilestoneDetail], imports: [MilestoneDetail],
providers: [ providers: [
@@ -157,6 +171,8 @@ describe('MilestoneDetail', () => {
{ provide: IssuesStore, useValue: issuesStore }, { provide: IssuesStore, useValue: issuesStore },
{ provide: MilestonesStore, useValue: milestonesStore }, { provide: MilestonesStore, useValue: milestonesStore },
{ provide: StatusesStore, useValue: statusesStore }, { provide: StatusesStore, useValue: statusesStore },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
{ provide: ProjectsStore, useValue: projectsStoreMock },
], ],
}).compileComponents(); }).compileComponents();
@@ -427,7 +443,7 @@ describe('MilestoneDetail', () => {
it('navigates to the issue detail page', () => { it('navigates to the issue detail page', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).navigateToIssue(42); (component as any).navigateToIssue(42);
expect(spy).toHaveBeenCalledWith(['/issues', 42]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
}); });
}); });
@@ -643,18 +659,60 @@ describe('MilestoneDetail', () => {
}); });
describe('deleteMilestone', () => { describe('deleteMilestone', () => {
it('removes the milestone and navigates to /milestones', async () => { it('removes the milestone and navigates to /projects/:pid/milestones', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
await (component as any).deleteMilestone(); await (component as any).deleteMilestone();
expect(spy).toHaveBeenCalledWith(['/milestones']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
});
});
describe('migration', () => {
it('availableProjects excludes the current project', () => {
const projects: ProjectEntity[] = (component as any).availableProjects;
expect(projects.every((p: ProjectEntity) => p.id !== 1)).toBe(true);
});
it('availableProjects includes other projects', () => {
const projects: ProjectEntity[] = (component as any).availableProjects;
expect(projects.some((p: ProjectEntity) => p.id === 2)).toBe(true);
});
it('openMigrate closes the more menu and shows the migration panel', () => {
(component as any).moreMenuOpen = true;
(component as any).openMigrate();
expect((component as any).moreMenuOpen).toBe(false);
expect((component as any).showMigratePanel).toBe(true);
expect((component as any).selectedMigrateProjectId).toBeNull();
});
it('cancelMigrate hides the migration panel', () => {
(component as any).showMigratePanel = true;
(component as any).selectedMigrateProjectId = 2;
(component as any).cancelMigrate();
expect((component as any).showMigratePanel).toBe(false);
expect((component as any).selectedMigrateProjectId).toBeNull();
});
it('confirmMigrate does nothing when no project is selected', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).selectedMigrateProjectId = null;
await (component as any).confirmMigrate();
expect(spy).not.toHaveBeenCalled();
});
it('confirmMigrate migrates and navigates to the target project milestones', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).selectedMigrateProjectId = 2;
await (component as any).confirmMigrate();
expect(spy).toHaveBeenCalledWith(['/projects', 2, 'milestones']);
}); });
}); });
describe('cancelCreation', () => { describe('cancelCreation', () => {
it('navigates to /milestones', () => { it('navigates to /projects/:pid/milestones', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).cancelCreation(); (component as any).cancelCreation();
expect(spy).toHaveBeenCalledWith(['/milestones']); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
}); });
}); });
@@ -795,6 +853,10 @@ describe('MilestoneDetail — new route', () => {
beforeEach(async () => { beforeEach(async () => {
milestonesStore = new FakeMilestonesStore(); milestonesStore = new FakeMilestonesStore();
const projectsStoreMock = {
projects: signal<ProjectEntity[]>([]).asReadonly(),
load: vi.fn().mockResolvedValue(undefined),
};
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [MilestoneDetail], imports: [MilestoneDetail],
providers: [ providers: [
@@ -811,6 +873,8 @@ describe('MilestoneDetail — new route', () => {
}, },
{ provide: IssuesStore, useValue: new FakeIssuesStore() }, { provide: IssuesStore, useValue: new FakeIssuesStore() },
{ provide: MilestonesStore, useValue: milestonesStore }, { provide: MilestonesStore, useValue: milestonesStore },
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
{ provide: ProjectsStore, useValue: projectsStoreMock },
], ],
}).compileComponents(); }).compileComponents();
@@ -844,6 +908,6 @@ describe('MilestoneDetail — new route', () => {
milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]); milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]);
(component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] }); (component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] });
await (component as any).saveMilestone(true); await (component as any).saveMilestone(true);
expect(spy).toHaveBeenCalledWith(['/milestones', 1]); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 1]);
}); });
}); });
@@ -7,8 +7,10 @@ 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'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram'; import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
import { ProjectContextService } from '../../projects/project-context.service';
import { ProjectEntity, ProjectsStore } from '../../projects/projects.store';
@Component({ @Component({
selector: 'app-milestone-detail', selector: 'app-milestone-detail',
@@ -23,6 +25,12 @@ export class MilestoneDetail {
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); private readonly statusesStore = inject(StatusesStore);
private readonly projectContext = inject(ProjectContextService);
private readonly projectsStore = inject(ProjectsStore);
private get projectId(): number {
return this.projectContext.projectId()!;
}
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();
@@ -44,18 +52,22 @@ export class MilestoneDetail {
protected issueSearchQuery = ''; protected issueSearchQuery = '';
protected showIssueSuggestions = false; protected showIssueSuggestions = false;
protected moreMenuOpen = false; protected moreMenuOpen = false;
protected showMigratePanel = false;
protected selectedMigrateProjectId: number | null = null;
protected showAddDependency = false; protected showAddDependency = false;
protected selectedCandidateMilestoneId: number | null = null; protected selectedCandidateMilestoneId: number | null = null;
constructor() { constructor() {
this.milestonesStore.load().then(() => { const projectId = this.projectId;
this.projectsStore.load();
this.milestonesStore.load(projectId).then(() => {
if (!this.isNewRoute) { if (!this.isNewRoute) {
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
const found = this.milestonesStore.getById(id); const found = this.milestonesStore.getById(id);
if (found) this.milestone = { ...found }; if (found) this.milestone = { ...found };
} }
}); });
this.issuesStore.load(); this.issuesStore.load(projectId);
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const id = Number(params.get('id')); const id = Number(params.get('id'));
@@ -335,17 +347,17 @@ export class MilestoneDetail {
const saved = await this.milestonesStore.upsert(this.milestone); const saved = await this.milestonesStore.upsert(this.milestone);
this.milestone = { ...saved }; this.milestone = { ...saved };
if (this.isNewRoute) { if (this.isNewRoute) {
this.router.navigate(['/milestones', saved.id]); this.router.navigate(['/projects', this.projectId, 'milestones', saved.id]);
} }
} }
protected cancelCreation(): void { protected cancelCreation(): void {
this.router.navigate(['/milestones']); this.router.navigate(['/projects', this.projectId, 'milestones']);
} }
protected async deleteMilestone(): Promise<void> { protected async deleteMilestone(): Promise<void> {
await this.milestonesStore.deleteById(this.milestone.id); await this.milestonesStore.deleteById(this.milestone.id);
this.router.navigate(['/milestones']); this.router.navigate(['/projects', this.projectId, 'milestones']);
} }
protected toggleMoreMenu(): void { protected toggleMoreMenu(): void {
@@ -356,8 +368,30 @@ export class MilestoneDetail {
this.moreMenuOpen = false; this.moreMenuOpen = false;
} }
protected get availableProjects(): ProjectEntity[] {
return this.projectsStore.projects().filter((p) => p.id !== this.projectId);
}
protected openMigrate(): void {
this.moreMenuOpen = false;
this.selectedMigrateProjectId = null;
this.showMigratePanel = true;
}
protected cancelMigrate(): void {
this.showMigratePanel = false;
this.selectedMigrateProjectId = null;
}
protected async confirmMigrate(): Promise<void> {
if (this.selectedMigrateProjectId === null) return;
const targetId = this.selectedMigrateProjectId;
await this.milestonesStore.migrate(this.milestone.id, targetId);
this.router.navigate(['/projects', targetId, 'milestones']);
}
protected navigateToIssue(id: number): void { protected navigateToIssue(id: number): void {
this.router.navigate(['/issues', id]); this.router.navigate(['/projects', this.projectId, 'issues', id]);
} }
private buildMilestone(): MilestoneEntity { private buildMilestone(): MilestoneEntity {
@@ -33,12 +33,13 @@ describe('MilestonesApiService', () => {
afterEach(() => http.verify()); afterEach(() => http.verify());
describe('getAll', () => { describe('getAll', () => {
it('sends GET /api/milestones and returns milestones', () => { it('sends GET /api/milestones?projectId=1 and returns milestones', () => {
const milestones = [makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]; const milestones = [makeMilestone({ id: 1 }), makeMilestone({ id: 2 })];
let result: MilestoneEntity[] | undefined; let result: MilestoneEntity[] | undefined;
service.getAll().subscribe((data) => (result = data)); service.getAll(1).subscribe((data) => (result = data));
const req = http.expectOne(`${API}/milestones`); const req = http.expectOne(`${API}/milestones?projectId=1`);
expect(req.request.method).toBe('GET'); expect(req.request.method).toBe('GET');
expect(req.request.params.get('projectId')).toBe('1');
req.flush(milestones); req.flush(milestones);
expect(result).toEqual(milestones); expect(result).toEqual(milestones);
}); });
@@ -81,4 +82,16 @@ describe('MilestonesApiService', () => {
expect(completed).toBe(true); expect(completed).toBe(true);
}); });
}); });
describe('migrate', () => {
it('sends PUT /api/milestones/:id/migrate with targetProjectId', () => {
let completed = false;
service.migrate(5, 3).subscribe({ complete: () => (completed = true) });
const req = http.expectOne(`${API}/milestones/5/migrate`);
expect(req.request.method).toBe('PUT');
expect(req.request.body).toEqual({ targetProjectId: 3 });
req.flush(null);
expect(completed).toBe(true);
});
});
}); });
+8 -2
View File
@@ -8,8 +8,10 @@ import { MilestoneEntity } from './milestones.store';
export class MilestonesApiService { export class MilestonesApiService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
getAll(): Observable<MilestoneEntity[]> { getAll(projectId: number): Observable<MilestoneEntity[]> {
return this.http.get<MilestoneEntity[]>(`${API_BASE_URL}/milestones`); return this.http.get<MilestoneEntity[]>(`${API_BASE_URL}/milestones`, {
params: { projectId: projectId.toString() },
});
} }
create(milestone: Omit<MilestoneEntity, 'id'>): Observable<MilestoneEntity> { create(milestone: Omit<MilestoneEntity, 'id'>): Observable<MilestoneEntity> {
@@ -23,4 +25,8 @@ export class MilestonesApiService {
remove(id: number): Observable<void> { remove(id: number): Observable<void> {
return this.http.delete<void>(`${API_BASE_URL}/milestones/${id}`); return this.http.delete<void>(`${API_BASE_URL}/milestones/${id}`);
} }
migrate(id: number, targetProjectId: number): Observable<void> {
return this.http.put<void>(`${API_BASE_URL}/milestones/${id}/migrate`, { targetProjectId });
}
} }
+51 -7
View File
@@ -21,9 +21,12 @@ describe('MilestonesStore', () => {
let store: MilestonesStore; let store: MilestonesStore;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
const PROJECT_ID = 1;
const MILESTONES_URL = `${API}/milestones?projectId=${PROJECT_ID}`;
const loadWith = async (milestones: MilestoneEntity[]) => { const loadWith = async (milestones: MilestoneEntity[]) => {
const p = store.load(); const p = store.load(PROJECT_ID);
httpMock.expectOne(`${API}/milestones`).flush(milestones); httpMock.expectOne(MILESTONES_URL).flush(milestones);
await p; await p;
}; };
@@ -48,18 +51,28 @@ describe('MilestonesStore', () => {
}); });
it('sets loading to true during load and false after', async () => { it('sets loading to true during load and false after', async () => {
const p = store.load(); const p = store.load(PROJECT_ID);
expect(store.loading()).toBe(true); expect(store.loading()).toBe(true);
httpMock.expectOne(`${API}/milestones`).flush([]); httpMock.expectOne(MILESTONES_URL).flush([]);
await p; await p;
expect(store.loading()).toBe(false); expect(store.loading()).toBe(false);
expect(store.loaded()).toBe(true); expect(store.loaded()).toBe(true);
}); });
it('does not reload if already loaded', async () => { it('does not reload if already loaded for the same project', async () => {
await loadWith([]); await loadWith([]);
await store.load(); await store.load(PROJECT_ID);
httpMock.expectNone(`${API}/milestones`); httpMock.expectNone(MILESTONES_URL);
});
it('reloads when projectId changes', async () => {
await loadWith([makeMilestone({ id: 1 })]);
const url2 = `${API}/milestones?projectId=2`;
const p = store.load(2);
httpMock.expectOne(url2).flush([makeMilestone({ id: 2 })]);
await p;
expect(store.milestones().length).toBe(1);
expect(store.milestones()[0].id).toBe(2);
}); });
}); });
@@ -129,6 +142,37 @@ describe('MilestonesStore', () => {
}); });
}); });
describe('migrate', () => {
beforeEach(async () => {
await loadWith([makeMilestone({ id: 1 }), makeMilestone({ id: 2 })]);
});
it('removes the milestone from the current project store when API succeeds', async () => {
const p = store.migrate(1, 3);
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1/migrate` }).flush(null);
await p;
expect(store.getById(1)).toBeUndefined();
expect(store.milestones().length).toBe(1);
});
it('keeps other milestones untouched after migration', async () => {
const p = store.migrate(1, 3);
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1/migrate` }).flush(null);
await p;
expect(store.getById(2)?.id).toBe(2);
});
it('removes the milestone even when the API returns a 404', async () => {
const p = store.migrate(1, 3);
httpMock
.expectOne({ method: 'PUT', url: `${API}/milestones/1/migrate` })
.flush(null, { status: 404, statusText: 'Not Found' });
await p;
expect(store.getById(1)).toBeUndefined();
expect(store.milestones().length).toBe(1);
});
});
describe('normalize', () => { describe('normalize', () => {
it('normalizes issueIds to empty array when not an array', async () => { it('normalizes issueIds to empty array when not an array', async () => {
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: null } as any; const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: null } as any;
+14 -4
View File
@@ -1,5 +1,5 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { catchError, firstValueFrom, of } from 'rxjs';
import { MilestonesApiService } from './milestones-api.service'; import { MilestonesApiService } from './milestones-api.service';
export type MilestoneEntity = { export type MilestoneEntity = {
@@ -17,6 +17,7 @@ export type MilestoneEntity = {
export class MilestonesStore { export class MilestonesStore {
private readonly api = inject(MilestonesApiService); private readonly api = inject(MilestonesApiService);
private readonly data = signal<MilestoneEntity[]>([]); private readonly data = signal<MilestoneEntity[]>([]);
private currentProjectId: number | null = null;
readonly loading = signal(false); readonly loading = signal(false);
readonly loaded = signal(false); readonly loaded = signal(false);
@@ -26,11 +27,13 @@ export class MilestonesStore {
return this.data().find((m) => m.id === id); return this.data().find((m) => m.id === id);
} }
async load(): Promise<void> { async load(projectId: number): Promise<void> {
if (this.loaded()) return; if (this.loaded() && this.currentProjectId === projectId) return;
this.currentProjectId = projectId;
this.loaded.set(false);
this.loading.set(true); this.loading.set(true);
try { try {
const milestones = await firstValueFrom(this.api.getAll()); const milestones = await firstValueFrom(this.api.getAll(projectId));
this.data.set(milestones.map((m) => this.normalize(m))); this.data.set(milestones.map((m) => this.normalize(m)));
this.loaded.set(true); this.loaded.set(true);
} finally { } finally {
@@ -65,6 +68,13 @@ export class MilestonesStore {
this.data.update((list) => list.filter((m) => m.id !== id)); this.data.update((list) => list.filter((m) => m.id !== id));
} }
async migrate(id: number, targetProjectId: number): Promise<void> {
// L'endpoint /migrate n'existe pas encore côté API (voir api-issues/migration-milestone-projet.md).
// catchError absorbe le 404 pour que la suppression locale s'effectue quand même.
await firstValueFrom(this.api.migrate(id, targetProjectId).pipe(catchError(() => of(null))));
this.data.update((list) => list.filter((m) => m.id !== id));
}
private normalize(milestone: Partial<MilestoneEntity>): MilestoneEntity { private normalize(milestone: Partial<MilestoneEntity>): MilestoneEntity {
return { return {
id: milestone.id ?? 0, id: milestone.id ?? 0,
+7 -4
View File
@@ -5,6 +5,7 @@ import { IssuesStore } from '../issues/issues.store';
import { MilestoneEntity, MilestonesStore } from './milestones.store'; import { MilestoneEntity, MilestonesStore } from './milestones.store';
import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram'; import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram';
import { StatusesStore } from '../statuses/statuses.store'; import { StatusesStore } from '../statuses/statuses.store';
import { ProjectContextService } from '../projects/project-context.service';
@Component({ @Component({
selector: 'app-milestones', selector: 'app-milestones',
@@ -17,10 +18,12 @@ export class Milestones {
private readonly milestonesStore = inject(MilestonesStore); private readonly milestonesStore = inject(MilestonesStore);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
private readonly statusesStore = inject(StatusesStore); private readonly statusesStore = inject(StatusesStore);
private readonly projectContext = inject(ProjectContextService);
constructor() { constructor() {
this.milestonesStore.load(); const projectId = this.projectContext.projectId()!;
this.issuesStore.load(); this.milestonesStore.load(projectId);
this.issuesStore.load(projectId);
} }
protected readonly milestones = this.milestonesStore.milestones; protected readonly milestones = this.milestonesStore.milestones;
@@ -103,10 +106,10 @@ export class Milestones {
} }
protected createMilestone(): void { protected createMilestone(): void {
this.router.navigate(['/milestones/new']); this.router.navigate(['/projects', this.projectContext.projectId(), 'milestones', 'new']);
} }
protected openMilestone(id: number): void { protected openMilestone(id: number): void {
this.router.navigate(['/milestones', id]); this.router.navigate(['/projects', this.projectContext.projectId(), 'milestones', id]);
} }
} }
@@ -0,0 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { ProjectContextService } from './project-context.service';
import { ProjectEntity } from './projects.store';
const makeProject = (overrides: Partial<ProjectEntity> = {}): ProjectEntity => ({
id: 1,
name: 'Mon Projet',
owner: 'Alice',
status: 'Actif',
progress: 50,
...overrides,
});
describe('ProjectContextService', () => {
let service: ProjectContextService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ProjectContextService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('starts with null projectId and project', () => {
expect(service.projectId()).toBeNull();
expect(service.project()).toBeNull();
});
describe('setId', () => {
it('sets projectId and clears project', () => {
service.set(makeProject({ id: 3 }));
service.setId(7);
expect(service.projectId()).toBe(7);
expect(service.project()).toBeNull();
});
});
describe('set', () => {
it('sets both project and projectId', () => {
const project = makeProject({ id: 5, name: 'Alpha' });
service.set(project);
expect(service.project()).toEqual(project);
expect(service.projectId()).toBe(5);
});
});
describe('clear', () => {
it('resets both projectId and project to null', () => {
service.set(makeProject({ id: 2 }));
service.clear();
expect(service.projectId()).toBeNull();
expect(service.project()).toBeNull();
});
});
});
@@ -0,0 +1,26 @@
import { Injectable, signal } from '@angular/core';
import { ProjectEntity } from './projects.store';
@Injectable({ providedIn: 'root' })
export class ProjectContextService {
private readonly _projectId = signal<number | null>(null);
private readonly _project = signal<ProjectEntity | null>(null);
readonly projectId = this._projectId.asReadonly();
readonly project = this._project.asReadonly();
setId(id: number): void {
this._projectId.set(id);
this._project.set(null);
}
set(project: ProjectEntity): void {
this._project.set(project);
this._projectId.set(project.id);
}
clear(): void {
this._projectId.set(null);
this._project.set(null);
}
}
@@ -0,0 +1,101 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { vi } from 'vitest';
import { ProjectContextService } from '../project-context.service';
import { ProjectEntity, ProjectsStore } from '../projects.store';
import { StatusesStore } from '../../statuses/statuses.store';
import { ProjectWorkspace } from './project-workspace';
const makeProject = (overrides: Partial<ProjectEntity> = {}): ProjectEntity => ({
id: 1,
name: 'Alpha',
owner: 'Alice',
status: 'Actif',
progress: 60,
...overrides,
});
describe('ProjectWorkspace', () => {
let component: ProjectWorkspace;
let fixture: ComponentFixture<ProjectWorkspace>;
const contextMock = {
setId: vi.fn(),
set: vi.fn(),
clear: vi.fn(),
projectId: signal<number | null>(null),
project: signal<ProjectEntity | null>(null),
};
const storeMock = {
load: vi.fn().mockResolvedValue(undefined),
getById: vi.fn(),
projects: signal<ProjectEntity[]>([]),
loading: signal(false),
loaded: signal(false),
};
const statusesStoreMock = { load: vi.fn() };
const activatedRouteMock = {
snapshot: { params: { projectId: '3' } },
};
beforeEach(async () => {
contextMock.setId = vi.fn();
contextMock.set = vi.fn();
contextMock.clear = vi.fn();
storeMock.load = vi.fn().mockResolvedValue(undefined);
storeMock.getById = vi.fn().mockReturnValue(makeProject({ id: 3 }));
statusesStoreMock.load = vi.fn();
await TestBed.configureTestingModule({
imports: [ProjectWorkspace],
providers: [
{ provide: ProjectContextService, useValue: contextMock },
{ provide: ProjectsStore, useValue: storeMock },
{ provide: StatusesStore, useValue: statusesStoreMock },
{ provide: ActivatedRoute, useValue: activatedRouteMock },
],
}).compileComponents();
fixture = TestBed.createComponent(ProjectWorkspace);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('calls statusesStore.load with the route param on init', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(statusesStoreMock.load).toHaveBeenCalledWith(3);
});
it('calls projectContext.setId with the route param on init', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(contextMock.setId).toHaveBeenCalledWith(3);
});
it('loads the projects store on init', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(storeMock.load).toHaveBeenCalled();
});
it('sets the project in context after load', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(contextMock.set).toHaveBeenCalledWith(makeProject({ id: 3 }));
});
it('clears project context on destroy', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.destroy();
expect(contextMock.clear).toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { ProjectContextService } from '../project-context.service';
import { ProjectsStore } from '../projects.store';
import { StatusesStore } from '../../statuses/statuses.store';
@Component({
selector: 'app-project-workspace',
imports: [RouterOutlet],
template: '<router-outlet />',
styles: [':host { display: contents; }'],
})
export class ProjectWorkspace implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly projectsStore = inject(ProjectsStore);
private readonly projectContext = inject(ProjectContextService);
private readonly statusesStore = inject(StatusesStore);
ngOnInit(): void {
const projectId = Number(this.route.snapshot.params['projectId']);
this.statusesStore.load(projectId);
this.projectContext.setId(projectId);
this.projectsStore.load().then(() => {
const project = this.projectsStore.getById(projectId);
if (project) this.projectContext.set(project);
});
}
ngOnDestroy(): void {
this.projectContext.clear();
}
}
@@ -0,0 +1,81 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ProjectsApiService } from './projects-api.service';
import { ProjectEntity } from './projects.store';
const API = '/api';
const makeProject = (overrides: Partial<ProjectEntity> = {}): ProjectEntity => ({
id: 1,
name: 'Mon Projet',
owner: 'Alice',
status: 'Actif',
progress: 50,
...overrides,
});
describe('ProjectsApiService', () => {
let service: ProjectsApiService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(ProjectsApiService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
describe('getAll', () => {
it('sends GET /api/projects and returns projects', () => {
const projects = [makeProject({ id: 1 }), makeProject({ id: 2 })];
let result: ProjectEntity[] | undefined;
service.getAll().subscribe((data) => (result = data));
const req = http.expectOne(`${API}/projects`);
expect(req.request.method).toBe('GET');
req.flush(projects);
expect(result).toEqual(projects);
});
});
describe('create', () => {
it('sends POST /api/projects with the body and returns the created project', () => {
const body = { name: 'Nouveau', owner: 'Bob', status: 'Nouveau' as const, progress: 0 };
const response = makeProject({ id: 10, name: 'Nouveau' });
let result: ProjectEntity | undefined;
service.create(body).subscribe((data) => (result = data));
const req = http.expectOne(`${API}/projects`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(body);
req.flush(response);
expect(result).toEqual(response);
});
});
describe('update', () => {
it('sends PUT /api/projects/:id and returns the updated project', () => {
const project = makeProject({ id: 1, name: 'Updated' });
let result: ProjectEntity | undefined;
service.update(1, project).subscribe((data) => (result = data));
const req = http.expectOne(`${API}/projects/1`);
expect(req.request.method).toBe('PUT');
expect(req.request.body).toEqual(project);
req.flush(project);
expect(result).toEqual(project);
});
});
describe('remove', () => {
it('sends DELETE /api/projects/:id and completes', () => {
let completed = false;
service.remove(1).subscribe({ complete: () => (completed = true) });
const req = http.expectOne(`${API}/projects/1`);
expect(req.request.method).toBe('DELETE');
req.flush(null);
expect(completed).toBe(true);
});
});
});
+26
View File
@@ -0,0 +1,26 @@
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 { ProjectEntity } from './projects.store';
@Injectable({ providedIn: 'root' })
export class ProjectsApiService {
private readonly http = inject(HttpClient);
getAll(): Observable<ProjectEntity[]> {
return this.http.get<ProjectEntity[]>(`${API_BASE_URL}/projects`);
}
create(project: Omit<ProjectEntity, 'id'>): Observable<ProjectEntity> {
return this.http.post<ProjectEntity>(`${API_BASE_URL}/projects`, project);
}
update(id: number, project: ProjectEntity): Observable<ProjectEntity> {
return this.http.put<ProjectEntity>(`${API_BASE_URL}/projects/${id}`, project);
}
remove(id: number): Observable<void> {
return this.http.delete<void>(`${API_BASE_URL}/projects/${id}`);
}
}
+253
View File
@@ -2,3 +2,256 @@
display: block; display: block;
} }
.projects-page {
padding: 2rem;
max-width: 1100px;
margin: 0 auto;
}
.projects-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
}
.projects-title {
font-size: 1.5rem;
font-weight: 700;
color: #111827;
margin: 0 0 0.25rem;
}
.projects-subtitle {
font-size: 0.9rem;
color: #6b7280;
margin: 0;
}
.projects-loading,
.projects-empty {
color: #6b7280;
font-size: 0.95rem;
padding: 2rem 0;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.project-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.25rem;
cursor: pointer;
text-align: left;
width: 100%;
transition: box-shadow 0.15s, border-color 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.project-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border-color: #2563eb;
transform: translateY(-2px);
}
.project-card-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.project-avatar {
width: 40px;
height: 40px;
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
font-size: 1.1rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.project-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-actif {
background: #dcfce7;
color: #166534;
}
.badge-attente {
background: #fef9c3;
color: #854d0e;
}
.badge-nouveau {
background: #dbeafe;
color: #1d4ed8;
}
.project-name {
font-size: 1rem;
font-weight: 600;
color: #111827;
line-height: 1.3;
}
.project-owner {
font-size: 0.82rem;
color: #6b7280;
}
.project-progress-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 0.25rem;
}
.project-progress-bar {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 9999px;
overflow: hidden;
}
.project-progress-fill {
height: 100%;
background: #2563eb;
border-radius: 9999px;
transition: width 0.3s;
}
.project-progress-label {
font-size: 0.78rem;
font-weight: 600;
color: #6b7280;
min-width: 2.5rem;
text-align: right;
}
.btn-new-project {
padding: 0.5rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: #2563eb;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btn-new-project:hover {
background: #1d4ed8;
}
.project-form {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
max-width: 540px;
}
.project-form-title {
font-size: 1rem;
font-weight: 700;
color: #111827;
margin-bottom: 1rem;
}
.project-form-fields {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.project-form-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.project-form-label {
font-size: 0.82rem;
font-weight: 600;
color: #374151;
}
.project-form-input {
padding: 0.45rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.4rem;
font-size: 0.9rem;
color: #111827;
outline: none;
transition: border-color 0.15s;
}
.project-form-input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.project-form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
.btn-submit {
padding: 0.45rem 1.1rem;
font-size: 0.88rem;
font-weight: 600;
color: #fff;
background: #2563eb;
border: none;
border-radius: 0.4rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-submit:not(:disabled):hover {
background: #1d4ed8;
}
.btn-cancel {
padding: 0.45rem 1rem;
font-size: 0.88rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 0.4rem;
cursor: pointer;
transition: background 0.1s;
}
.btn-cancel:hover {
background: #f3f4f6;
}
+67 -31
View File
@@ -1,34 +1,70 @@
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3 mb-4"> <div class="projects-page">
<div> <div class="projects-header">
<h1 class="h2 mb-2">Projets</h1> <div>
<p class="text-secondary mb-0">Vue d'ensemble des projets actifs.</p> <h1 class="projects-title">Projets</h1>
<p class="projects-subtitle">Sélectionnez un projet pour accéder à ses Issues, Milestones et Tableau de bord.</p>
</div>
@if (!showForm()) {
<button class="btn-new-project" (click)="openForm()">+ Nouveau projet</button>
}
</div> </div>
<button type="button" class="btn btn-primary" (click)="createProject()">
Nouveau projet
</button>
</div>
<div class="card shadow-sm"> @if (showForm()) {
<div class="table-responsive"> <form class="project-form" (ngSubmit)="createProject()">
<table class="table table-hover align-middle mb-0"> <div class="project-form-title">Nouveau projet</div>
<thead class="table-light"> <div class="project-form-fields">
<tr> <div class="project-form-group">
<th>Nom</th> <label class="project-form-label" for="proj-name">Nom du projet *</label>
<th>Responsable</th> <input
<th>Statut</th> id="proj-name"
<th>Progression</th> class="project-form-input"
</tr> [(ngModel)]="newName"
</thead> name="name"
<tbody> placeholder="Ex : Refonte Interface"
@for (project of projects(); track project.id) { required
<tr> autofocus
<td>{{ project.name }}</td> />
<td>{{ project.owner }}</td> </div>
<td>{{ project.status }}</td> <div class="project-form-group">
<td>{{ project.progress }}%</td> <label class="project-form-label" for="proj-owner">Responsable</label>
</tr> <input
} id="proj-owner"
</tbody> class="project-form-input"
</table> [(ngModel)]="newOwner"
</div> name="owner"
placeholder="Ex : Alice"
/>
</div>
</div>
<div class="project-form-actions">
<button type="submit" class="btn-submit" [disabled]="!newName.trim()">Créer et ouvrir</button>
<button type="button" class="btn-cancel" (click)="cancelForm()">Annuler</button>
</div>
</form>
}
@if (loading()) {
<div class="projects-loading">Chargement des projets…</div>
} @else if (projects().length === 0) {
<div class="projects-empty">Aucun projet disponible. Créez votre premier projet.</div>
} @else {
<div class="projects-grid">
@for (project of projects(); track project.id) {
<button class="project-card" (click)="openProject(project)">
<div class="project-card-top">
<div class="project-avatar">{{ project.name.charAt(0).toUpperCase() }}</div>
<span class="project-badge" [class]="statusClass(project.status)">{{ project.status }}</span>
</div>
<div class="project-name">{{ project.name }}</div>
<div class="project-owner">{{ project.owner }}</div>
<div class="project-progress-row">
<div class="project-progress-bar">
<div class="project-progress-fill" [style.width.%]="project.progress"></div>
</div>
<span class="project-progress-label">{{ project.progress }}%</span>
</div>
</button>
}
</div>
}
</div> </div>
+98 -19
View File
@@ -1,15 +1,52 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { vi } from 'vitest';
import { ProjectEntity, ProjectsStore } from './projects.store';
import { Projects } from './projects'; import { Projects } from './projects';
const makeProject = (overrides: Partial<ProjectEntity> = {}): ProjectEntity => ({
id: 1,
name: 'Mon Projet',
owner: 'Alice',
status: 'Actif',
progress: 50,
...overrides,
});
describe('Projects', () => { describe('Projects', () => {
let component: Projects; let component: Projects;
let fixture: ComponentFixture<Projects>; let fixture: ComponentFixture<Projects>;
let router: Router;
const projectsData = signal<ProjectEntity[]>([
makeProject({ id: 1, name: 'Projet A' }),
makeProject({ id: 2, name: 'Projet B', status: 'En attente' }),
makeProject({ id: 3, name: 'Projet C', status: 'Nouveau' }),
]);
const storeMock = {
projects: projectsData.asReadonly(),
loading: signal(false),
loaded: signal(true),
load: vi.fn().mockResolvedValue(undefined),
getById: vi.fn(),
createLocal: vi.fn((name: string, owner: string): ProjectEntity => makeProject({ id: 10, name, owner })),
};
beforeEach(async () => { beforeEach(async () => {
storeMock.load = vi.fn().mockResolvedValue(undefined);
storeMock.createLocal = vi.fn((name: string, owner: string): ProjectEntity => makeProject({ id: 10, name, owner }));
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Projects], imports: [Projects],
providers: [
provideRouter([]),
{ provide: ProjectsStore, useValue: storeMock },
],
}).compileComponents(); }).compileComponents();
router = TestBed.inject(Router);
fixture = TestBed.createComponent(Projects); fixture = TestBed.createComponent(Projects);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
@@ -19,32 +56,74 @@ describe('Projects', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it('should have 3 default projects', () => { it('calls store.load() on construction', () => {
expect(storeMock.load).toHaveBeenCalled();
});
it('exposes projects from the store', () => {
expect((component as any).projects().length).toBe(3); expect((component as any).projects().length).toBe(3);
}); });
it('createProject adds a new project', () => { describe('openProject', () => {
(component as any).createProject(); it('navigates to /projects/:id/dashboard', () => {
expect((component as any).projects().length).toBe(4); const spy = vi.spyOn(router, 'navigate');
(component as any).openProject(makeProject({ id: 7 }));
expect(spy).toHaveBeenCalledWith(['/projects', 7, 'dashboard']);
});
}); });
it('createProject increments the id each time', () => { describe('statusClass', () => {
(component as any).createProject(); it('returns badge-actif for Actif', () => {
(component as any).createProject(); expect((component as any).statusClass('Actif')).toBe('badge-actif');
const projects = (component as any).projects(); });
expect(projects[3].id).toBe(4);
expect(projects[4].id).toBe(5); it('returns badge-attente for En attente', () => {
expect((component as any).statusClass('En attente')).toBe('badge-attente');
});
it('returns badge-nouveau for Nouveau', () => {
expect((component as any).statusClass('Nouveau')).toBe('badge-nouveau');
});
}); });
it('new project starts with Nouveau status', () => { describe('project creation form', () => {
(component as any).createProject(); it('showForm is false initially', () => {
const newProject = (component as any).projects()[3]; expect((component as any).showForm()).toBe(false);
expect(newProject.status).toBe('Nouveau'); });
});
it('new project starts with 0 progress', () => { it('openForm sets showForm to true', () => {
(component as any).createProject(); (component as any).openForm();
const newProject = (component as any).projects()[3]; expect((component as any).showForm()).toBe(true);
expect(newProject.progress).toBe(0); });
it('cancelForm sets showForm to false', () => {
(component as any).openForm();
(component as any).cancelForm();
expect((component as any).showForm()).toBe(false);
});
it('createProject calls store.createLocal with name and owner', () => {
(component as any).newName = 'Mon Projet';
(component as any).newOwner = 'Bob';
const spy = vi.spyOn(router, 'navigate');
(component as any).createProject();
expect(storeMock.createLocal).toHaveBeenCalledWith('Mon Projet', 'Bob');
});
it('createProject navigates to the new project dashboard', () => {
(component as any).newName = 'Test';
(component as any).newOwner = '';
const spy = vi.spyOn(router, 'navigate');
(component as any).createProject();
expect(spy).toHaveBeenCalledWith(['/projects', 10, 'dashboard']);
});
it('createProject does nothing when name is empty', () => {
(component as any).newName = ' ';
const spy = vi.spyOn(router, 'navigate');
(component as any).createProject();
expect(storeMock.createLocal).not.toHaveBeenCalled();
expect(spy).not.toHaveBeenCalled();
});
}); });
}); });
+146
View File
@@ -0,0 +1,146 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ProjectEntity, ProjectsStore } from './projects.store';
const API = '/api';
const makeProject = (overrides: Partial<ProjectEntity> = {}): ProjectEntity => ({
id: 1,
name: 'Mon Projet',
owner: 'Alice',
status: 'Actif',
progress: 50,
...overrides,
});
describe('ProjectsStore', () => {
let store: ProjectsStore;
let httpMock: HttpTestingController;
const loadWith = async (projects: ProjectEntity[]) => {
const p = store.load();
httpMock.expectOne(`${API}/projects`).flush(projects);
await p;
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(ProjectsStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('should be created', () => {
expect(store).toBeTruthy();
});
describe('load', () => {
it('populates projects from the API', async () => {
await loadWith([makeProject({ id: 1 }), makeProject({ id: 2 })]);
expect(store.projects().length).toBe(2);
});
it('sets loading to true during load and false after', async () => {
const p = store.load();
expect(store.loading()).toBe(true);
httpMock.expectOne(`${API}/projects`).flush([]);
await p;
expect(store.loading()).toBe(false);
expect(store.loaded()).toBe(true);
});
it('does not reload if already loaded', async () => {
await loadWith([]);
await store.load();
httpMock.expectNone(`${API}/projects`);
});
it('falls back to stub projects when the API returns an error', async () => {
const p = store.load();
httpMock.expectOne(`${API}/projects`).error(new ProgressEvent('error'));
await p;
expect(store.projects().length).toBeGreaterThan(0);
expect(store.loaded()).toBe(true);
});
});
describe('getById', () => {
beforeEach(async () => {
await loadWith([makeProject({ id: 1 }), makeProject({ id: 2 })]);
});
it('returns the project with the given id', () => {
expect(store.getById(1)?.id).toBe(1);
});
it('returns undefined for an unknown id', () => {
expect(store.getById(9999)).toBeUndefined();
});
});
describe('createLocal', () => {
beforeEach(async () => {
await loadWith([makeProject({ id: 1 }), makeProject({ id: 3 })]);
});
it('adds a new project with id = max existing id + 1', () => {
const created = store.createLocal('Nouveau', 'Alice');
expect(created.id).toBe(4);
expect(created.name).toBe('Nouveau');
expect(created.owner).toBe('Alice');
expect(created.status).toBe('Nouveau');
});
it('appends the new project to the list', () => {
const before = store.projects().length;
store.createLocal('Test', 'Bob');
expect(store.projects().length).toBe(before + 1);
});
it('trims name and owner', () => {
const created = store.createLocal(' Nom ', ' Alice ');
expect(created.name).toBe('Nom');
expect(created.owner).toBe('Alice');
});
});
describe('upsert', () => {
beforeEach(async () => {
await loadWith([makeProject({ id: 1, name: 'Existing' }), makeProject({ id: 2 })]);
});
it('creates a new project via POST when id is 0', async () => {
const before = store.projects().length;
const p = store.upsert(makeProject({ id: 0, name: 'New Project' }));
httpMock.expectOne({ method: 'POST', url: `${API}/projects` }).flush(makeProject({ id: 99, name: 'New Project' }));
await p;
expect(store.projects().length).toBe(before + 1);
expect(store.getById(99)?.name).toBe('New Project');
});
it('updates an existing project via PUT', async () => {
const p = store.upsert(makeProject({ id: 1, name: 'Updated' }));
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/1` }).flush(makeProject({ id: 1, name: 'Updated' }));
await p;
expect(store.getById(1)?.name).toBe('Updated');
});
});
describe('deleteById', () => {
beforeEach(async () => {
await loadWith([makeProject({ id: 1 }), makeProject({ id: 2 })]);
});
it('removes the project from the store', async () => {
const p = store.deleteById(1);
httpMock.expectOne({ method: 'DELETE', url: `${API}/projects/1` }).flush(null);
await p;
expect(store.getById(1)).toBeUndefined();
expect(store.projects().length).toBe(1);
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import { Injectable, inject, signal } from '@angular/core';
import { catchError, firstValueFrom, of } from 'rxjs';
import { ProjectsApiService } from './projects-api.service';
export type ProjectEntity = {
id: number;
name: string;
owner: string;
status: 'Actif' | 'En attente' | 'Nouveau';
progress: number;
};
const STUB_PROJECTS: ProjectEntity[] = [
{ id: 1, name: 'Refonte Interface', owner: 'Marie', status: 'Actif', progress: 70 },
{ id: 2, name: 'API Inventaire', owner: 'Nabil', status: 'En attente', progress: 45 },
{ id: 3, name: 'Pipeline CI', owner: 'Sonia', status: 'Actif', progress: 90 },
];
@Injectable({ providedIn: 'root' })
export class ProjectsStore {
private readonly api = inject(ProjectsApiService);
private readonly data = signal<ProjectEntity[]>([]);
readonly loading = signal(false);
readonly loaded = signal(false);
readonly projects = this.data.asReadonly();
getById(id: number): ProjectEntity | undefined {
return this.data().find((p) => p.id === id);
}
async load(): Promise<void> {
if (this.loaded()) return;
this.loading.set(true);
try {
const projects = await firstValueFrom(
this.api.getAll().pipe(catchError(() => of(STUB_PROJECTS))),
);
this.data.set(projects);
this.loaded.set(true);
} finally {
this.loading.set(false);
}
}
createLocal(name: string, owner: string): ProjectEntity {
const maxId = this.data().reduce((max, p) => Math.max(max, p.id), 0);
const project: ProjectEntity = { id: maxId + 1, name: name.trim(), owner: owner.trim(), status: 'Nouveau', progress: 0 };
this.data.update((list) => [...list, project]);
return project;
}
async upsert(project: ProjectEntity): Promise<ProjectEntity> {
if (!project.id) {
const { id: _id, ...body } = project;
const created = await firstValueFrom(this.api.create(body));
this.data.update((list) => [...list, created]);
return created;
} else {
const updated = await firstValueFrom(this.api.update(project.id, project));
this.data.update((list) => {
const idx = list.findIndex((p) => p.id === project.id);
if (idx === -1) return list;
const copy = [...list];
copy[idx] = updated;
return copy;
});
return updated;
}
}
async deleteById(id: number): Promise<void> {
await firstValueFrom(this.api.remove(id));
this.data.update((list) => list.filter((p) => p.id !== id));
}
}
+44 -25
View File
@@ -1,38 +1,57 @@
import { Component, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
type Project = { import { Router } from '@angular/router';
id: number; import { ProjectEntity, ProjectsStore } from './projects.store';
name: string;
owner: string;
status: 'Actif' | 'En attente' | 'Nouveau';
progress: number;
};
@Component({ @Component({
selector: 'app-projects', selector: 'app-projects',
imports: [], imports: [FormsModule],
templateUrl: './projects.html', templateUrl: './projects.html',
styleUrl: './projects.css', styleUrl: './projects.css',
}) })
export class Projects { export class Projects {
protected readonly projects = signal<Project[]>([ private readonly router = inject(Router);
{ id: 1, name: 'Refonte Interface', owner: 'Marie', status: 'Actif', progress: 70 }, protected readonly projectsStore = inject(ProjectsStore);
{ id: 2, name: 'API Inventaire', owner: 'Nabil', status: 'En attente', progress: 45 },
{ id: 3, name: 'Pipeline CI', owner: 'Sonia', status: 'Actif', progress: 90 },
]);
private nextId = 4; constructor() {
this.projectsStore.load();
}
protected readonly projects = this.projectsStore.projects;
protected readonly loading = this.projectsStore.loading;
protected showForm = signal(false);
protected newName = '';
protected newOwner = '';
protected openProject(project: ProjectEntity): void {
this.router.navigate(['/projects', project.id, 'dashboard']);
}
protected openForm(): void {
this.newName = '';
this.newOwner = '';
this.showForm.set(true);
}
protected cancelForm(): void {
this.showForm.set(false);
}
protected createProject(): void { protected createProject(): void {
const newProject: Project = { const name = this.newName.trim();
id: this.nextId, if (!name) return;
name: `Nouveau projet ${this.nextId}`, const project = this.projectsStore.createLocal(name, this.newOwner);
owner: 'A definir', this.showForm.set(false);
status: 'Nouveau', this.router.navigate(['/projects', project.id, 'dashboard']);
progress: 0, }
};
this.projects.update((currentProjects) => [...currentProjects, newProject]); protected statusClass(status: ProjectEntity['status']): string {
this.nextId += 1; const map: Record<ProjectEntity['status'], string> = {
Actif: 'badge-actif',
'En attente': 'badge-attente',
Nouveau: 'badge-nouveau',
};
return map[status] ?? '';
} }
} }
+5 -71
View File
@@ -1,71 +1,5 @@
import { Injectable, signal } from '@angular/core'; // Ré-exporte le store unique de référence pour que tous les composants
// qui importent depuis ce chemin obtiennent le même singleton Angular.
export type StatusEntity = { export { StatusesStore } from '../../statuses/statuses.store';
id: string; export type { StatusEntity } from '../../statuses/statuses.store';
label: string; export { DEFAULT_STATUSES } from '../../statuses/statuses.store';
bg: string;
color: string;
order: number;
countsAsCompleted: boolean;
};
export const DEFAULT_STATUSES: StatusEntity[] = [
{ 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';
@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);
}
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' | 'countsAsCompleted'>>): void {
this.data.update((statuses) =>
statuses.map((s) => (s.id === id ? { ...s, ...changes } : s)),
);
this.saveToStorage();
}
}
+43
View File
@@ -129,4 +129,47 @@ describe('StatusesStore', () => {
expect(store.getById('todo')?.label).toBe('À FAIRE'); expect(store.getById('todo')?.label).toBe('À FAIRE');
}); });
}); });
describe('load (project-scoped)', () => {
afterEach(() => {
localStorage.removeItem('bonsai_statuses_1');
localStorage.removeItem('bonsai_statuses_2');
});
it('loads default statuses when project has no saved statuses', () => {
store.load(1);
expect(store.statuses()).toEqual(DEFAULT_STATUSES);
});
it('loads statuses from the project-specific key', () => {
const custom = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0, countsAsCompleted: false }];
localStorage.setItem('bonsai_statuses_1', JSON.stringify(custom));
store.load(1);
expect(store.statuses().length).toBe(1);
expect(store.statuses()[0].id).toBe('custom');
});
it('does not reload if the same projectId is requested again', () => {
store.load(1);
store.create({ id: 'added', label: 'ADD', bg: '#fff', color: '#000', countsAsCompleted: false });
store.load(1);
expect(store.getById('added')).toBeDefined();
});
it('reloads when switching to a different projectId', () => {
store.load(1);
store.create({ id: 'proj1-status', label: 'P1', bg: '#fff', color: '#000', countsAsCompleted: false });
store.load(2);
expect(store.getById('proj1-status')).toBeUndefined();
expect(store.statuses()).toEqual(DEFAULT_STATUSES);
});
it('saves create() to the project-scoped key', () => {
store.load(1);
store.create({ id: 'scoped', label: 'SCOPED', bg: '#fff', color: '#000', countsAsCompleted: false });
const stored = JSON.parse(localStorage.getItem('bonsai_statuses_1')!);
expect(stored.some((s: { id: string }) => s.id === 'scoped')).toBe(true);
expect(localStorage.getItem('bonsai_statuses')).toBeNull();
});
});
}); });
+17 -6
View File
@@ -16,14 +16,21 @@ export const DEFAULT_STATUSES: StatusEntity[] = [
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true }, { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
]; ];
const STORAGE_KEY = 'bonsai_statuses'; const BASE_STORAGE_KEY = 'bonsai_statuses';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class StatusesStore { export class StatusesStore {
private readonly data = signal<StatusEntity[]>(this.loadFromStorage()); private currentProjectId: number | null = null;
private readonly data = signal<StatusEntity[]>(this.readFromStorage(null));
readonly statuses = this.data.asReadonly(); readonly statuses = this.data.asReadonly();
load(projectId: number): void {
if (this.currentProjectId === projectId) return;
this.currentProjectId = projectId;
this.data.set(this.readFromStorage(projectId));
}
getById(id: string): StatusEntity | undefined { getById(id: string): StatusEntity | undefined {
return this.data().find((s) => s.id === id); return this.data().find((s) => s.id === id);
} }
@@ -50,13 +57,17 @@ export class StatusesStore {
this.saveToStorage(); this.saveToStorage();
} }
private saveToStorage(): void { private storageKey(projectId: number | null): string {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data())); return projectId != null ? `${BASE_STORAGE_KEY}_${projectId}` : BASE_STORAGE_KEY;
} }
private loadFromStorage(): StatusEntity[] { private saveToStorage(): void {
localStorage.setItem(this.storageKey(this.currentProjectId), JSON.stringify(this.data()));
}
private readFromStorage(projectId: number | null): StatusEntity[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(this.storageKey(projectId));
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as StatusEntity[]; const parsed = JSON.parse(raw) as StatusEntity[];
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {