Ajoute projet et migration milestone
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
+25
-12
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -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]="item.path"
|
routerLink="/projects"
|
||||||
routerLinkActive="sidebar-link--active"
|
routerLinkActive="sidebar-link--active"
|
||||||
[routerLinkActiveOptions]="{ exact: true }"
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
>
|
||||||
|
Projets
|
||||||
|
</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 }}
|
{{ item.label }}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-user">
|
<div class="sidebar-user">
|
||||||
|
|||||||
+35
-21
@@ -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');
|
||||||
|
expect(items[1].path).toBe('/projects/5/issues');
|
||||||
|
expect(items[2].path).toBe('/projects/5/milestones');
|
||||||
|
expect(items[3].path).toBe('/projects/5/statuses');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain Milestones link', () => {
|
|
||||||
const items = (component as any).menuItems as { label: string; path: string }[];
|
|
||||||
expect(items.some((i) => i.path === '/milestones')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-8
@@ -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,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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 class="projects-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h2 mb-2">Projets</h1>
|
<h1 class="projects-title">Projets</h1>
|
||||||
<p class="text-secondary mb-0">Vue d'ensemble des projets actifs.</p>
|
<p class="projects-subtitle">Sélectionnez un projet pour accéder à ses Issues, Milestones et Tableau de bord.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-primary" (click)="createProject()">
|
@if (!showForm()) {
|
||||||
Nouveau projet
|
<button class="btn-new-project" (click)="openForm()">+ Nouveau projet</button>
|
||||||
</button>
|
}
|
||||||
</div>
|
</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"
|
||||||
|
class="project-form-input"
|
||||||
|
[(ngModel)]="newOwner"
|
||||||
|
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>
|
||||||
}
|
}
|
||||||
</tbody>
|
|
||||||
</table>
|
@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>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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('new project starts with Nouveau status', () => {
|
it('returns badge-attente for En attente', () => {
|
||||||
(component as any).createProject();
|
expect((component as any).statusClass('En attente')).toBe('badge-attente');
|
||||||
const newProject = (component as any).projects()[3];
|
|
||||||
expect(newProject.status).toBe('Nouveau');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('new project starts with 0 progress', () => {
|
it('returns badge-nouveau for Nouveau', () => {
|
||||||
|
expect((component as any).statusClass('Nouveau')).toBe('badge-nouveau');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('project creation form', () => {
|
||||||
|
it('showForm is false initially', () => {
|
||||||
|
expect((component as any).showForm()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
(component as any).createProject();
|
||||||
const newProject = (component as any).projects()[3];
|
expect(storeMock.createLocal).toHaveBeenCalledWith('Mon Projet', 'Bob');
|
||||||
expect(newProject.progress).toBe(0);
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user