diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6791eb3..a56d4ca 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,21 +6,34 @@ import { Issues } from './issues/issues'; import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail'; import { Milestones } from './milestones/milestones'; import { Projects } from './projects/projects'; +import { ProjectWorkspace } from './projects/project-workspace/project-workspace'; import { Statuses } from './statuses/statuses'; import { authGuard } from './auth/auth.guard'; export const routes: Routes = [ - { path: '', pathMatch: 'full', redirectTo: 'home' }, + { path: '', pathMatch: 'full', redirectTo: 'projects' }, { path: 'home', component: Home }, - { path: 'dashboard', component: Dashboard, canActivate: [authGuard] }, - { path: 'project', component: Projects, canActivate: [authGuard] }, - { path: 'projects', redirectTo: 'project' }, - { path: 'issues/new', component: IssueDetail, canActivate: [authGuard] }, - { path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] }, - { path: 'issues', component: Issues, canActivate: [authGuard] }, - { path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] }, - { path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] }, - { path: 'milestones', component: Milestones, canActivate: [authGuard] }, - { path: 'statuses', component: Statuses, canActivate: [authGuard] }, - { path: '**', redirectTo: 'home' }, + { + path: 'projects', + canActivate: [authGuard], + children: [ + { path: '', component: Projects, pathMatch: 'full' }, + { + path: ':projectId', + component: ProjectWorkspace, + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { 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' }, ]; diff --git a/src/app/dashboard/dashboard.spec.ts b/src/app/dashboard/dashboard.spec.ts index 5011ec8..81cc7c6 100644 --- a/src/app/dashboard/dashboard.spec.ts +++ b/src/app/dashboard/dashboard.spec.ts @@ -1,6 +1,7 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; +import { ProjectContextService } from '../projects/project-context.service'; import { vi } from 'vitest'; import { Dashboard } from './dashboard'; import { IssueEntity, IssuesStore } from '../issues/issues.store'; @@ -89,6 +90,7 @@ describe('Dashboard', () => { { provide: IssuesStore, useValue: issuesStore }, { provide: MilestonesStore, useValue: milestonesStore }, { provide: StatusesStore, useValue: statusesStore }, + { provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } }, ], }).compileComponents(); @@ -296,28 +298,28 @@ describe('Dashboard', () => { }); 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); (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); (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); (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); (component as any).navigateToMilestones(); - expect(spy).toHaveBeenCalledWith(['/milestones']); + expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']); }); }); }); diff --git a/src/app/dashboard/dashboard.ts b/src/app/dashboard/dashboard.ts index 6166de5..8d5febf 100644 --- a/src/app/dashboard/dashboard.ts +++ b/src/app/dashboard/dashboard.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { IssueEntity, IssuesStore } from '../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { StatusesStore } from '../statuses/statuses.store'; +import { ProjectContextService } from '../projects/project-context.service'; @Component({ selector: 'app-dashboard', @@ -15,10 +16,12 @@ export class Dashboard { private readonly issuesStore = inject(IssuesStore); private readonly milestonesStore = inject(MilestonesStore); private readonly statusesStore = inject(StatusesStore); + private readonly projectContext = inject(ProjectContextService); constructor() { - this.issuesStore.load(); - this.milestonesStore.load(); + const projectId = this.projectContext.projectId()!; + this.issuesStore.load(projectId); + this.milestonesStore.load(projectId); } protected readonly totalIssues = computed(() => this.issuesStore.issues().length); @@ -152,18 +155,22 @@ export class Dashboard { } 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 { - this.router.navigate(['/milestones', id]); + const pid = this.projectContext.projectId(); + this.router.navigate(['/projects', pid, 'milestones', id]); } protected navigateToIssues(): void { - this.router.navigate(['/issues']); + const pid = this.projectContext.projectId(); + this.router.navigate(['/projects', pid, 'issues']); } protected navigateToMilestones(): void { - this.router.navigate(['/milestones']); + const pid = this.projectContext.projectId(); + this.router.navigate(['/projects', pid, 'milestones']); } } diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index d682938..8287ed5 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -5,7 +5,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { marked } from 'marked'; import { handleImagePaste, insertAtSelection } from '../paste-image.util'; 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'; @Component({ diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 1310a85..3fb1467 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -7,6 +7,7 @@ import { vi } from 'vitest'; import { IssueDetail } from './issue-detail'; import { IssueEntity, IssuesStore } from '../issues.store'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; +import { ProjectContextService } from '../../projects/project-context.service'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -161,6 +162,7 @@ describe('IssueDetail — existing issue', () => { { provide: ActivatedRoute, useValue: makeRoute('1') }, { provide: IssuesStore, useValue: store }, { provide: MilestonesStore, useValue: milestonesStore }, + { provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } }, ], }).compileComponents(); @@ -469,19 +471,19 @@ describe('IssueDetail — existing issue', () => { }); 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); await (component as any).deleteIssue(); expect(store.getById(1)).toBeUndefined(); - expect(spy).toHaveBeenCalledWith(['/issues']); + expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']); }); }); describe('cancelCreation', () => { - it('navigates to /issues', async () => { + it('navigates to /projects/:pid/issues', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (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'; const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (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', () => { @@ -657,7 +659,7 @@ describe('IssueDetail — existing issue', () => { it('navigates to the composed issue detail', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (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] })]); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).navigateToMilestone(); - expect(spy).toHaveBeenCalledWith(['/milestones', 10]); + expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 10]); }); it('does nothing when no milestone is linked', () => { @@ -1081,6 +1083,7 @@ describe('IssueDetail — new issue route', () => { }, { provide: IssuesStore, useValue: store }, { provide: MilestonesStore, useValue: new FakeMilestonesStore() }, + { provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } }, ], }).compileComponents(); @@ -1115,6 +1118,6 @@ describe('IssueDetail — new issue route', () => { (component as any).issue.name = 'Brand New Issue'; await (component as any).saveIssue(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]); }); }); diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 5e4871d..6c1fbdf 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -10,6 +10,7 @@ import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store'; import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram'; +import { ProjectContextService } from '../../projects/project-context.service'; @Component({ selector: 'app-issue-detail', @@ -24,6 +25,7 @@ export class IssueDetail { private readonly milestonesStore = inject(MilestonesStore); private readonly statusesStore = inject(StatusesStore); private readonly sanitizer = inject(DomSanitizer); + private readonly projectContext = inject(ProjectContextService); protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; protected issue: IssueEntity = this.buildIssue(); @@ -32,12 +34,17 @@ export class IssueDetail { protected moreMenuOpen = false; protected statusMenuOpen = false; + private get projectId(): number { + return this.projectContext.projectId()!; + } + constructor() { const idParam = this.route.snapshot.paramMap.get('id'); const safeId = Number(idParam ?? 0); + const projectId = this.projectId; - this.milestonesStore.load(); - this.issuesStore.load().then(() => { + this.milestonesStore.load(projectId); + this.issuesStore.load(projectId).then(() => { if (safeId) { const found = this.issuesStore.getById(safeId); if (found) this.issue = { ...found }; @@ -473,7 +480,7 @@ export class IssueDetail { } protected openComposedIssue(id: number): void { - this.router.navigate(['/issues', id]); + this.router.navigate(['/projects', this.projectId, 'issues', id]); } protected get epicIssueId(): number | null { @@ -483,7 +490,7 @@ export class IssueDetail { protected navigateToEpic(): void { 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 { 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); this.issue = { ...saved }; if (this.isNewIssueRoute) { - this.router.navigate(['/issues', saved.id]); + this.router.navigate(['/projects', this.projectId, 'issues', saved.id]); } } protected cancelCreation(): void { - this.router.navigate(['/issues']); + this.router.navigate(['/projects', this.projectId, 'issues']); } protected async deleteIssue(): Promise { 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 { diff --git a/src/app/issues/issues-api.service.ts b/src/app/issues/issues-api.service.ts index 2348a32..29fdb61 100644 --- a/src/app/issues/issues-api.service.ts +++ b/src/app/issues/issues-api.service.ts @@ -9,8 +9,10 @@ export const API_BASE_URL = '/api'; export class IssuesApiService { private readonly http = inject(HttpClient); - getAll(): Observable { - return this.http.get(`${API_BASE_URL}/issues`); + getAll(projectId: number): Observable { + return this.http.get(`${API_BASE_URL}/issues`, { + params: { projectId: projectId.toString() }, + }); } create(issue: Omit): Observable { diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 9289b88..2b308fb 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -7,6 +7,7 @@ import { Issues } from './issues'; import { IssueEntity, IssuesStore } from './issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { StatusesStore } from '../statuses/statuses.store'; +import { ProjectContextService } from '../projects/project-context.service'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -174,6 +175,7 @@ describe('Issues', () => { { provide: IssuesStore, useValue: store }, { provide: MilestonesStore, useValue: milestonesStore }, { provide: StatusesStore, useValue: statusesStore }, + { provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } }, ], }).compileComponents(); @@ -281,10 +283,10 @@ describe('Issues', () => { }); describe('createIssue', () => { - it('navigates to /issues/new', () => { + it('navigates to /projects/:pid/issues/new', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (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 () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).openIssue(42); - expect(spy).toHaveBeenCalledWith(['/issues', 42]); + expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]); }); }); diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts index ba08360..4bd33dc 100644 --- a/src/app/issues/issues.store.spec.ts +++ b/src/app/issues/issues.store.spec.ts @@ -28,9 +28,12 @@ describe('IssuesStore', () => { let store: IssuesStore; let httpMock: HttpTestingController; + const PROJECT_ID = 1; + const ISSUES_URL = `${API_BASE_URL}/issues?projectId=${PROJECT_ID}`; + const loadWith = async (issues: IssueEntity[]) => { - const p = store.load(); - httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues); + const p = store.load(PROJECT_ID); + httpMock.expectOne(ISSUES_URL).flush(issues); await p; }; @@ -57,18 +60,28 @@ describe('IssuesStore', () => { }); 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); - httpMock.expectOne(`${API_BASE_URL}/issues`).flush([]); + httpMock.expectOne(ISSUES_URL).flush([]); await p; expect(store.loading()).toBe(false); 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 store.load(); - httpMock.expectNone(`${API_BASE_URL}/issues`); + await store.load(PROJECT_ID); + 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); }); }); diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 31367c1..725e61c 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -37,6 +37,7 @@ export type IssueEntity = { export class IssuesStore { private readonly api = inject(IssuesApiService); private readonly data = signal([]); + private currentProjectId: number | null = null; readonly loading = signal(false); readonly loaded = signal(false); @@ -51,11 +52,13 @@ export class IssuesStore { return ids.length === 0 ? 1 : Math.max(...ids) + 1; } - async load(): Promise { - if (this.loaded()) return; + async load(projectId: number): Promise { + if (this.loaded() && this.currentProjectId === projectId) return; + this.currentProjectId = projectId; + this.loaded.set(false); this.loading.set(true); 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.loaded.set(true); } finally { diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index 4a1c3f8..e75295b 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router'; import { IssueEntity, IssueStatus, IssuesStore } from './issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { StatusEntity, StatusesStore } from '../statuses/statuses.store'; +import { ProjectContextService } from '../projects/project-context.service'; @Component({ selector: 'app-issues', @@ -16,10 +17,12 @@ export class Issues { private readonly issuesStore = inject(IssuesStore); private readonly milestonesStore = inject(MilestonesStore); private readonly statusesStore = inject(StatusesStore); + private readonly projectContext = inject(ProjectContextService); constructor() { - this.issuesStore.load(); - this.milestonesStore.load(); + const projectId = this.projectContext.projectId()!; + this.issuesStore.load(projectId); + this.milestonesStore.load(projectId); } protected readonly issues = this.issuesStore.issues; @@ -138,11 +141,11 @@ export class Issues { } protected createIssue(): void { - this.router.navigate(['/issues/new']); + this.router.navigate(['/projects', this.projectContext.projectId(), 'issues', 'new']); } protected openIssue(issueId: number): void { - this.router.navigate(['/issues', issueId]); + this.router.navigate(['/projects', this.projectContext.projectId(), 'issues', issueId]); } protected getProgress(issue: IssueEntity): number { diff --git a/src/app/menu/menu.css b/src/app/menu/menu.css index 788daec..d9f8766 100644 --- a/src/app/menu/menu.css +++ b/src/app/menu/menu.css @@ -124,3 +124,29 @@ 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; +} + diff --git a/src/app/menu/menu.html b/src/app/menu/menu.html index 703fa86..15f4d16 100644 --- a/src/app/menu/menu.html +++ b/src/app/menu/menu.html @@ -30,16 +30,31 @@ @if (keycloak.isAuthenticated()) { + +@if (showMigratePanel) { +
+
Migrer « {{ milestone.name }} » vers un autre projet
+ @if (availableProjects.length === 0) { +

Aucun autre projet disponible.

+ } @else { + + } +
+ + +
+
+} +
diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts index d4f8a95..c4e0f72 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.spec.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -8,6 +8,8 @@ import { MilestoneDetail } from './milestone-detail'; import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones.store'; import { StatusesStore } from '../../settings/statuses/statuses.store'; +import { ProjectContextService } from '../../projects/project-context.service'; +import { ProjectEntity, ProjectsStore } from '../../projects/projects.store'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -125,6 +127,11 @@ class FakeMilestonesStore { this._data.update((list) => list.filter((m) => m.id !== id)); return Promise.resolve(); } + + migrate(id: number, _targetProjectId: number): Promise { + this._data.update((list) => list.filter((m) => m.id !== id)); + return Promise.resolve(); + } } function makeRoute(id = '1', path = 'milestones/:id') { @@ -149,6 +156,13 @@ describe('MilestoneDetail', () => { issuesStore = new FakeIssuesStore(); milestonesStore = new FakeMilestonesStore(); statusesStore = new FakeStatusesStore(); + const projectsStoreMock = { + projects: signal([ + { 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({ imports: [MilestoneDetail], providers: [ @@ -157,6 +171,8 @@ describe('MilestoneDetail', () => { { provide: IssuesStore, useValue: issuesStore }, { provide: MilestonesStore, useValue: milestonesStore }, { provide: StatusesStore, useValue: statusesStore }, + { provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } }, + { provide: ProjectsStore, useValue: projectsStoreMock }, ], }).compileComponents(); @@ -427,7 +443,7 @@ describe('MilestoneDetail', () => { it('navigates to the issue detail page', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (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', () => { - 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); 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', () => { - it('navigates to /milestones', () => { + it('navigates to /projects/:pid/milestones', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).cancelCreation(); - expect(spy).toHaveBeenCalledWith(['/milestones']); + expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']); }); }); @@ -795,6 +853,10 @@ describe('MilestoneDetail — new route', () => { beforeEach(async () => { milestonesStore = new FakeMilestonesStore(); + const projectsStoreMock = { + projects: signal([]).asReadonly(), + load: vi.fn().mockResolvedValue(undefined), + }; await TestBed.configureTestingModule({ imports: [MilestoneDetail], providers: [ @@ -811,6 +873,8 @@ describe('MilestoneDetail — new route', () => { }, { provide: IssuesStore, useValue: new FakeIssuesStore() }, { provide: MilestonesStore, useValue: milestonesStore }, + { provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } }, + { provide: ProjectsStore, useValue: projectsStoreMock }, ], }).compileComponents(); @@ -844,6 +908,6 @@ describe('MilestoneDetail — new route', () => { milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] }); await (component as any).saveMilestone(true); - expect(spy).toHaveBeenCalledWith(['/milestones', 1]); + expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 1]); }); }); diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index d6f5636..ea43ed2 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -7,8 +7,10 @@ import { marked } from 'marked'; import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util'; 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 { ProjectContextService } from '../../projects/project-context.service'; +import { ProjectEntity, ProjectsStore } from '../../projects/projects.store'; @Component({ selector: 'app-milestone-detail', @@ -23,6 +25,12 @@ export class MilestoneDetail { private readonly issuesStore = inject(IssuesStore); private readonly sanitizer = inject(DomSanitizer); 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 milestone: MilestoneEntity = this.buildMilestone(); @@ -44,18 +52,22 @@ export class MilestoneDetail { protected issueSearchQuery = ''; protected showIssueSuggestions = false; protected moreMenuOpen = false; + protected showMigratePanel = false; + protected selectedMigrateProjectId: number | null = null; protected showAddDependency = false; protected selectedCandidateMilestoneId: number | null = null; constructor() { - this.milestonesStore.load().then(() => { + const projectId = this.projectId; + this.projectsStore.load(); + this.milestonesStore.load(projectId).then(() => { if (!this.isNewRoute) { const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); const found = this.milestonesStore.getById(id); if (found) this.milestone = { ...found }; } }); - this.issuesStore.load(); + this.issuesStore.load(projectId); this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { const id = Number(params.get('id')); @@ -335,17 +347,17 @@ export class MilestoneDetail { const saved = await this.milestonesStore.upsert(this.milestone); this.milestone = { ...saved }; if (this.isNewRoute) { - this.router.navigate(['/milestones', saved.id]); + this.router.navigate(['/projects', this.projectId, 'milestones', saved.id]); } } protected cancelCreation(): void { - this.router.navigate(['/milestones']); + this.router.navigate(['/projects', this.projectId, 'milestones']); } protected async deleteMilestone(): Promise { await this.milestonesStore.deleteById(this.milestone.id); - this.router.navigate(['/milestones']); + this.router.navigate(['/projects', this.projectId, 'milestones']); } protected toggleMoreMenu(): void { @@ -356,8 +368,30 @@ export class MilestoneDetail { 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 { + 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 { - this.router.navigate(['/issues', id]); + this.router.navigate(['/projects', this.projectId, 'issues', id]); } private buildMilestone(): MilestoneEntity { diff --git a/src/app/milestones/milestones-api.service.spec.ts b/src/app/milestones/milestones-api.service.spec.ts index 2eb67e1..605d56e 100644 --- a/src/app/milestones/milestones-api.service.spec.ts +++ b/src/app/milestones/milestones-api.service.spec.ts @@ -33,12 +33,13 @@ describe('MilestonesApiService', () => { afterEach(() => http.verify()); 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 })]; let result: MilestoneEntity[] | undefined; - service.getAll().subscribe((data) => (result = data)); - const req = http.expectOne(`${API}/milestones`); + service.getAll(1).subscribe((data) => (result = data)); + const req = http.expectOne(`${API}/milestones?projectId=1`); expect(req.request.method).toBe('GET'); + expect(req.request.params.get('projectId')).toBe('1'); req.flush(milestones); expect(result).toEqual(milestones); }); @@ -81,4 +82,16 @@ describe('MilestonesApiService', () => { 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); + }); + }); }); diff --git a/src/app/milestones/milestones-api.service.ts b/src/app/milestones/milestones-api.service.ts index dd7ea23..e076482 100644 --- a/src/app/milestones/milestones-api.service.ts +++ b/src/app/milestones/milestones-api.service.ts @@ -8,8 +8,10 @@ import { MilestoneEntity } from './milestones.store'; export class MilestonesApiService { private readonly http = inject(HttpClient); - getAll(): Observable { - return this.http.get(`${API_BASE_URL}/milestones`); + getAll(projectId: number): Observable { + return this.http.get(`${API_BASE_URL}/milestones`, { + params: { projectId: projectId.toString() }, + }); } create(milestone: Omit): Observable { @@ -23,4 +25,8 @@ export class MilestonesApiService { remove(id: number): Observable { return this.http.delete(`${API_BASE_URL}/milestones/${id}`); } + + migrate(id: number, targetProjectId: number): Observable { + return this.http.put(`${API_BASE_URL}/milestones/${id}/migrate`, { targetProjectId }); + } } diff --git a/src/app/milestones/milestones.store.spec.ts b/src/app/milestones/milestones.store.spec.ts index 6ddff4c..0b2a6ef 100644 --- a/src/app/milestones/milestones.store.spec.ts +++ b/src/app/milestones/milestones.store.spec.ts @@ -21,9 +21,12 @@ describe('MilestonesStore', () => { let store: MilestonesStore; let httpMock: HttpTestingController; + const PROJECT_ID = 1; + const MILESTONES_URL = `${API}/milestones?projectId=${PROJECT_ID}`; + const loadWith = async (milestones: MilestoneEntity[]) => { - const p = store.load(); - httpMock.expectOne(`${API}/milestones`).flush(milestones); + const p = store.load(PROJECT_ID); + httpMock.expectOne(MILESTONES_URL).flush(milestones); await p; }; @@ -48,18 +51,28 @@ describe('MilestonesStore', () => { }); 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); - httpMock.expectOne(`${API}/milestones`).flush([]); + httpMock.expectOne(MILESTONES_URL).flush([]); await p; expect(store.loading()).toBe(false); 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 store.load(); - httpMock.expectNone(`${API}/milestones`); + await store.load(PROJECT_ID); + 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', () => { it('normalizes issueIds to empty array when not an array', async () => { const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: null } as any; diff --git a/src/app/milestones/milestones.store.ts b/src/app/milestones/milestones.store.ts index 59f13fe..ad32790 100644 --- a/src/app/milestones/milestones.store.ts +++ b/src/app/milestones/milestones.store.ts @@ -1,5 +1,5 @@ import { Injectable, inject, signal } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; +import { catchError, firstValueFrom, of } from 'rxjs'; import { MilestonesApiService } from './milestones-api.service'; export type MilestoneEntity = { @@ -17,6 +17,7 @@ export type MilestoneEntity = { export class MilestonesStore { private readonly api = inject(MilestonesApiService); private readonly data = signal([]); + private currentProjectId: number | null = null; readonly loading = signal(false); readonly loaded = signal(false); @@ -26,11 +27,13 @@ export class MilestonesStore { return this.data().find((m) => m.id === id); } - async load(): Promise { - if (this.loaded()) return; + async load(projectId: number): Promise { + if (this.loaded() && this.currentProjectId === projectId) return; + this.currentProjectId = projectId; + this.loaded.set(false); this.loading.set(true); 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.loaded.set(true); } finally { @@ -65,6 +68,13 @@ export class MilestonesStore { this.data.update((list) => list.filter((m) => m.id !== id)); } + async migrate(id: number, targetProjectId: number): Promise { + // 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 { return { id: milestone.id ?? 0, diff --git a/src/app/milestones/milestones.ts b/src/app/milestones/milestones.ts index 9eddcef..63f6301 100644 --- a/src/app/milestones/milestones.ts +++ b/src/app/milestones/milestones.ts @@ -5,6 +5,7 @@ import { IssuesStore } from '../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from './milestones.store'; import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram'; import { StatusesStore } from '../statuses/statuses.store'; +import { ProjectContextService } from '../projects/project-context.service'; @Component({ selector: 'app-milestones', @@ -17,10 +18,12 @@ export class Milestones { private readonly milestonesStore = inject(MilestonesStore); private readonly issuesStore = inject(IssuesStore); private readonly statusesStore = inject(StatusesStore); + private readonly projectContext = inject(ProjectContextService); constructor() { - this.milestonesStore.load(); - this.issuesStore.load(); + const projectId = this.projectContext.projectId()!; + this.milestonesStore.load(projectId); + this.issuesStore.load(projectId); } protected readonly milestones = this.milestonesStore.milestones; @@ -103,10 +106,10 @@ export class Milestones { } protected createMilestone(): void { - this.router.navigate(['/milestones/new']); + this.router.navigate(['/projects', this.projectContext.projectId(), 'milestones', 'new']); } protected openMilestone(id: number): void { - this.router.navigate(['/milestones', id]); + this.router.navigate(['/projects', this.projectContext.projectId(), 'milestones', id]); } } diff --git a/src/app/projects/project-context.service.spec.ts b/src/app/projects/project-context.service.spec.ts new file mode 100644 index 0000000..4ab2175 --- /dev/null +++ b/src/app/projects/project-context.service.spec.ts @@ -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 => ({ + 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(); + }); + }); +}); diff --git a/src/app/projects/project-context.service.ts b/src/app/projects/project-context.service.ts new file mode 100644 index 0000000..81b0b79 --- /dev/null +++ b/src/app/projects/project-context.service.ts @@ -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(null); + private readonly _project = signal(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); + } +} diff --git a/src/app/projects/project-workspace/project-workspace.spec.ts b/src/app/projects/project-workspace/project-workspace.spec.ts new file mode 100644 index 0000000..7cbf39a --- /dev/null +++ b/src/app/projects/project-workspace/project-workspace.spec.ts @@ -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 => ({ + id: 1, + name: 'Alpha', + owner: 'Alice', + status: 'Actif', + progress: 60, + ...overrides, +}); + +describe('ProjectWorkspace', () => { + let component: ProjectWorkspace; + let fixture: ComponentFixture; + + const contextMock = { + setId: vi.fn(), + set: vi.fn(), + clear: vi.fn(), + projectId: signal(null), + project: signal(null), + }; + + const storeMock = { + load: vi.fn().mockResolvedValue(undefined), + getById: vi.fn(), + projects: signal([]), + 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(); + }); +}); diff --git a/src/app/projects/project-workspace/project-workspace.ts b/src/app/projects/project-workspace/project-workspace.ts new file mode 100644 index 0000000..8be8af9 --- /dev/null +++ b/src/app/projects/project-workspace/project-workspace.ts @@ -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: '', + 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(); + } +} diff --git a/src/app/projects/projects-api.service.spec.ts b/src/app/projects/projects-api.service.spec.ts new file mode 100644 index 0000000..ed39c4e --- /dev/null +++ b/src/app/projects/projects-api.service.spec.ts @@ -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 => ({ + 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); + }); + }); +}); diff --git a/src/app/projects/projects-api.service.ts b/src/app/projects/projects-api.service.ts new file mode 100644 index 0000000..1bf0af2 --- /dev/null +++ b/src/app/projects/projects-api.service.ts @@ -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 { + return this.http.get(`${API_BASE_URL}/projects`); + } + + create(project: Omit): Observable { + return this.http.post(`${API_BASE_URL}/projects`, project); + } + + update(id: number, project: ProjectEntity): Observable { + return this.http.put(`${API_BASE_URL}/projects/${id}`, project); + } + + remove(id: number): Observable { + return this.http.delete(`${API_BASE_URL}/projects/${id}`); + } +} diff --git a/src/app/projects/projects.css b/src/app/projects/projects.css index db89229..fcc2fad 100644 --- a/src/app/projects/projects.css +++ b/src/app/projects/projects.css @@ -2,3 +2,256 @@ 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; +} diff --git a/src/app/projects/projects.html b/src/app/projects/projects.html index 7313163..46f6127 100644 --- a/src/app/projects/projects.html +++ b/src/app/projects/projects.html @@ -1,34 +1,70 @@ -
-
-

Projets

-

Vue d'ensemble des projets actifs.

+
+
+
+

Projets

+

Sélectionnez un projet pour accéder à ses Issues, Milestones et Tableau de bord.

+
+ @if (!showForm()) { + + }
- -
-
-
- - - - - - - - - - - @for (project of projects(); track project.id) { - - - - - - - } - -
NomResponsableStatutProgression
{{ project.name }}{{ project.owner }}{{ project.status }}{{ project.progress }}%
-
+ @if (showForm()) { +
+
Nouveau projet
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ } + + @if (loading()) { +
Chargement des projets…
+ } @else if (projects().length === 0) { +
Aucun projet disponible. Créez votre premier projet.
+ } @else { +
+ @for (project of projects(); track project.id) { + + } +
+ }
diff --git a/src/app/projects/projects.spec.ts b/src/app/projects/projects.spec.ts index 131c8d4..5f8663a 100644 --- a/src/app/projects/projects.spec.ts +++ b/src/app/projects/projects.spec.ts @@ -1,15 +1,52 @@ +import { signal } from '@angular/core'; 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'; +const makeProject = (overrides: Partial = {}): ProjectEntity => ({ + id: 1, + name: 'Mon Projet', + owner: 'Alice', + status: 'Actif', + progress: 50, + ...overrides, +}); + describe('Projects', () => { let component: Projects; let fixture: ComponentFixture; + let router: Router; + + const projectsData = signal([ + 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 () => { + storeMock.load = vi.fn().mockResolvedValue(undefined); + storeMock.createLocal = vi.fn((name: string, owner: string): ProjectEntity => makeProject({ id: 10, name, owner })); + await TestBed.configureTestingModule({ imports: [Projects], + providers: [ + provideRouter([]), + { provide: ProjectsStore, useValue: storeMock }, + ], }).compileComponents(); + router = TestBed.inject(Router); fixture = TestBed.createComponent(Projects); component = fixture.componentInstance; await fixture.whenStable(); @@ -19,32 +56,74 @@ describe('Projects', () => { 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); }); - it('createProject adds a new project', () => { - (component as any).createProject(); - expect((component as any).projects().length).toBe(4); + describe('openProject', () => { + it('navigates to /projects/:id/dashboard', () => { + 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', () => { - (component as any).createProject(); - (component as any).createProject(); - const projects = (component as any).projects(); - expect(projects[3].id).toBe(4); - expect(projects[4].id).toBe(5); + describe('statusClass', () => { + it('returns badge-actif for Actif', () => { + expect((component as any).statusClass('Actif')).toBe('badge-actif'); + }); + + 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', () => { - (component as any).createProject(); - const newProject = (component as any).projects()[3]; - expect(newProject.status).toBe('Nouveau'); - }); + describe('project creation form', () => { + it('showForm is false initially', () => { + expect((component as any).showForm()).toBe(false); + }); - it('new project starts with 0 progress', () => { - (component as any).createProject(); - const newProject = (component as any).projects()[3]; - expect(newProject.progress).toBe(0); + it('openForm sets showForm to true', () => { + (component as any).openForm(); + expect((component as any).showForm()).toBe(true); + }); + + 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(); + }); }); }); diff --git a/src/app/projects/projects.store.spec.ts b/src/app/projects/projects.store.spec.ts new file mode 100644 index 0000000..b7c31d2 --- /dev/null +++ b/src/app/projects/projects.store.spec.ts @@ -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 => ({ + 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); + }); + }); +}); diff --git a/src/app/projects/projects.store.ts b/src/app/projects/projects.store.ts new file mode 100644 index 0000000..64c2866 --- /dev/null +++ b/src/app/projects/projects.store.ts @@ -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([]); + + 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 { + 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 { + 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 { + await firstValueFrom(this.api.remove(id)); + this.data.update((list) => list.filter((p) => p.id !== id)); + } +} diff --git a/src/app/projects/projects.ts b/src/app/projects/projects.ts index 657725c..1352965 100644 --- a/src/app/projects/projects.ts +++ b/src/app/projects/projects.ts @@ -1,38 +1,57 @@ -import { Component, signal } from '@angular/core'; - -type Project = { - id: number; - name: string; - owner: string; - status: 'Actif' | 'En attente' | 'Nouveau'; - progress: number; -}; +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ProjectEntity, ProjectsStore } from './projects.store'; @Component({ selector: 'app-projects', - imports: [], + imports: [FormsModule], templateUrl: './projects.html', styleUrl: './projects.css', }) export class Projects { - protected readonly projects = signal([ - { 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 }, - ]); + private readonly router = inject(Router); + protected readonly projectsStore = inject(ProjectsStore); - 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 { - const newProject: Project = { - id: this.nextId, - name: `Nouveau projet ${this.nextId}`, - owner: 'A definir', - status: 'Nouveau', - progress: 0, - }; + const name = this.newName.trim(); + if (!name) return; + const project = this.projectsStore.createLocal(name, this.newOwner); + this.showForm.set(false); + this.router.navigate(['/projects', project.id, 'dashboard']); + } - this.projects.update((currentProjects) => [...currentProjects, newProject]); - this.nextId += 1; + protected statusClass(status: ProjectEntity['status']): string { + const map: Record = { + Actif: 'badge-actif', + 'En attente': 'badge-attente', + Nouveau: 'badge-nouveau', + }; + return map[status] ?? ''; } } diff --git a/src/app/settings/statuses/statuses.store.ts b/src/app/settings/statuses/statuses.store.ts index 95dd66c..c519a12 100644 --- a/src/app/settings/statuses/statuses.store.ts +++ b/src/app/settings/statuses/statuses.store.ts @@ -1,71 +1,5 @@ -import { Injectable, signal } from '@angular/core'; - -export type StatusEntity = { - id: string; - label: string; - 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([]); - - 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): 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>): void { - this.data.update((statuses) => - statuses.map((s) => (s.id === id ? { ...s, ...changes } : s)), - ); - this.saveToStorage(); - } -} +// 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 { StatusesStore } from '../../statuses/statuses.store'; +export type { StatusEntity } from '../../statuses/statuses.store'; +export { DEFAULT_STATUSES } from '../../statuses/statuses.store'; diff --git a/src/app/statuses/statuses.store.spec.ts b/src/app/statuses/statuses.store.spec.ts index 1965495..fce105e 100644 --- a/src/app/statuses/statuses.store.spec.ts +++ b/src/app/statuses/statuses.store.spec.ts @@ -129,4 +129,47 @@ describe('StatusesStore', () => { 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(); + }); + }); }); diff --git a/src/app/statuses/statuses.store.ts b/src/app/statuses/statuses.store.ts index 7f1dcf7..d158f1c 100644 --- a/src/app/statuses/statuses.store.ts +++ b/src/app/statuses/statuses.store.ts @@ -16,14 +16,21 @@ export const DEFAULT_STATUSES: StatusEntity[] = [ { 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' }) export class StatusesStore { - private readonly data = signal(this.loadFromStorage()); + private currentProjectId: number | null = null; + private readonly data = signal(this.readFromStorage(null)); 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 { return this.data().find((s) => s.id === id); } @@ -50,13 +57,17 @@ export class StatusesStore { this.saveToStorage(); } - private saveToStorage(): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data())); + private storageKey(projectId: number | null): string { + 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 { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(this.storageKey(projectId)); if (raw) { const parsed = JSON.parse(raw) as StatusEntity[]; if (Array.isArray(parsed) && parsed.length > 0) {