Compare commits
10 Commits
401da09f8f
...
5de40c06cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de40c06cb | |||
| 66e39f15b2 | |||
| ea54f49024 | |||
| 11aba5dbd0 | |||
| 9f5012e9ea | |||
| a47f733e57 | |||
| 844090528f | |||
| f680c06208 | |||
| fe1c346eac | |||
| 54d1534d4d |
@@ -27,6 +27,11 @@ jobs:
|
||||
id: repo
|
||||
run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Bump version in package.json
|
||||
run: |
|
||||
VERSION=$(echo "${{ gitea.ref_name }}" | sed 's/^v//')
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json
|
||||
|
||||
- name: Build and push
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
@@ -35,3 +40,33 @@ jobs:
|
||||
tags: |
|
||||
git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:${{ gitea.ref_name }}
|
||||
git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:latest
|
||||
cache-from: type=registry,ref=git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:cache
|
||||
cache-to: type=registry,ref=git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:cache,mode=max
|
||||
|
||||
- name: Trigger production deployment
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
|
||||
https://watchtower.goutailler-olivier.com/v1/update
|
||||
|
||||
- name: Merge release to main
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea"
|
||||
git remote set-url origin https://${{ gitea.actor }}:${{ secrets.RELEASE_TOKEN }}@git.goutailler-olivier.com/${{ gitea.repository }}.git
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git merge --no-ff ${{ gitea.ref_name }} -m "chore: release ${{ gitea.ref_name }}"
|
||||
git push origin main
|
||||
|
||||
- name: Bump version to next SNAPSHOT on develop
|
||||
run: |
|
||||
VERSION=$(echo "${{ gitea.ref_name }}" | sed 's/^v//')
|
||||
IFS='.' read -r major minor patch <<< "$VERSION"
|
||||
NEXT_VERSION="$major.$minor.$((patch + 1))-SNAPSHOT"
|
||||
git fetch origin develop
|
||||
git checkout develop
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$NEXT_VERSION\"/" package.json
|
||||
git add package.json
|
||||
git commit -m "chore: bump version to $NEXT_VERSION"
|
||||
git push origin develop
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Rollback
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version cible (ex: v1.2.3)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
name: Rollback to ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Login to Gitea container registry
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: git.goutailler-olivier.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Retag version as latest in registry
|
||||
run: |
|
||||
docker pull git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:${{ inputs.version }}
|
||||
docker tag git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:${{ inputs.version }} git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:latest
|
||||
docker push git.goutailler-olivier.com/${{ steps.repo.outputs.name }}:latest
|
||||
|
||||
- name: Trigger production deployment
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
|
||||
https://watchtower.goutailler-olivier.com/v1/update
|
||||
+25
-12
@@ -6,21 +6,34 @@ import { Issues } from './issues/issues';
|
||||
import { MilestoneDetail } from './milestones/milestone-detail/milestone-detail';
|
||||
import { Milestones } from './milestones/milestones';
|
||||
import { Projects } from './projects/projects';
|
||||
import { ProjectWorkspace } from './projects/project-workspace/project-workspace';
|
||||
import { Statuses } from './statuses/statuses';
|
||||
import { authGuard } from './auth/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'home' },
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'projects' },
|
||||
{ path: 'home', component: Home },
|
||||
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] },
|
||||
{ path: 'project', component: Projects, canActivate: [authGuard] },
|
||||
{ path: 'projects', redirectTo: 'project' },
|
||||
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
||||
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
|
||||
{ path: 'issues', component: Issues, canActivate: [authGuard] },
|
||||
{ path: 'milestones/new', component: MilestoneDetail, canActivate: [authGuard] },
|
||||
{ path: 'milestones/:id', component: MilestoneDetail, canActivate: [authGuard] },
|
||||
{ path: 'milestones', component: Milestones, canActivate: [authGuard] },
|
||||
{ path: 'statuses', component: Statuses, canActivate: [authGuard] },
|
||||
{ path: '**', redirectTo: 'home' },
|
||||
{
|
||||
path: 'projects',
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{ path: '', component: Projects, pathMatch: 'full' },
|
||||
{
|
||||
path: ':projectId',
|
||||
component: ProjectWorkspace,
|
||||
children: [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: Dashboard },
|
||||
{ path: 'issues', component: Issues },
|
||||
{ path: 'issues/new', component: IssueDetail },
|
||||
{ path: 'issues/:id', component: IssueDetail },
|
||||
{ path: 'milestones', component: Milestones },
|
||||
{ path: 'milestones/new', component: MilestoneDetail },
|
||||
{ path: 'milestones/:id', component: MilestoneDetail },
|
||||
{ path: 'statuses', component: Statuses },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'projects' },
|
||||
];
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import { vi } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { KeycloakService } from './keycloak.service';
|
||||
|
||||
const mockKc = vi.hoisted(() => ({
|
||||
init: vi.fn(),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
updateToken: vi.fn(),
|
||||
token: 'mock-token' as string | undefined,
|
||||
tokenParsed: { preferred_username: 'testuser' } as Record<string, string> | undefined,
|
||||
authenticated: true as boolean | undefined,
|
||||
onTokenExpired: undefined as (() => Promise<void> | void) | undefined,
|
||||
}));
|
||||
|
||||
const MockKeycloak = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('keycloak-js', () => ({ default: MockKeycloak }));
|
||||
|
||||
describe('KeycloakService', () => {
|
||||
let service: KeycloakService;
|
||||
let mockKc: {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
login: ReturnType<typeof vi.fn>;
|
||||
logout: ReturnType<typeof vi.fn>;
|
||||
updateToken: ReturnType<typeof vi.fn>;
|
||||
token: string | undefined;
|
||||
tokenParsed: Record<string, string> | undefined;
|
||||
authenticated: boolean | undefined;
|
||||
onTokenExpired: (() => Promise<void> | void) | undefined;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
MockKeycloak.mockImplementation(function () { return mockKc; });
|
||||
mockKc.init.mockResolvedValue(true);
|
||||
mockKc.login.mockResolvedValue(undefined);
|
||||
mockKc.logout.mockResolvedValue(undefined);
|
||||
mockKc.updateToken.mockResolvedValue(true);
|
||||
mockKc.token = 'mock-token';
|
||||
mockKc.tokenParsed = { preferred_username: 'testuser' };
|
||||
mockKc.authenticated = true;
|
||||
mockKc.onTokenExpired = undefined;
|
||||
mockKc = {
|
||||
init: vi.fn().mockResolvedValue(true),
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
updateToken: vi.fn().mockResolvedValue(true),
|
||||
token: 'mock-token',
|
||||
tokenParsed: { preferred_username: 'testuser' },
|
||||
authenticated: true,
|
||||
onTokenExpired: undefined,
|
||||
};
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(KeycloakService);
|
||||
(service as any).keycloak = mockKc;
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
import { vi } from 'vitest';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { IssueEntity, IssuesStore } from '../issues/issues.store';
|
||||
@@ -89,6 +90,7 @@ describe('Dashboard', () => {
|
||||
{ provide: IssuesStore, useValue: issuesStore },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: StatusesStore, useValue: statusesStore },
|
||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -296,28 +298,28 @@ describe('Dashboard', () => {
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('navigue vers /issues/:id via openIssue', () => {
|
||||
it('navigue vers /projects/:pid/issues/:id via openIssue', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).openIssue(42);
|
||||
expect(spy).toHaveBeenCalledWith(['/issues', 42]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
|
||||
});
|
||||
|
||||
it('navigue vers /milestones/:id via openMilestone', () => {
|
||||
it('navigue vers /projects/:pid/milestones/:id via openMilestone', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).openMilestone(7);
|
||||
expect(spy).toHaveBeenCalledWith(['/milestones', 7]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 7]);
|
||||
});
|
||||
|
||||
it('navigue vers /issues via navigateToIssues', () => {
|
||||
it('navigue vers /projects/:pid/issues via navigateToIssues', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).navigateToIssues();
|
||||
expect(spy).toHaveBeenCalledWith(['/issues']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
|
||||
});
|
||||
|
||||
it('navigue vers /milestones via navigateToMilestones', () => {
|
||||
it('navigue vers /projects/:pid/milestones via navigateToMilestones', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).navigateToMilestones();
|
||||
expect(spy).toHaveBeenCalledWith(['/milestones']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import { IssueEntity, IssuesStore } from '../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@@ -15,10 +16,12 @@ export class Dashboard {
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
private readonly projectContext = inject(ProjectContextService);
|
||||
|
||||
constructor() {
|
||||
this.issuesStore.load();
|
||||
this.milestonesStore.load();
|
||||
const projectId = this.projectContext.projectId()!;
|
||||
this.issuesStore.load(projectId);
|
||||
this.milestonesStore.load(projectId);
|
||||
}
|
||||
|
||||
protected readonly totalIssues = computed(() => this.issuesStore.issues().length);
|
||||
@@ -152,18 +155,22 @@ export class Dashboard {
|
||||
}
|
||||
|
||||
protected openIssue(id: number): void {
|
||||
this.router.navigate(['/issues', id]);
|
||||
const pid = this.projectContext.projectId();
|
||||
this.router.navigate(['/projects', pid, 'issues', id]);
|
||||
}
|
||||
|
||||
protected openMilestone(id: number): void {
|
||||
this.router.navigate(['/milestones', id]);
|
||||
const pid = this.projectContext.projectId();
|
||||
this.router.navigate(['/projects', pid, 'milestones', id]);
|
||||
}
|
||||
|
||||
protected navigateToIssues(): void {
|
||||
this.router.navigate(['/issues']);
|
||||
const pid = this.projectContext.projectId();
|
||||
this.router.navigate(['/projects', pid, 'issues']);
|
||||
}
|
||||
|
||||
protected navigateToMilestones(): void {
|
||||
this.router.navigate(['/milestones']);
|
||||
const pid = this.projectContext.projectId();
|
||||
this.router.navigate(['/projects', pid, 'milestones']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { marked } from 'marked';
|
||||
import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
||||
import { IssueComment, IssueEntity, IssuesStore } from '../issues.store';
|
||||
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
|
||||
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
|
||||
import { MilestonesStore } from '../../milestones/milestones.store';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { vi } from 'vitest';
|
||||
import { IssueDetail } from './issue-detail';
|
||||
import { IssueEntity, IssuesStore } from '../issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
|
||||
import { ProjectContextService } from '../../projects/project-context.service';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 99,
|
||||
@@ -161,6 +162,7 @@ describe('IssueDetail — existing issue', () => {
|
||||
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -469,19 +471,19 @@ describe('IssueDetail — existing issue', () => {
|
||||
});
|
||||
|
||||
describe('deleteIssue', () => {
|
||||
it('removes the issue and navigates to /issues', async () => {
|
||||
it('removes the issue and navigates to /projects/:pid/issues', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
await (component as any).deleteIssue();
|
||||
expect(store.getById(1)).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(['/issues']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelCreation', () => {
|
||||
it('navigates to /issues', async () => {
|
||||
it('navigates to /projects/:pid/issues', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).cancelCreation();
|
||||
expect(spy).toHaveBeenCalledWith(['/issues']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -642,7 +644,7 @@ describe('IssueDetail — existing issue', () => {
|
||||
(component as any).issue.epic = 'Nav Epic';
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).navigateToEpic();
|
||||
expect(spy).toHaveBeenCalledWith(['/issues', 100]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 100]);
|
||||
});
|
||||
|
||||
it('does nothing when no matching epic is found', () => {
|
||||
@@ -657,7 +659,7 @@ describe('IssueDetail — existing issue', () => {
|
||||
it('navigates to the composed issue detail', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).openComposedIssue(42);
|
||||
expect(spy).toHaveBeenCalledWith(['/issues', 42]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -943,7 +945,7 @@ describe('IssueDetail — existing issue', () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]);
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).navigateToMilestone();
|
||||
expect(spy).toHaveBeenCalledWith(['/milestones', 10]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 10]);
|
||||
});
|
||||
|
||||
it('does nothing when no milestone is linked', () => {
|
||||
@@ -1081,6 +1083,7 @@ describe('IssueDetail — new issue route', () => {
|
||||
},
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
{ provide: MilestonesStore, useValue: new FakeMilestonesStore() },
|
||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -1115,6 +1118,6 @@ describe('IssueDetail — new issue route', () => {
|
||||
(component as any).issue.name = 'Brand New Issue';
|
||||
await (component as any).saveIssue(true);
|
||||
expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true);
|
||||
expect(spy).toHaveBeenCalledWith(['/issues', 10]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 10]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { handleImagePaste, insertAtSelection } from '../paste-image.util';
|
||||
import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
|
||||
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
|
||||
import { ProjectContextService } from '../../projects/project-context.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issue-detail',
|
||||
@@ -24,6 +25,7 @@ export class IssueDetail {
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly projectContext = inject(ProjectContextService);
|
||||
protected readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||
|
||||
protected issue: IssueEntity = this.buildIssue();
|
||||
@@ -32,12 +34,17 @@ export class IssueDetail {
|
||||
protected moreMenuOpen = false;
|
||||
protected statusMenuOpen = false;
|
||||
|
||||
private get projectId(): number {
|
||||
return this.projectContext.projectId()!;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const idParam = this.route.snapshot.paramMap.get('id');
|
||||
const safeId = Number(idParam ?? 0);
|
||||
const projectId = this.projectId;
|
||||
|
||||
this.milestonesStore.load();
|
||||
this.issuesStore.load().then(() => {
|
||||
this.milestonesStore.load(projectId);
|
||||
this.issuesStore.load(projectId).then(() => {
|
||||
if (safeId) {
|
||||
const found = this.issuesStore.getById(safeId);
|
||||
if (found) this.issue = { ...found };
|
||||
@@ -473,7 +480,7 @@ export class IssueDetail {
|
||||
}
|
||||
|
||||
protected openComposedIssue(id: number): void {
|
||||
this.router.navigate(['/issues', id]);
|
||||
this.router.navigate(['/projects', this.projectId, 'issues', id]);
|
||||
}
|
||||
|
||||
protected get epicIssueId(): number | null {
|
||||
@@ -483,7 +490,7 @@ export class IssueDetail {
|
||||
|
||||
protected navigateToEpic(): void {
|
||||
if (this.epicIssueId !== null) {
|
||||
this.router.navigate(['/issues', this.epicIssueId]);
|
||||
this.router.navigate(['/projects', this.projectId, 'issues', this.epicIssueId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +527,7 @@ export class IssueDetail {
|
||||
|
||||
protected navigateToMilestone(): void {
|
||||
if (this.currentMilestoneId !== null) {
|
||||
this.router.navigate(['/milestones', this.currentMilestoneId]);
|
||||
this.router.navigate(['/projects', this.projectId, 'milestones', this.currentMilestoneId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,17 +538,17 @@ export class IssueDetail {
|
||||
const saved = await this.issuesStore.upsert(this.issue);
|
||||
this.issue = { ...saved };
|
||||
if (this.isNewIssueRoute) {
|
||||
this.router.navigate(['/issues', saved.id]);
|
||||
this.router.navigate(['/projects', this.projectId, 'issues', saved.id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected cancelCreation(): void {
|
||||
this.router.navigate(['/issues']);
|
||||
this.router.navigate(['/projects', this.projectId, 'issues']);
|
||||
}
|
||||
|
||||
protected async deleteIssue(): Promise<void> {
|
||||
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> {
|
||||
|
||||
@@ -9,19 +9,19 @@ export const API_BASE_URL = '/api';
|
||||
export class IssuesApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getAll(): Observable<IssueEntity[]> {
|
||||
return this.http.get<IssueEntity[]>(`${API_BASE_URL}/issues`);
|
||||
getAll(projectId: number): Observable<IssueEntity[]> {
|
||||
return this.http.get<IssueEntity[]>(`${API_BASE_URL}/projects/${projectId}/issues`);
|
||||
}
|
||||
|
||||
create(issue: Omit<IssueEntity, 'id'>): Observable<IssueEntity> {
|
||||
return this.http.post<IssueEntity>(`${API_BASE_URL}/issues`, issue);
|
||||
create(projectId: number, issue: Omit<IssueEntity, 'id'>): Observable<IssueEntity> {
|
||||
return this.http.post<IssueEntity>(`${API_BASE_URL}/projects/${projectId}/issues`, issue);
|
||||
}
|
||||
|
||||
update(id: number, issue: IssueEntity): Observable<IssueEntity> {
|
||||
return this.http.put<IssueEntity>(`${API_BASE_URL}/issues/${id}`, issue);
|
||||
update(projectId: number, id: number, issue: IssueEntity): Observable<IssueEntity> {
|
||||
return this.http.put<IssueEntity>(`${API_BASE_URL}/projects/${projectId}/issues/${id}`, issue);
|
||||
}
|
||||
|
||||
remove(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${API_BASE_URL}/issues/${id}`);
|
||||
remove(projectId: number, id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${API_BASE_URL}/projects/${projectId}/issues/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { IssuesFilterService } from './issues-filter.service';
|
||||
|
||||
describe('IssuesFilterService', () => {
|
||||
let service: IssuesFilterService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(IssuesFilterService);
|
||||
});
|
||||
|
||||
it('initializes with empty filter state', () => {
|
||||
expect(service.searchQuery).toBe('');
|
||||
expect(service.selectedTypes.size).toBe(0);
|
||||
expect(service.selectedStatuses.size).toBe(0);
|
||||
expect(service.selectedPriorities.size).toBe(0);
|
||||
expect(service.selectedMilestoneIds.size).toBe(0);
|
||||
expect(service.showNoMilestone).toBe(false);
|
||||
});
|
||||
|
||||
it('allows mutation of filter state', () => {
|
||||
service.searchQuery = 'bug';
|
||||
service.selectedTypes.add('Bug');
|
||||
service.selectedPriorities.add('HAUTE');
|
||||
|
||||
expect(service.searchQuery).toBe('bug');
|
||||
expect(service.selectedTypes.has('Bug')).toBe(true);
|
||||
expect(service.selectedPriorities.has('HAUTE')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { IssueType, IssueStatus, IssuePriority } from './issues.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class IssuesFilterService {
|
||||
searchQuery = '';
|
||||
selectedTypes = new Set<IssueType>();
|
||||
selectedStatuses = new Set<IssueStatus>();
|
||||
selectedPriorities = new Set<IssuePriority>();
|
||||
selectedMilestoneIds = new Set<number>();
|
||||
showNoMilestone = false;
|
||||
}
|
||||
@@ -32,5 +32,28 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sortable-col {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortable-col:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sortable-col.sorted {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.sort-icon--idle {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,44 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Filtre Priorité -->
|
||||
<div class="dropdown">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm d-flex align-items-center gap-1"
|
||||
[class.btn-outline-secondary]="selectedPriorities.size === 0"
|
||||
[class.btn-primary]="selectedPriorities.size > 0"
|
||||
(click)="toggleDropdown('priority', $event)"
|
||||
>
|
||||
@if (selectedPriorities.size === 1) {
|
||||
<span [style.color]="priorityDisplay([...selectedPriorities][0]).color" style="font-weight:700;">
|
||||
{{ priorityDisplay([...selectedPriorities][0]).symbol }}
|
||||
</span>
|
||||
}
|
||||
{{ priorityDropdownLabel() }}
|
||||
<span class="ms-1">▾</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" [class.show]="openDropdown === 'priority'">
|
||||
<li>
|
||||
<button class="dropdown-item text-secondary small" [disabled]="selectedPriorities.size === 0" (click)="clearPriorities($event)">
|
||||
Tout effacer
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@for (p of priorityOptions; track p) {
|
||||
<li>
|
||||
<button class="dropdown-item d-flex align-items-center gap-2" (click)="togglePriority(p, $event)">
|
||||
<span class="filter-check">@if (selectedPriorities.has(p)) { ✓ }</span>
|
||||
<span [style.color]="priorityDisplay(p).color" style="font-weight:700; min-width:1.4rem; display:inline-block;">
|
||||
{{ priorityDisplay(p).symbol }}
|
||||
</span>
|
||||
{{ priorityDisplay(p).label }}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Filtre Milestone -->
|
||||
<div class="dropdown">
|
||||
<button
|
||||
@@ -127,7 +165,17 @@
|
||||
<th>#</th>
|
||||
<th>Titre</th>
|
||||
<th>Type</th>
|
||||
<th>Priorite</th>
|
||||
<th
|
||||
class="sortable-col"
|
||||
(click)="toggleSortPriority()"
|
||||
[class.sorted]="sortPriority !== null"
|
||||
title="Trier par priorité"
|
||||
>
|
||||
Priorité
|
||||
@if (sortPriority === 'desc') { <span class="sort-icon">↓</span> }
|
||||
@else if (sortPriority === 'asc') { <span class="sort-icon">↑</span> }
|
||||
@else { <span class="sort-icon sort-icon--idle">⇅</span> }
|
||||
</th>
|
||||
<th>Statut</th>
|
||||
<th>Milestone</th>
|
||||
<th>Assignee</th>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { provideRouter } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import { Issues } from './issues';
|
||||
import { IssueEntity, IssuesStore } from './issues.store';
|
||||
import { IssuesFilterService } from './issues-filter.service';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 99,
|
||||
@@ -174,6 +176,8 @@ describe('Issues', () => {
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: StatusesStore, useValue: statusesStore },
|
||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||
{ provide: IssuesFilterService, useFactory: () => new IssuesFilterService() },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -280,11 +284,136 @@ describe('Issues', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filteredIssues — priority filter', () => {
|
||||
beforeEach(() => {
|
||||
store.upsert(makeIssue({ id: 10, name: 'High Prio', priority: 'HAUTE' }));
|
||||
store.upsert(makeIssue({ id: 11, name: 'Low Prio', priority: 'BASSE' }));
|
||||
});
|
||||
|
||||
it('shows all issues when no priority filter is active', () => {
|
||||
(component as any).selectedPriorities = new Set();
|
||||
expect((component as any).filteredIssues.length).toBe(store.issues().length);
|
||||
});
|
||||
|
||||
it('shows only issues matching the selected priority', () => {
|
||||
(component as any).selectedPriorities = new Set(['HAUTE']);
|
||||
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
||||
expect(filtered.every((i) => i.priority === 'HAUTE')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows issues from multiple selected priorities', () => {
|
||||
(component as any).selectedPriorities = new Set(['HAUTE', 'BASSE']);
|
||||
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
||||
expect(filtered.every((i) => i.priority === 'HAUTE' || i.priority === 'BASSE')).toBe(true);
|
||||
expect(filtered.some((i) => i.priority === 'HAUTE')).toBe(true);
|
||||
expect(filtered.some((i) => i.priority === 'BASSE')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePriority', () => {
|
||||
it('adds a priority when not already selected', () => {
|
||||
(component as any).selectedPriorities = new Set();
|
||||
(component as any).togglePriority('HAUTE', mockEvent);
|
||||
expect((component as any).selectedPriorities.has('HAUTE')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes a priority when already selected (toggle off)', () => {
|
||||
(component as any).selectedPriorities = new Set(['HAUTE']);
|
||||
(component as any).togglePriority('HAUTE', mockEvent);
|
||||
expect((component as any).selectedPriorities.has('HAUTE')).toBe(false);
|
||||
});
|
||||
|
||||
it('can select multiple priorities simultaneously', () => {
|
||||
(component as any).selectedPriorities = new Set();
|
||||
(component as any).togglePriority('HAUTE', mockEvent);
|
||||
(component as any).togglePriority('BASSE', mockEvent);
|
||||
expect((component as any).selectedPriorities.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPriorities', () => {
|
||||
it('empties the priority selection', () => {
|
||||
(component as any).selectedPriorities = new Set(['HAUTE', 'BASSE']);
|
||||
(component as any).clearPriorities(mockEvent);
|
||||
expect((component as any).selectedPriorities.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('priorityDropdownLabel', () => {
|
||||
it('returns "Priorité" when nothing is selected', () => {
|
||||
(component as any).selectedPriorities = new Set();
|
||||
expect((component as any).priorityDropdownLabel()).toBe('Priorité');
|
||||
});
|
||||
|
||||
it('returns the priority label when exactly one is selected', () => {
|
||||
(component as any).selectedPriorities = new Set(['HAUTE']);
|
||||
expect((component as any).priorityDropdownLabel()).toBe('Haute');
|
||||
});
|
||||
|
||||
it('returns a count when multiple priorities are selected', () => {
|
||||
(component as any).selectedPriorities = new Set(['HAUTE', 'BASSE']);
|
||||
expect((component as any).priorityDropdownLabel()).toBe('Priorité (2)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortPriority — toggleSortPriority', () => {
|
||||
it('starts with no sort active', () => {
|
||||
expect((component as any).sortPriority).toBeNull();
|
||||
});
|
||||
|
||||
it('first toggle sets sort to desc (highest first)', () => {
|
||||
(component as any).toggleSortPriority();
|
||||
expect((component as any).sortPriority).toBe('desc');
|
||||
});
|
||||
|
||||
it('second toggle sets sort to asc (lowest first)', () => {
|
||||
(component as any).sortPriority = 'desc';
|
||||
(component as any).toggleSortPriority();
|
||||
expect((component as any).sortPriority).toBe('asc');
|
||||
});
|
||||
|
||||
it('third toggle resets sort to null', () => {
|
||||
(component as any).sortPriority = 'asc';
|
||||
(component as any).toggleSortPriority();
|
||||
expect((component as any).sortPriority).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filteredIssues — priority sort', () => {
|
||||
beforeEach(() => {
|
||||
store.upsert(makeIssue({ id: 10, name: 'Très haute', priority: 'TRES_HAUTE' }));
|
||||
store.upsert(makeIssue({ id: 11, name: 'Basse', priority: 'BASSE' }));
|
||||
store.upsert(makeIssue({ id: 12, name: 'Haute', priority: 'HAUTE' }));
|
||||
});
|
||||
|
||||
it('does not reorder issues when sortPriority is null', () => {
|
||||
(component as any).sortPriority = null;
|
||||
const ids = (component as any).filteredIssues.map((i: IssueEntity) => i.id);
|
||||
expect(ids).toEqual(store.issues().map((i) => i.id));
|
||||
});
|
||||
|
||||
it('sorts highest priority first when sortPriority is desc', () => {
|
||||
(component as any).sortPriority = 'desc';
|
||||
const priorities = (component as any).filteredIssues.map((i: IssueEntity) => i.priority);
|
||||
const order = ['TRES_HAUTE', 'HAUTE', 'MOYENNE', 'BASSE', 'TRES_FAIBLE'];
|
||||
const indices = priorities.map((p: string) => order.indexOf(p));
|
||||
expect(indices).toEqual([...indices].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('sorts lowest priority first when sortPriority is asc', () => {
|
||||
(component as any).sortPriority = 'asc';
|
||||
const priorities = (component as any).filteredIssues.map((i: IssueEntity) => i.priority);
|
||||
const order = ['TRES_HAUTE', 'HAUTE', 'MOYENNE', 'BASSE', 'TRES_FAIBLE'];
|
||||
const indices = priorities.map((p: string) => order.indexOf(p));
|
||||
expect(indices).toEqual([...indices].sort((a, b) => b - a));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIssue', () => {
|
||||
it('navigates to /issues/new', () => {
|
||||
it('navigates to /projects/:pid/issues/new', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).createIssue();
|
||||
expect(spy).toHaveBeenCalledWith(['/issues/new']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 'new']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +421,7 @@ describe('Issues', () => {
|
||||
it('navigates to the issue detail page', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).openIssue(42);
|
||||
expect(spy).toHaveBeenCalledWith(['/issues', 42]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,9 +28,12 @@ describe('IssuesStore', () => {
|
||||
let store: IssuesStore;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const PROJECT_ID = 1;
|
||||
const ISSUES_URL = `${API_BASE_URL}/projects/${PROJECT_ID}/issues`;
|
||||
|
||||
const loadWith = async (issues: IssueEntity[]) => {
|
||||
const p = store.load();
|
||||
httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues);
|
||||
const p = store.load(PROJECT_ID);
|
||||
httpMock.expectOne(ISSUES_URL).flush(issues);
|
||||
await p;
|
||||
};
|
||||
|
||||
@@ -57,18 +60,28 @@ describe('IssuesStore', () => {
|
||||
});
|
||||
|
||||
it('sets loading to true during load and false after', async () => {
|
||||
const p = store.load();
|
||||
const p = store.load(PROJECT_ID);
|
||||
expect(store.loading()).toBe(true);
|
||||
httpMock.expectOne(`${API_BASE_URL}/issues`).flush([]);
|
||||
httpMock.expectOne(ISSUES_URL).flush([]);
|
||||
await p;
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.loaded()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reload if already loaded', async () => {
|
||||
it('does not reload if already loaded for the same project', async () => {
|
||||
await loadWith([]);
|
||||
await store.load();
|
||||
httpMock.expectNone(`${API_BASE_URL}/issues`);
|
||||
await store.load(PROJECT_ID);
|
||||
httpMock.expectNone(ISSUES_URL);
|
||||
});
|
||||
|
||||
it('reloads when projectId changes', async () => {
|
||||
await loadWith([makeIssue({ id: 1 })]);
|
||||
const url2 = `${API_BASE_URL}/projects/2/issues`;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +119,7 @@ describe('IssuesStore', () => {
|
||||
it('creates a new issue via POST when id is 0', async () => {
|
||||
const before = store.issues().length;
|
||||
const p = store.upsert(makeIssue({ id: 0, name: 'New Issue' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 999, name: 'New Issue' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 999, name: 'New Issue' }));
|
||||
await p;
|
||||
expect(store.issues().length).toBe(before + 1);
|
||||
expect(store.getById(999)?.name).toBe('New Issue');
|
||||
@@ -114,7 +127,7 @@ describe('IssuesStore', () => {
|
||||
|
||||
it('updates an existing issue via PUT', async () => {
|
||||
const p = store.upsert(makeIssue({ id: 1, name: 'Updated Name' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, name: 'Updated Name' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, name: 'Updated Name' }));
|
||||
await p;
|
||||
expect(store.getById(1)?.name).toBe('Updated Name');
|
||||
expect(store.issues().filter((i) => i.id === 1).length).toBe(1);
|
||||
@@ -123,7 +136,7 @@ describe('IssuesStore', () => {
|
||||
it('normalizes legacy dependsOnId to dependsOnIds array', async () => {
|
||||
const issue = { id: 0, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] }));
|
||||
await p;
|
||||
expect(store.getById(998)?.dependsOnIds).toEqual([1]);
|
||||
});
|
||||
@@ -131,7 +144,7 @@ describe('IssuesStore', () => {
|
||||
it('filters non-number values from dependsOnIds', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), dependsOnIds: [1, 'two', null] } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] }));
|
||||
await p;
|
||||
expect(store.getById(997)?.dependsOnIds).toEqual([1]);
|
||||
});
|
||||
@@ -139,7 +152,7 @@ describe('IssuesStore', () => {
|
||||
it('ensures comments is always an array when missing', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), comments: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 996, comments: [] }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 996, comments: [] }));
|
||||
await p;
|
||||
expect(store.getById(996)?.comments).toEqual([]);
|
||||
});
|
||||
@@ -147,7 +160,7 @@ describe('IssuesStore', () => {
|
||||
it('sets default type to Story when type is missing', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), type: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 995, type: 'Story' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 995, type: 'Story' }));
|
||||
await p;
|
||||
expect(store.getById(995)?.type).toBe('Story');
|
||||
});
|
||||
@@ -155,7 +168,7 @@ describe('IssuesStore', () => {
|
||||
it('sets estimatedTime to null when missing in API response', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), estimatedTime: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined });
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined });
|
||||
await p;
|
||||
expect(store.getById(994)?.estimatedTime).toBeNull();
|
||||
});
|
||||
@@ -163,7 +176,7 @@ describe('IssuesStore', () => {
|
||||
it('defaults startDate to empty string when missing in API response', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), startDate: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 993 }), startDate: undefined });
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush({ ...makeIssue({ id: 993 }), startDate: undefined });
|
||||
await p;
|
||||
expect(store.getById(993)?.startDate).toBe('');
|
||||
});
|
||||
@@ -171,7 +184,7 @@ describe('IssuesStore', () => {
|
||||
it('defaults endDate to empty string when missing in API response', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), endDate: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 992 }), endDate: undefined });
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush({ ...makeIssue({ id: 992 }), endDate: undefined });
|
||||
await p;
|
||||
expect(store.getById(992)?.endDate).toBe('');
|
||||
});
|
||||
@@ -179,7 +192,7 @@ describe('IssuesStore', () => {
|
||||
it('preserves startDate and endDate when provided', async () => {
|
||||
const issue = makeIssue({ id: 0, startDate: '2026-01-01', endDate: '2026-01-31' });
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 991, startDate: '2026-01-01', endDate: '2026-01-31' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues` }).flush(makeIssue({ id: 991, startDate: '2026-01-01', endDate: '2026-01-31' }));
|
||||
await p;
|
||||
expect(store.getById(991)?.startDate).toBe('2026-01-01');
|
||||
expect(store.getById(991)?.endDate).toBe('2026-01-31');
|
||||
@@ -189,7 +202,7 @@ describe('IssuesStore', () => {
|
||||
const issue = makeIssue({ id: 1, startDateMode: 'calculated' });
|
||||
const p = store.upsert(issue);
|
||||
const apiResponse = { ...makeIssue({ id: 1 }), startDateMode: undefined };
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(apiResponse);
|
||||
await p;
|
||||
expect(store.getById(1)?.startDateMode).toBe('calculated');
|
||||
});
|
||||
@@ -198,7 +211,7 @@ describe('IssuesStore', () => {
|
||||
const issueWithComment = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [2, 3] }] });
|
||||
const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: undefined as any }] });
|
||||
const p = store.upsert(issueWithComment);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(apiResponse);
|
||||
await p;
|
||||
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([2, 3]);
|
||||
});
|
||||
@@ -207,7 +220,7 @@ describe('IssuesStore', () => {
|
||||
const issue = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] });
|
||||
const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] });
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(apiResponse);
|
||||
await p;
|
||||
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([5]);
|
||||
});
|
||||
@@ -220,7 +233,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', endDate: '' }),
|
||||
]);
|
||||
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
await p;
|
||||
expect(store.getById(2)?.startDate).toBe('2026-06-10');
|
||||
});
|
||||
@@ -231,7 +244,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }),
|
||||
]);
|
||||
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
await p;
|
||||
expect(store.getById(2)?.startDate).toBe('2026-06-10');
|
||||
expect(store.getById(2)?.endDate).toBe('2026-06-11');
|
||||
@@ -243,7 +256,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }),
|
||||
]);
|
||||
const p = store.upsert(makeIssue({ id: 1, endDate: '' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' }));
|
||||
await p;
|
||||
expect(store.getById(2)?.startDate).toBe('');
|
||||
expect(store.getById(2)?.endDate).toBe('');
|
||||
@@ -256,7 +269,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [2], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }),
|
||||
]);
|
||||
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
await p;
|
||||
expect(store.getById(2)?.startDate).toBe('2026-06-10');
|
||||
expect(store.getById(2)?.endDate).toBe('2026-06-10');
|
||||
@@ -270,7 +283,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 2, startDateMode: 'forced', dependsOnIds: [1], startDate: '2026-05-01', endDate: '2026-05-15' }),
|
||||
]);
|
||||
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
await p;
|
||||
expect(store.getById(2)?.startDate).toBe('2026-05-01');
|
||||
expect(store.getById(2)?.endDate).toBe('2026-05-15');
|
||||
@@ -283,7 +296,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '', estimatedTime: 8, endDate: '' }),
|
||||
]);
|
||||
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
|
||||
await p;
|
||||
expect(store.getById(3)?.startDate).toBe('2026-06-10');
|
||||
expect(store.getById(3)?.endDate).toBe('2026-06-10');
|
||||
@@ -296,7 +309,7 @@ describe('IssuesStore', () => {
|
||||
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }),
|
||||
]);
|
||||
const p = store.deleteById(1);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(null);
|
||||
await p;
|
||||
expect(store.getById(3)?.startDate).toBe('2026-06-05');
|
||||
expect(store.getById(3)?.endDate).toBe('2026-06-05');
|
||||
@@ -314,21 +327,21 @@ describe('IssuesStore', () => {
|
||||
|
||||
it('removes the issue from the store', async () => {
|
||||
const p = store.deleteById(1);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(null);
|
||||
await p;
|
||||
expect(store.getById(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes the deleted id from dependsOnIds of other issues', async () => {
|
||||
const p = store.deleteById(100);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/100` }).flush(null);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/100` }).flush(null);
|
||||
await p;
|
||||
expect(store.getById(101)?.dependsOnIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not affect issues with unrelated dependsOnIds', async () => {
|
||||
const p = store.deleteById(1);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/projects/${PROJECT_ID}/issues/1` }).flush(null);
|
||||
await p;
|
||||
expect(store.getById(101)?.dependsOnIds).toEqual([100]);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ export type IssueEntity = {
|
||||
export class IssuesStore {
|
||||
private readonly api = inject(IssuesApiService);
|
||||
private readonly data = signal<IssueEntity[]>([]);
|
||||
private currentProjectId: number | null = null;
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly loaded = signal(false);
|
||||
@@ -51,11 +52,13 @@ export class IssuesStore {
|
||||
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded()) return;
|
||||
async load(projectId: number): Promise<void> {
|
||||
if (this.loaded() && this.currentProjectId === projectId) return;
|
||||
this.currentProjectId = projectId;
|
||||
this.loaded.set(false);
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const issues = await firstValueFrom(this.api.getAll());
|
||||
const issues = await firstValueFrom(this.api.getAll(projectId));
|
||||
this.data.set(issues.map((i) => this.normalizeIssue(i)));
|
||||
this.loaded.set(true);
|
||||
} finally {
|
||||
@@ -67,12 +70,12 @@ export class IssuesStore {
|
||||
const normalized = this.normalizeIssue(issue);
|
||||
if (!normalized.id) {
|
||||
const { id: _id, ...body } = normalized;
|
||||
const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
|
||||
const created = this.normalizeIssue(await firstValueFrom(this.api.create(this.currentProjectId!, body)));
|
||||
this.data.update((issues) => [...issues, created]);
|
||||
this.recalculateCalculatedIssues();
|
||||
return created;
|
||||
} else {
|
||||
const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized));
|
||||
const apiResult = await firstValueFrom(this.api.update(this.currentProjectId!, normalized.id, normalized));
|
||||
// L'API ne retourne pas linkedIssueIds dans les commentaires : on le restaure
|
||||
// depuis les données envoyées pour ne pas perdre les liens.
|
||||
if (Array.isArray(apiResult.comments) && Array.isArray(normalized.comments)) {
|
||||
@@ -100,7 +103,7 @@ export class IssuesStore {
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await firstValueFrom(this.api.remove(id));
|
||||
await firstValueFrom(this.api.remove(this.currentProjectId!, id));
|
||||
this.data.update((issues) =>
|
||||
issues
|
||||
.filter((i) => i.id !== id)
|
||||
|
||||
+71
-13
@@ -1,9 +1,11 @@
|
||||
import { Component, HostListener, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { IssueEntity, IssueStatus, IssuesStore } from './issues.store';
|
||||
import { IssueEntity, IssueStatus, IssuePriority, IssuesStore } from './issues.store';
|
||||
import { IssuesFilterService } from './issues-filter.service';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../statuses/statuses.store';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-issues',
|
||||
@@ -16,25 +18,51 @@ export class Issues {
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
private readonly projectContext = inject(ProjectContextService);
|
||||
private readonly filters = inject(IssuesFilterService);
|
||||
|
||||
constructor() {
|
||||
this.issuesStore.load();
|
||||
this.milestonesStore.load();
|
||||
const projectId = this.projectContext.projectId()!;
|
||||
this.issuesStore.load(projectId);
|
||||
this.milestonesStore.load(projectId);
|
||||
}
|
||||
|
||||
protected readonly issues = this.issuesStore.issues;
|
||||
protected readonly milestones = this.milestonesStore.milestones;
|
||||
protected searchQuery = '';
|
||||
protected selectedTypes = new Set<IssueEntity['type']>();
|
||||
protected selectedStatuses = new Set<IssueStatus>();
|
||||
protected selectedMilestoneIds = new Set<number>();
|
||||
protected showNoMilestone = false;
|
||||
protected openDropdown: 'type' | 'status' | 'milestone' | null = null;
|
||||
protected openDropdown: 'type' | 'status' | 'priority' | 'milestone' | null = null;
|
||||
|
||||
protected get searchQuery(): string { return this.filters.searchQuery; }
|
||||
protected set searchQuery(v: string) { this.filters.searchQuery = v; }
|
||||
|
||||
protected get selectedTypes(): Set<IssueEntity['type']> { return this.filters.selectedTypes; }
|
||||
protected set selectedTypes(v: Set<IssueEntity['type']>) { this.filters.selectedTypes = v; }
|
||||
|
||||
protected get selectedStatuses(): Set<IssueStatus> { return this.filters.selectedStatuses; }
|
||||
protected set selectedStatuses(v: Set<IssueStatus>) { this.filters.selectedStatuses = v; }
|
||||
|
||||
protected get selectedPriorities(): Set<IssuePriority> { return this.filters.selectedPriorities; }
|
||||
protected set selectedPriorities(v: Set<IssuePriority>) { this.filters.selectedPriorities = v; }
|
||||
|
||||
protected get selectedMilestoneIds(): Set<number> { return this.filters.selectedMilestoneIds; }
|
||||
protected set selectedMilestoneIds(v: Set<number>) { this.filters.selectedMilestoneIds = v; }
|
||||
|
||||
protected get showNoMilestone(): boolean { return this.filters.showNoMilestone; }
|
||||
protected set showNoMilestone(v: boolean) { this.filters.showNoMilestone = v; }
|
||||
|
||||
protected readonly typeOptions: IssueEntity['type'][] = [
|
||||
'Epic', 'Bug', 'Study', 'Story', 'Task', 'Technical Story',
|
||||
];
|
||||
|
||||
protected readonly priorityOptions: IssuePriority[] = [
|
||||
'TRES_HAUTE', 'HAUTE', 'MOYENNE', 'BASSE', 'TRES_FAIBLE',
|
||||
];
|
||||
|
||||
private readonly PRIORITY_ORDER: Record<IssuePriority, number> = {
|
||||
TRES_HAUTE: 0, HAUTE: 1, MOYENNE: 2, BASSE: 3, TRES_FAIBLE: 4,
|
||||
};
|
||||
|
||||
protected sortPriority: 'desc' | 'asc' | null = null;
|
||||
|
||||
protected readonly statusOptions = this.statusesStore.statuses;
|
||||
|
||||
protected getMilestoneForIssue(issueId: number): MilestoneEntity | undefined {
|
||||
@@ -44,9 +72,10 @@ export class Issues {
|
||||
protected get filteredIssues(): IssueEntity[] {
|
||||
const q = this.searchQuery.trim().toLowerCase();
|
||||
const milestoneActive = this.selectedMilestoneIds.size > 0 || this.showNoMilestone;
|
||||
return this.issues().filter((i) => {
|
||||
const filtered = this.issues().filter((i) => {
|
||||
if (this.selectedTypes.size > 0 && !this.selectedTypes.has(i.type)) return false;
|
||||
if (this.selectedStatuses.size > 0 && !this.selectedStatuses.has(i.status)) return false;
|
||||
if (this.selectedPriorities.size > 0 && !this.selectedPriorities.has(i.priority)) return false;
|
||||
if (milestoneActive) {
|
||||
const m = this.getMilestoneForIssue(i.id);
|
||||
const matchesMilestone = m !== undefined && this.selectedMilestoneIds.has(m.id);
|
||||
@@ -56,9 +85,20 @@ export class Issues {
|
||||
if (q && !i.name.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
if (this.sortPriority === null) return filtered;
|
||||
const dir = this.sortPriority === 'desc' ? 1 : -1;
|
||||
return [...filtered].sort(
|
||||
(a, b) => (this.PRIORITY_ORDER[a.priority] - this.PRIORITY_ORDER[b.priority]) * dir,
|
||||
);
|
||||
}
|
||||
|
||||
protected toggleDropdown(name: 'type' | 'status' | 'milestone', event: Event): void {
|
||||
protected toggleSortPriority(): void {
|
||||
if (this.sortPriority === null) this.sortPriority = 'desc';
|
||||
else if (this.sortPriority === 'desc') this.sortPriority = 'asc';
|
||||
else this.sortPriority = null;
|
||||
}
|
||||
|
||||
protected toggleDropdown(name: 'type' | 'status' | 'priority' | 'milestone', event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.openDropdown = this.openDropdown === name ? null : name;
|
||||
}
|
||||
@@ -92,6 +132,18 @@ export class Issues {
|
||||
this.selectedStatuses = new Set();
|
||||
}
|
||||
|
||||
protected togglePriority(priority: IssuePriority, event: Event): void {
|
||||
event.stopPropagation();
|
||||
const next = new Set(this.selectedPriorities);
|
||||
next.has(priority) ? next.delete(priority) : next.add(priority);
|
||||
this.selectedPriorities = next;
|
||||
}
|
||||
|
||||
protected clearPriorities(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.selectedPriorities = new Set();
|
||||
}
|
||||
|
||||
protected toggleMilestone(id: number, event: Event): void {
|
||||
event.stopPropagation();
|
||||
const next = new Set(this.selectedMilestoneIds);
|
||||
@@ -122,6 +174,12 @@ export class Issues {
|
||||
return `Statut (${this.selectedStatuses.size})`;
|
||||
}
|
||||
|
||||
protected priorityDropdownLabel(): string {
|
||||
if (this.selectedPriorities.size === 0) return 'Priorité';
|
||||
if (this.selectedPriorities.size === 1) return this.priorityDisplay([...this.selectedPriorities][0]).label;
|
||||
return `Priorité (${this.selectedPriorities.size})`;
|
||||
}
|
||||
|
||||
protected milestoneDropdownLabel(): string {
|
||||
const count = this.selectedMilestoneIds.size + (this.showNoMilestone ? 1 : 0);
|
||||
if (count === 0) return 'Milestone';
|
||||
@@ -138,11 +196,11 @@ export class Issues {
|
||||
}
|
||||
|
||||
protected createIssue(): void {
|
||||
this.router.navigate(['/issues/new']);
|
||||
this.router.navigate(['/projects', this.projectContext.projectId(), 'issues', 'new']);
|
||||
}
|
||||
|
||||
protected openIssue(issueId: number): void {
|
||||
this.router.navigate(['/issues', issueId]);
|
||||
this.router.navigate(['/projects', this.projectContext.projectId(), 'issues', issueId]);
|
||||
}
|
||||
|
||||
protected getProgress(issue: IssueEntity): number {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
position: relative;
|
||||
padding: 0 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -34,11 +35,120 @@
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sidebar-version-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
font-size: 0.72rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.sidebar-info-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
line-height: 1;
|
||||
color: #9ca3af;
|
||||
background: transparent;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-info-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.sidebar-info-btn--warn {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.sidebar-info-btn--warn:hover {
|
||||
background: #fde68a;
|
||||
color: #78350f;
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
.version-popover {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
top: calc(100% + 0.5rem);
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.version-popover-close {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-popover-close:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.version-popover-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.version-popover-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.version-popover-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.version-popover-value {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.version-popover-warn {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -124,3 +234,29 @@
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.sidebar-project-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-project-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #9ca3af;
|
||||
padding: 0.4rem 0.75rem 0.2rem;
|
||||
}
|
||||
|
||||
.sidebar-link--project {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
margin: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
+43
-3
@@ -25,21 +25,61 @@
|
||||
<circle cx="18" cy="16" r="6" fill="#48BB78"/>
|
||||
</svg>
|
||||
<span class="sidebar-logo-text">Bonsai</span>
|
||||
<span class="sidebar-version">v{{ version }}</span>
|
||||
<div class="sidebar-version-row">
|
||||
<button
|
||||
class="sidebar-info-btn"
|
||||
[class.sidebar-info-btn--warn]="versionMismatch()"
|
||||
(click)="toggleInfo()"
|
||||
type="button"
|
||||
aria-label="Informations de version"
|
||||
>i</button>
|
||||
</div>
|
||||
@if (showInfo()) {
|
||||
<div class="version-popover" role="dialog" aria-label="Versions">
|
||||
<button class="version-popover-close" (click)="closeInfo()" type="button" aria-label="Fermer">×</button>
|
||||
<div class="version-popover-title">Versions</div>
|
||||
<div class="version-popover-row">
|
||||
<span class="version-popover-label">Interface</span>
|
||||
<span class="version-popover-value">v{{ version }}</span>
|
||||
</div>
|
||||
<div class="version-popover-row">
|
||||
<span class="version-popover-label">API</span>
|
||||
<span class="version-popover-value">{{ apiVersion() !== null ? 'v' + apiVersion() : '...' }}</span>
|
||||
</div>
|
||||
@if (versionMismatch()) {
|
||||
<div class="version-popover-warn">Versions différentes</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (keycloak.isAuthenticated()) {
|
||||
<nav class="sidebar-nav">
|
||||
@for (item of menuItems; track item.path) {
|
||||
<a
|
||||
class="sidebar-link"
|
||||
[routerLink]="item.path"
|
||||
routerLink="/projects"
|
||||
routerLinkActive="sidebar-link--active"
|
||||
[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 }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-user">
|
||||
|
||||
+102
-21
@@ -1,28 +1,42 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { KeycloakService } from '../auth/keycloak.service';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
import { ProjectEntity } from '../projects/projects.store';
|
||||
import { VersionApiService } from '../version/version-api.service';
|
||||
import { Menu } from './menu';
|
||||
|
||||
describe('Menu', () => {
|
||||
let component: Menu;
|
||||
let fixture: ComponentFixture<Menu>;
|
||||
const keycloakMock = {
|
||||
isAuthenticated: signal(false),
|
||||
username: signal<string | undefined>(undefined),
|
||||
logout: vi.fn(),
|
||||
login: vi.fn(),
|
||||
};
|
||||
|
||||
const isAuthenticated = signal(false);
|
||||
const username = signal<string | undefined>(undefined);
|
||||
const projectId = signal<number | null>(null);
|
||||
const project = signal<ProjectEntity | null>(null);
|
||||
|
||||
const keycloakMock = { isAuthenticated, username, logout: vi.fn(), login: vi.fn() };
|
||||
const projectContextMock = { projectId, project };
|
||||
const versionApiMock = { getVersion: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
isAuthenticated.set(false);
|
||||
projectId.set(null);
|
||||
project.set(null);
|
||||
keycloakMock.logout = vi.fn();
|
||||
keycloakMock.login = vi.fn();
|
||||
versionApiMock.getVersion = vi.fn();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Menu],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: KeycloakService, useValue: keycloakMock },
|
||||
{ provide: ProjectContextService, useValue: projectContextMock },
|
||||
{ provide: VersionApiService, useValue: versionApiMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -35,28 +49,95 @@ describe('Menu', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have five menu items', () => {
|
||||
const items = (component as any).menuItems as { label: string; path: string }[];
|
||||
expect(items.length).toBe(6);
|
||||
describe('projectMenuItems', () => {
|
||||
it('returns empty array when no project is selected', () => {
|
||||
expect((component as any).projectMenuItems()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should contain Issues link', () => {
|
||||
const items = (component as any).menuItems as { label: string; path: string }[];
|
||||
expect(items.some((i) => i.path === '/issues')).toBe(true);
|
||||
it('returns dashboard, issues, milestones and statuts items when a project is selected', () => {
|
||||
projectId.set(5);
|
||||
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()', () => {
|
||||
(component as any).logout();
|
||||
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);
|
||||
});
|
||||
|
||||
describe('info button', () => {
|
||||
it('toggleInfo opens the popover and fetches API version', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
|
||||
|
||||
(component as any).toggleInfo();
|
||||
|
||||
expect((component as any).showInfo()).toBe(true);
|
||||
expect((component as any).apiVersion()).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('toggleInfo closes the popover when already open', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
|
||||
(component as any).toggleInfo();
|
||||
(component as any).toggleInfo();
|
||||
|
||||
expect((component as any).showInfo()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call getVersion again when popover is reopened', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
|
||||
(component as any).toggleInfo();
|
||||
(component as any).toggleInfo();
|
||||
(component as any).toggleInfo();
|
||||
|
||||
expect(versionApiMock.getVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets apiVersion to — on API error', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(throwError(() => new Error('network')));
|
||||
|
||||
(component as any).toggleInfo();
|
||||
|
||||
expect((component as any).apiVersion()).toBe('—');
|
||||
});
|
||||
|
||||
it('closeInfo hides the popover', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
|
||||
(component as any).toggleInfo();
|
||||
(component as any).closeInfo();
|
||||
|
||||
expect((component as any).showInfo()).toBe(false);
|
||||
});
|
||||
|
||||
it('versionMismatch is false when versions match', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(of({ version: (component as any).version }));
|
||||
(component as any).toggleInfo();
|
||||
|
||||
expect((component as any).versionMismatch()).toBe(false);
|
||||
});
|
||||
|
||||
it('versionMismatch is true when versions differ', () => {
|
||||
versionApiMock.getVersion.mockReturnValue(of({ version: '99.99.99' }));
|
||||
(component as any).toggleInfo();
|
||||
|
||||
expect((component as any).versionMismatch()).toBe(true);
|
||||
});
|
||||
|
||||
it('versionMismatch is false before API responds', () => {
|
||||
expect((component as any).versionMismatch()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+35
-8
@@ -1,7 +1,9 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { version } from '../../../package.json';
|
||||
import { KeycloakService } from '../auth/keycloak.service';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
import { VersionApiService } from '../version/version-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-menu',
|
||||
@@ -12,15 +14,40 @@ import { KeycloakService } from '../auth/keycloak.service';
|
||||
export class Menu {
|
||||
protected readonly version = version;
|
||||
protected readonly keycloak = inject(KeycloakService);
|
||||
protected readonly projectContext = inject(ProjectContextService);
|
||||
private readonly versionApi = inject(VersionApiService);
|
||||
|
||||
protected readonly menuItems = [
|
||||
{ label: 'Accueil', path: '/home' },
|
||||
{ label: 'Tableau de bord', path: '/dashboard' },
|
||||
{ label: 'Projet', path: '/project' },
|
||||
{ label: 'Issues', path: '/issues' },
|
||||
{ label: 'Milestones', path: '/milestones' },
|
||||
{ label: 'Statuts', path: '/statuses' },
|
||||
protected readonly showInfo = signal(false);
|
||||
protected readonly apiVersion = signal<string | null>(null);
|
||||
protected readonly versionMismatch = computed(() => {
|
||||
const api = this.apiVersion();
|
||||
return api !== null && api !== this.version;
|
||||
});
|
||||
|
||||
protected readonly projectMenuItems = computed(() => {
|
||||
const pid = this.projectContext.projectId();
|
||||
if (pid === null) return [];
|
||||
return [
|
||||
{ label: 'Tableau de bord', path: `/projects/${pid}/dashboard` },
|
||||
{ label: 'Issues', path: `/projects/${pid}/issues` },
|
||||
{ label: 'Milestones', path: `/projects/${pid}/milestones` },
|
||||
{ label: 'Statuts', path: `/projects/${pid}/statuses` },
|
||||
];
|
||||
});
|
||||
|
||||
protected toggleInfo(): void {
|
||||
if (!this.showInfo() && this.apiVersion() === null) {
|
||||
this.versionApi.getVersion().subscribe({
|
||||
next: (r) => this.apiVersion.set(r.version),
|
||||
error: () => this.apiVersion.set('—'),
|
||||
});
|
||||
}
|
||||
this.showInfo.update((v) => !v);
|
||||
}
|
||||
|
||||
protected closeInfo(): void {
|
||||
this.showInfo.set(false);
|
||||
}
|
||||
|
||||
protected logout(): void {
|
||||
this.keycloak.logout();
|
||||
|
||||
@@ -255,3 +255,35 @@
|
||||
.markdown-body > *:last-child {
|
||||
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>
|
||||
@if (moreMenuOpen) {
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
@@ -22,6 +24,40 @@
|
||||
</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 -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MilestoneDetail } from './milestone-detail';
|
||||
import { IssueEntity, IssuesStore } from '../../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones.store';
|
||||
import { StatusesStore } from '../../settings/statuses/statuses.store';
|
||||
import { ProjectContextService } from '../../projects/project-context.service';
|
||||
import { ProjectEntity, ProjectsStore } from '../../projects/projects.store';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 99,
|
||||
@@ -125,6 +127,11 @@ class FakeMilestonesStore {
|
||||
this._data.update((list) => list.filter((m) => m.id !== id));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
migrate(id: number, _targetProjectId: number): Promise<void> {
|
||||
this._data.update((list) => list.filter((m) => m.id !== id));
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function makeRoute(id = '1', path = 'milestones/:id') {
|
||||
@@ -149,6 +156,13 @@ describe('MilestoneDetail', () => {
|
||||
issuesStore = new FakeIssuesStore();
|
||||
milestonesStore = new FakeMilestonesStore();
|
||||
statusesStore = new FakeStatusesStore();
|
||||
const projectsStoreMock = {
|
||||
projects: signal<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({
|
||||
imports: [MilestoneDetail],
|
||||
providers: [
|
||||
@@ -157,6 +171,8 @@ describe('MilestoneDetail', () => {
|
||||
{ provide: IssuesStore, useValue: issuesStore },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: StatusesStore, useValue: statusesStore },
|
||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||
{ provide: ProjectsStore, useValue: projectsStoreMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -427,7 +443,7 @@ describe('MilestoneDetail', () => {
|
||||
it('navigates to the issue detail page', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).navigateToIssue(42);
|
||||
expect(spy).toHaveBeenCalledWith(['/issues', 42]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -643,18 +659,60 @@ describe('MilestoneDetail', () => {
|
||||
});
|
||||
|
||||
describe('deleteMilestone', () => {
|
||||
it('removes the milestone and navigates to /milestones', async () => {
|
||||
it('removes the milestone and navigates to /projects/:pid/milestones', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
await (component as any).deleteMilestone();
|
||||
expect(spy).toHaveBeenCalledWith(['/milestones']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('availableProjects excludes the current project', () => {
|
||||
const projects: ProjectEntity[] = (component as any).availableProjects;
|
||||
expect(projects.every((p: ProjectEntity) => p.id !== 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('availableProjects includes other projects', () => {
|
||||
const projects: ProjectEntity[] = (component as any).availableProjects;
|
||||
expect(projects.some((p: ProjectEntity) => p.id === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('openMigrate closes the more menu and shows the migration panel', () => {
|
||||
(component as any).moreMenuOpen = true;
|
||||
(component as any).openMigrate();
|
||||
expect((component as any).moreMenuOpen).toBe(false);
|
||||
expect((component as any).showMigratePanel).toBe(true);
|
||||
expect((component as any).selectedMigrateProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('cancelMigrate hides the migration panel', () => {
|
||||
(component as any).showMigratePanel = true;
|
||||
(component as any).selectedMigrateProjectId = 2;
|
||||
(component as any).cancelMigrate();
|
||||
expect((component as any).showMigratePanel).toBe(false);
|
||||
expect((component as any).selectedMigrateProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it('confirmMigrate does nothing when no project is selected', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).selectedMigrateProjectId = null;
|
||||
await (component as any).confirmMigrate();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirmMigrate migrates and navigates to the target project milestones', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).selectedMigrateProjectId = 2;
|
||||
await (component as any).confirmMigrate();
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 2, 'milestones']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelCreation', () => {
|
||||
it('navigates to /milestones', () => {
|
||||
it('navigates to /projects/:pid/milestones', () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).cancelCreation();
|
||||
expect(spy).toHaveBeenCalledWith(['/milestones']);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -795,6 +853,10 @@ describe('MilestoneDetail — new route', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
milestonesStore = new FakeMilestonesStore();
|
||||
const projectsStoreMock = {
|
||||
projects: signal<ProjectEntity[]>([]).asReadonly(),
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MilestoneDetail],
|
||||
providers: [
|
||||
@@ -811,6 +873,8 @@ describe('MilestoneDetail — new route', () => {
|
||||
},
|
||||
{ provide: IssuesStore, useValue: new FakeIssuesStore() },
|
||||
{ provide: MilestonesStore, useValue: milestonesStore },
|
||||
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
||||
{ provide: ProjectsStore, useValue: projectsStoreMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -844,6 +908,6 @@ describe('MilestoneDetail — new route', () => {
|
||||
milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]);
|
||||
(component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] });
|
||||
await (component as any).saveMilestone(true);
|
||||
expect(spy).toHaveBeenCalledWith(['/milestones', 1]);
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ import { marked } from 'marked';
|
||||
import { IssueEntity, IssuesStore } from '../../issues/issues.store';
|
||||
import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util';
|
||||
import { MilestoneEntity, MilestonesStore } from '../milestones.store';
|
||||
import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store';
|
||||
import { StatusEntity, StatusesStore } from '../../statuses/statuses.store';
|
||||
import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram';
|
||||
import { ProjectContextService } from '../../projects/project-context.service';
|
||||
import { ProjectEntity, ProjectsStore } from '../../projects/projects.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-milestone-detail',
|
||||
@@ -23,6 +25,12 @@ export class MilestoneDetail {
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
private readonly projectContext = inject(ProjectContextService);
|
||||
private readonly projectsStore = inject(ProjectsStore);
|
||||
|
||||
private get projectId(): number {
|
||||
return this.projectContext.projectId()!;
|
||||
}
|
||||
|
||||
protected readonly isNewRoute = this.route.snapshot.routeConfig?.path === 'milestones/new';
|
||||
protected milestone: MilestoneEntity = this.buildMilestone();
|
||||
@@ -44,18 +52,22 @@ export class MilestoneDetail {
|
||||
protected issueSearchQuery = '';
|
||||
protected showIssueSuggestions = false;
|
||||
protected moreMenuOpen = false;
|
||||
protected showMigratePanel = false;
|
||||
protected selectedMigrateProjectId: number | null = null;
|
||||
protected showAddDependency = false;
|
||||
protected selectedCandidateMilestoneId: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.milestonesStore.load().then(() => {
|
||||
const projectId = this.projectId;
|
||||
this.projectsStore.load();
|
||||
this.milestonesStore.load(projectId).then(() => {
|
||||
if (!this.isNewRoute) {
|
||||
const id = Number(this.route.snapshot.paramMap.get('id') ?? 0);
|
||||
const found = this.milestonesStore.getById(id);
|
||||
if (found) this.milestone = { ...found };
|
||||
}
|
||||
});
|
||||
this.issuesStore.load();
|
||||
this.issuesStore.load(projectId);
|
||||
|
||||
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const id = Number(params.get('id'));
|
||||
@@ -335,17 +347,17 @@ export class MilestoneDetail {
|
||||
const saved = await this.milestonesStore.upsert(this.milestone);
|
||||
this.milestone = { ...saved };
|
||||
if (this.isNewRoute) {
|
||||
this.router.navigate(['/milestones', saved.id]);
|
||||
this.router.navigate(['/projects', this.projectId, 'milestones', saved.id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected cancelCreation(): void {
|
||||
this.router.navigate(['/milestones']);
|
||||
this.router.navigate(['/projects', this.projectId, 'milestones']);
|
||||
}
|
||||
|
||||
protected async deleteMilestone(): Promise<void> {
|
||||
await this.milestonesStore.deleteById(this.milestone.id);
|
||||
this.router.navigate(['/milestones']);
|
||||
this.router.navigate(['/projects', this.projectId, 'milestones']);
|
||||
}
|
||||
|
||||
protected toggleMoreMenu(): void {
|
||||
@@ -356,8 +368,30 @@ export class MilestoneDetail {
|
||||
this.moreMenuOpen = false;
|
||||
}
|
||||
|
||||
protected get availableProjects(): ProjectEntity[] {
|
||||
return this.projectsStore.projects().filter((p) => p.id !== this.projectId);
|
||||
}
|
||||
|
||||
protected openMigrate(): void {
|
||||
this.moreMenuOpen = false;
|
||||
this.selectedMigrateProjectId = null;
|
||||
this.showMigratePanel = true;
|
||||
}
|
||||
|
||||
protected cancelMigrate(): void {
|
||||
this.showMigratePanel = false;
|
||||
this.selectedMigrateProjectId = null;
|
||||
}
|
||||
|
||||
protected async confirmMigrate(): Promise<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 {
|
||||
this.router.navigate(['/issues', id]);
|
||||
this.router.navigate(['/projects', this.projectId, 'issues', id]);
|
||||
}
|
||||
|
||||
private buildMilestone(): MilestoneEntity {
|
||||
|
||||
@@ -33,11 +33,11 @@ describe('MilestonesApiService', () => {
|
||||
afterEach(() => http.verify());
|
||||
|
||||
describe('getAll', () => {
|
||||
it('sends GET /api/milestones and returns milestones', () => {
|
||||
it('sends GET /api/projects/1/milestones and returns milestones', () => {
|
||||
const milestones = [makeMilestone({ id: 1 }), makeMilestone({ id: 2 })];
|
||||
let result: MilestoneEntity[] | undefined;
|
||||
service.getAll().subscribe((data) => (result = data));
|
||||
const req = http.expectOne(`${API}/milestones`);
|
||||
service.getAll(1).subscribe((data) => (result = data));
|
||||
const req = http.expectOne(`${API}/projects/1/milestones`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(milestones);
|
||||
expect(result).toEqual(milestones);
|
||||
@@ -45,12 +45,12 @@ describe('MilestonesApiService', () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('sends POST /api/milestones with the body and returns the created milestone', () => {
|
||||
it('sends POST /api/projects/1/milestones with the body and returns the created milestone', () => {
|
||||
const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] };
|
||||
const response = makeMilestone({ id: 2, name: 'Sprint 2' });
|
||||
let result: MilestoneEntity | undefined;
|
||||
service.create(body).subscribe((data) => (result = data));
|
||||
const req = http.expectOne(`${API}/milestones`);
|
||||
service.create(1, body).subscribe((data) => (result = data));
|
||||
const req = http.expectOne(`${API}/projects/1/milestones`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(body);
|
||||
req.flush(response);
|
||||
@@ -59,11 +59,11 @@ describe('MilestonesApiService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('sends PUT /api/milestones/:id with the body and returns the updated milestone', () => {
|
||||
it('sends PUT /api/projects/1/milestones/:id with the body and returns the updated milestone', () => {
|
||||
const milestone = makeMilestone({ id: 1, name: 'Updated Sprint' });
|
||||
let result: MilestoneEntity | undefined;
|
||||
service.update(1, milestone).subscribe((data) => (result = data));
|
||||
const req = http.expectOne(`${API}/milestones/1`);
|
||||
service.update(1, 1, milestone).subscribe((data) => (result = data));
|
||||
const req = http.expectOne(`${API}/projects/1/milestones/1`);
|
||||
expect(req.request.method).toBe('PUT');
|
||||
expect(req.request.body).toEqual(milestone);
|
||||
req.flush(milestone);
|
||||
@@ -72,13 +72,25 @@ describe('MilestonesApiService', () => {
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('sends DELETE /api/milestones/:id and completes', () => {
|
||||
it('sends DELETE /api/projects/1/milestones/:id and completes', () => {
|
||||
let completed = false;
|
||||
service.remove(1).subscribe({ complete: () => (completed = true) });
|
||||
const req = http.expectOne(`${API}/milestones/1`);
|
||||
service.remove(1, 1).subscribe({ complete: () => (completed = true) });
|
||||
const req = http.expectOne(`${API}/projects/1/milestones/1`);
|
||||
expect(req.request.method).toBe('DELETE');
|
||||
req.flush(null);
|
||||
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,19 +8,23 @@ import { MilestoneEntity } from './milestones.store';
|
||||
export class MilestonesApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getAll(): Observable<MilestoneEntity[]> {
|
||||
return this.http.get<MilestoneEntity[]>(`${API_BASE_URL}/milestones`);
|
||||
getAll(projectId: number): Observable<MilestoneEntity[]> {
|
||||
return this.http.get<MilestoneEntity[]>(`${API_BASE_URL}/projects/${projectId}/milestones`);
|
||||
}
|
||||
|
||||
create(milestone: Omit<MilestoneEntity, 'id'>): Observable<MilestoneEntity> {
|
||||
return this.http.post<MilestoneEntity>(`${API_BASE_URL}/milestones`, milestone);
|
||||
create(projectId: number, milestone: Omit<MilestoneEntity, 'id'>): Observable<MilestoneEntity> {
|
||||
return this.http.post<MilestoneEntity>(`${API_BASE_URL}/projects/${projectId}/milestones`, milestone);
|
||||
}
|
||||
|
||||
update(id: number, milestone: MilestoneEntity): Observable<MilestoneEntity> {
|
||||
return this.http.put<MilestoneEntity>(`${API_BASE_URL}/milestones/${id}`, milestone);
|
||||
update(projectId: number, id: number, milestone: MilestoneEntity): Observable<MilestoneEntity> {
|
||||
return this.http.put<MilestoneEntity>(`${API_BASE_URL}/projects/${projectId}/milestones/${id}`, milestone);
|
||||
}
|
||||
|
||||
remove(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${API_BASE_URL}/milestones/${id}`);
|
||||
remove(projectId: number, id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${API_BASE_URL}/projects/${projectId}/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 httpMock: HttpTestingController;
|
||||
|
||||
const PROJECT_ID = 1;
|
||||
const MILESTONES_URL = `${API}/projects/${PROJECT_ID}/milestones`;
|
||||
|
||||
const loadWith = async (milestones: MilestoneEntity[]) => {
|
||||
const p = store.load();
|
||||
httpMock.expectOne(`${API}/milestones`).flush(milestones);
|
||||
const p = store.load(PROJECT_ID);
|
||||
httpMock.expectOne(MILESTONES_URL).flush(milestones);
|
||||
await p;
|
||||
};
|
||||
|
||||
@@ -48,18 +51,28 @@ describe('MilestonesStore', () => {
|
||||
});
|
||||
|
||||
it('sets loading to true during load and false after', async () => {
|
||||
const p = store.load();
|
||||
const p = store.load(PROJECT_ID);
|
||||
expect(store.loading()).toBe(true);
|
||||
httpMock.expectOne(`${API}/milestones`).flush([]);
|
||||
httpMock.expectOne(MILESTONES_URL).flush([]);
|
||||
await p;
|
||||
expect(store.loading()).toBe(false);
|
||||
expect(store.loaded()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not reload if already loaded', async () => {
|
||||
it('does not reload if already loaded for the same project', async () => {
|
||||
await loadWith([]);
|
||||
await store.load();
|
||||
httpMock.expectNone(`${API}/milestones`);
|
||||
await store.load(PROJECT_ID);
|
||||
httpMock.expectNone(MILESTONES_URL);
|
||||
});
|
||||
|
||||
it('reloads when projectId changes', async () => {
|
||||
await loadWith([makeMilestone({ id: 1 })]);
|
||||
const url2 = `${API}/projects/2/milestones`;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +98,7 @@ describe('MilestonesStore', () => {
|
||||
it('creates a new milestone via POST when id is 0', async () => {
|
||||
const before = store.milestones().length;
|
||||
const p = store.upsert(makeMilestone({ id: 0, name: 'New Sprint' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API}/milestones` }).flush(makeMilestone({ id: 99, name: 'New Sprint' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API}/projects/${PROJECT_ID}/milestones` }).flush(makeMilestone({ id: 99, name: 'New Sprint' }));
|
||||
await p;
|
||||
expect(store.milestones().length).toBe(before + 1);
|
||||
expect(store.getById(99)?.name).toBe('New Sprint');
|
||||
@@ -93,7 +106,7 @@ describe('MilestonesStore', () => {
|
||||
|
||||
it('updates an existing milestone via PUT', async () => {
|
||||
const p = store.upsert(makeMilestone({ id: 1, name: 'Updated Sprint' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated Sprint' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated Sprint' }));
|
||||
await p;
|
||||
expect(store.getById(1)?.name).toBe('Updated Sprint');
|
||||
expect(store.milestones().filter((m) => m.id === 1).length).toBe(1);
|
||||
@@ -101,7 +114,7 @@ describe('MilestonesStore', () => {
|
||||
|
||||
it('returns the normalized milestone after update', async () => {
|
||||
const p = store.upsert(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush(makeMilestone({ id: 1, name: 'Updated', issueIds: [1, 2] }));
|
||||
const result = await p;
|
||||
expect(result.issueIds).toEqual([1, 2]);
|
||||
});
|
||||
@@ -109,7 +122,7 @@ describe('MilestonesStore', () => {
|
||||
it('leaves list unchanged when PUT response id is not found in store', async () => {
|
||||
const before = store.milestones().length;
|
||||
const p = store.upsert(makeMilestone({ id: 999, name: 'Unknown' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/999` }).flush(makeMilestone({ id: 999, name: 'Unknown' }));
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/999` }).flush(makeMilestone({ id: 999, name: 'Unknown' }));
|
||||
await p;
|
||||
expect(store.milestones().length).toBe(before);
|
||||
});
|
||||
@@ -122,7 +135,38 @@ describe('MilestonesStore', () => {
|
||||
|
||||
it('removes the milestone from the store', async () => {
|
||||
const p = store.deleteById(1);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API}/milestones/1` }).flush(null);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush(null);
|
||||
await p;
|
||||
expect(store.getById(1)).toBeUndefined();
|
||||
expect(store.milestones().length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -130,10 +174,14 @@ describe('MilestonesStore', () => {
|
||||
});
|
||||
|
||||
describe('normalize', () => {
|
||||
beforeEach(async () => {
|
||||
await loadWith([makeMilestone({ id: 1 })]);
|
||||
});
|
||||
|
||||
it('normalizes issueIds to empty array when not an array', async () => {
|
||||
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: null } as any;
|
||||
const p = store.upsert(raw);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, issueIds: null });
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ ...raw, issueIds: null });
|
||||
const result = await p;
|
||||
expect(result.issueIds).toEqual([]);
|
||||
});
|
||||
@@ -141,7 +189,7 @@ describe('MilestonesStore', () => {
|
||||
it('filters non-number values from issueIds', async () => {
|
||||
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1, 'bad', null] } as any;
|
||||
const p = store.upsert(raw);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1] });
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [1] });
|
||||
const result = await p;
|
||||
expect(result.issueIds).toEqual([1]);
|
||||
});
|
||||
@@ -149,7 +197,7 @@ describe('MilestonesStore', () => {
|
||||
it('normalizes dependsOnIds to empty array when not an array', async () => {
|
||||
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: null } as any;
|
||||
const p = store.upsert(raw);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, dependsOnIds: null });
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ ...raw, dependsOnIds: null });
|
||||
const result = await p;
|
||||
expect(result.dependsOnIds).toEqual([]);
|
||||
});
|
||||
@@ -157,7 +205,7 @@ describe('MilestonesStore', () => {
|
||||
it('filters non-number values from dependsOnIds', async () => {
|
||||
const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2, 'bad', null] } as any;
|
||||
const p = store.upsert(raw);
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2] });
|
||||
httpMock.expectOne({ method: 'PUT', url: `${API}/projects/${PROJECT_ID}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2] });
|
||||
const result = await p;
|
||||
expect(result.dependsOnIds).toEqual([2]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { catchError, firstValueFrom, of } from 'rxjs';
|
||||
import { MilestonesApiService } from './milestones-api.service';
|
||||
|
||||
export type MilestoneEntity = {
|
||||
@@ -17,6 +17,7 @@ export type MilestoneEntity = {
|
||||
export class MilestonesStore {
|
||||
private readonly api = inject(MilestonesApiService);
|
||||
private readonly data = signal<MilestoneEntity[]>([]);
|
||||
private currentProjectId: number | null = null;
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly loaded = signal(false);
|
||||
@@ -26,11 +27,13 @@ export class MilestonesStore {
|
||||
return this.data().find((m) => m.id === id);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded()) return;
|
||||
async load(projectId: number): Promise<void> {
|
||||
if (this.loaded() && this.currentProjectId === projectId) return;
|
||||
this.currentProjectId = projectId;
|
||||
this.loaded.set(false);
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const milestones = await firstValueFrom(this.api.getAll());
|
||||
const milestones = await firstValueFrom(this.api.getAll(projectId));
|
||||
this.data.set(milestones.map((m) => this.normalize(m)));
|
||||
this.loaded.set(true);
|
||||
} finally {
|
||||
@@ -42,12 +45,12 @@ export class MilestonesStore {
|
||||
const normalized = this.normalize(milestone);
|
||||
if (!normalized.id) {
|
||||
const { id: _id, ...body } = normalized;
|
||||
const created = this.normalize(await firstValueFrom(this.api.create(body)));
|
||||
const created = this.normalize(await firstValueFrom(this.api.create(this.currentProjectId!, body)));
|
||||
this.data.update((list) => [...list, created]);
|
||||
return created;
|
||||
} else {
|
||||
const updated = this.normalize(
|
||||
await firstValueFrom(this.api.update(normalized.id, normalized)),
|
||||
await firstValueFrom(this.api.update(this.currentProjectId!, normalized.id, normalized)),
|
||||
);
|
||||
this.data.update((list) => {
|
||||
const idx = list.findIndex((m) => m.id === normalized.id);
|
||||
@@ -61,7 +64,12 @@ export class MilestonesStore {
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<void> {
|
||||
await firstValueFrom(this.api.remove(id));
|
||||
await firstValueFrom(this.api.remove(this.currentProjectId!, id));
|
||||
this.data.update((list) => list.filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
async migrate(id: number, targetProjectId: number): Promise<void> {
|
||||
await firstValueFrom(this.api.migrate(id, targetProjectId).pipe(catchError(() => of(null))));
|
||||
this.data.update((list) => list.filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IssuesStore } from '../issues/issues.store';
|
||||
import { MilestoneEntity, MilestonesStore } from './milestones.store';
|
||||
import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram';
|
||||
import { StatusesStore } from '../statuses/statuses.store';
|
||||
import { ProjectContextService } from '../projects/project-context.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-milestones',
|
||||
@@ -17,10 +18,12 @@ export class Milestones {
|
||||
private readonly milestonesStore = inject(MilestonesStore);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly statusesStore = inject(StatusesStore);
|
||||
private readonly projectContext = inject(ProjectContextService);
|
||||
|
||||
constructor() {
|
||||
this.milestonesStore.load();
|
||||
this.issuesStore.load();
|
||||
const projectId = this.projectContext.projectId()!;
|
||||
this.milestonesStore.load(projectId);
|
||||
this.issuesStore.load(projectId);
|
||||
}
|
||||
|
||||
protected readonly milestones = this.milestonesStore.milestones;
|
||||
@@ -103,10 +106,10 @@ export class Milestones {
|
||||
}
|
||||
|
||||
protected createMilestone(): void {
|
||||
this.router.navigate(['/milestones/new']);
|
||||
this.router.navigate(['/projects', this.projectContext.projectId(), 'milestones', 'new']);
|
||||
}
|
||||
|
||||
protected openMilestone(id: number): void {
|
||||
this.router.navigate(['/milestones', id]);
|
||||
this.router.navigate(['/projects', this.projectContext.projectId(), 'milestones', id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
.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>
|
||||
<h1 class="h2 mb-2">Projets</h1>
|
||||
<p class="text-secondary mb-0">Vue d'ensemble des projets actifs.</p>
|
||||
<h1 class="projects-title">Projets</h1>
|
||||
<p class="projects-subtitle">Sélectionnez un projet pour accéder à ses Issues, Milestones et Tableau de bord.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" (click)="createProject()">
|
||||
Nouveau projet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Responsable</th>
|
||||
<th>Statut</th>
|
||||
<th>Progression</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (project of projects(); track project.id) {
|
||||
<tr>
|
||||
<td>{{ project.name }}</td>
|
||||
<td>{{ project.owner }}</td>
|
||||
<td>{{ project.status }}</td>
|
||||
<td>{{ project.progress }}%</td>
|
||||
</tr>
|
||||
@if (!showForm()) {
|
||||
<button class="btn-new-project" (click)="openForm()">+ Nouveau projet</button>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (showForm()) {
|
||||
<form class="project-form" (ngSubmit)="createProject()">
|
||||
<div class="project-form-title">Nouveau projet</div>
|
||||
<div class="project-form-fields">
|
||||
<div class="project-form-group">
|
||||
<label class="project-form-label" for="proj-name">Nom du projet *</label>
|
||||
<input
|
||||
id="proj-name"
|
||||
class="project-form-input"
|
||||
[(ngModel)]="newName"
|
||||
name="name"
|
||||
placeholder="Ex : Refonte Interface"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="project-form-group">
|
||||
<label class="project-form-label" for="proj-owner">Responsable</label>
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="projects-loading">Chargement des projets…</div>
|
||||
} @else if (projects().length === 0) {
|
||||
<div class="projects-empty">Aucun projet disponible. Créez votre premier projet.</div>
|
||||
} @else {
|
||||
<div class="projects-grid">
|
||||
@for (project of projects(); track project.id) {
|
||||
<button class="project-card" (click)="openProject(project)">
|
||||
<div class="project-card-top">
|
||||
<div class="project-avatar">{{ project.name.charAt(0).toUpperCase() }}</div>
|
||||
<span class="project-badge" [class]="statusClass(project.status)">{{ project.status }}</span>
|
||||
</div>
|
||||
<div class="project-name">{{ project.name }}</div>
|
||||
<div class="project-owner">{{ project.owner }}</div>
|
||||
<div class="project-progress-row">
|
||||
<div class="project-progress-bar">
|
||||
<div class="project-progress-fill" [style.width.%]="project.progress"></div>
|
||||
</div>
|
||||
<span class="project-progress-label">{{ project.progress }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { vi } from 'vitest';
|
||||
import { ProjectEntity, ProjectsStore } from './projects.store';
|
||||
import { Projects } from './projects';
|
||||
|
||||
|
||||
@Component({ standalone: true, template: '' })
|
||||
class StubRouteComponent {}
|
||||
|
||||
|
||||
const makeProject = (overrides: Partial<ProjectEntity> = {}): ProjectEntity => ({
|
||||
id: 1,
|
||||
name: 'Mon Projet',
|
||||
owner: 'Alice',
|
||||
status: 'Actif',
|
||||
progress: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Projects', () => {
|
||||
let component: 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 })),
|
||||
upsert: vi.fn().mockResolvedValue(makeProject({ id: 10 })),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
storeMock.load = vi.fn().mockResolvedValue(undefined);
|
||||
storeMock.createLocal = vi.fn((name: string, owner: string): ProjectEntity => makeProject({ id: 10, name, owner }));
|
||||
storeMock.upsert = vi.fn().mockResolvedValue(makeProject({ id: 10 }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Projects],
|
||||
providers: [
|
||||
provideRouter([{ path: 'projects/:id/dashboard', component: StubRouteComponent }]),
|
||||
{ provide: ProjectsStore, useValue: storeMock },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
fixture = TestBed.createComponent(Projects);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
@@ -19,32 +63,75 @@ describe('Projects', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have 3 default projects', () => {
|
||||
it('calls store.load() on construction', () => {
|
||||
expect(storeMock.load).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes projects from the store', () => {
|
||||
expect((component as any).projects().length).toBe(3);
|
||||
});
|
||||
|
||||
it('createProject adds a new project', () => {
|
||||
(component as any).createProject();
|
||||
expect((component as any).projects().length).toBe(4);
|
||||
describe('openProject', () => {
|
||||
it('navigates to /projects/:id/dashboard', () => {
|
||||
const spy = vi.spyOn(router, 'navigate');
|
||||
(component as any).openProject(makeProject({ id: 7 }));
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 7, 'dashboard']);
|
||||
});
|
||||
});
|
||||
|
||||
it('createProject increments the id each time', () => {
|
||||
(component as any).createProject();
|
||||
(component as any).createProject();
|
||||
const projects = (component as any).projects();
|
||||
expect(projects[3].id).toBe(4);
|
||||
expect(projects[4].id).toBe(5);
|
||||
describe('statusClass', () => {
|
||||
it('returns badge-actif for Actif', () => {
|
||||
expect((component as any).statusClass('Actif')).toBe('badge-actif');
|
||||
});
|
||||
|
||||
it('new project starts with Nouveau status', () => {
|
||||
(component as any).createProject();
|
||||
const newProject = (component as any).projects()[3];
|
||||
expect(newProject.status).toBe('Nouveau');
|
||||
it('returns badge-attente for En attente', () => {
|
||||
expect((component as any).statusClass('En attente')).toBe('badge-attente');
|
||||
});
|
||||
|
||||
it('new project starts with 0 progress', () => {
|
||||
(component as any).createProject();
|
||||
const newProject = (component as any).projects()[3];
|
||||
expect(newProject.progress).toBe(0);
|
||||
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.upsert with name, owner and defaults', async () => {
|
||||
(component as any).newName = 'Mon Projet';
|
||||
(component as any).newOwner = 'Bob';
|
||||
await (component as any).createProject();
|
||||
expect(storeMock.upsert).toHaveBeenCalledWith({
|
||||
id: 0, name: 'Mon Projet', owner: 'Bob', status: 'Nouveau', progress: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('createProject navigates to the new project dashboard', async () => {
|
||||
(component as any).newName = 'Test';
|
||||
(component as any).newOwner = '';
|
||||
const spy = vi.spyOn(router, 'navigate');
|
||||
await (component as any).createProject();
|
||||
expect(spy).toHaveBeenCalledWith(['/projects', 10, 'dashboard']);
|
||||
});
|
||||
|
||||
it('createProject does nothing when name is empty', async () => {
|
||||
(component as any).newName = ' ';
|
||||
const spy = vi.spyOn(router, 'navigate');
|
||||
await (component as any).createProject();
|
||||
expect(storeMock.upsert).not.toHaveBeenCalled();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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('preserves local fields when the API response omits owner/status/progress', async () => {
|
||||
const p = store.upsert(makeProject({ id: 0, name: 'Minimal', owner: 'Cedric', status: 'Nouveau', progress: 0 }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API}/projects` }).flush({ id: 100, name: 'Minimal' });
|
||||
await p;
|
||||
expect(store.getById(100)?.owner).toBe('Cedric');
|
||||
expect(store.getById(100)?.status).toBe('Nouveau');
|
||||
expect(store.getById(100)?.progress).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
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,77 @@
|
||||
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));
|
||||
const merged: ProjectEntity = { ...project, id: created.id };
|
||||
this.data.update((list) => [...list, merged]);
|
||||
return merged;
|
||||
} 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,59 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
|
||||
type Project = {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: string;
|
||||
status: 'Actif' | 'En attente' | 'Nouveau';
|
||||
progress: number;
|
||||
};
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ProjectEntity, ProjectsStore } from './projects.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects',
|
||||
imports: [],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './projects.html',
|
||||
styleUrl: './projects.css',
|
||||
})
|
||||
export class Projects {
|
||||
protected readonly projects = signal<Project[]>([
|
||||
{ id: 1, name: 'Refonte Interface', owner: 'Marie', status: 'Actif', progress: 70 },
|
||||
{ id: 2, name: 'API Inventaire', owner: 'Nabil', status: 'En attente', progress: 45 },
|
||||
{ id: 3, name: 'Pipeline CI', owner: 'Sonia', status: 'Actif', progress: 90 },
|
||||
]);
|
||||
private readonly router = inject(Router);
|
||||
protected readonly projectsStore = inject(ProjectsStore);
|
||||
|
||||
private nextId = 4;
|
||||
constructor() {
|
||||
this.projectsStore.load();
|
||||
}
|
||||
|
||||
protected createProject(): void {
|
||||
const newProject: Project = {
|
||||
id: this.nextId,
|
||||
name: `Nouveau projet ${this.nextId}`,
|
||||
owner: 'A definir',
|
||||
status: 'Nouveau',
|
||||
progress: 0,
|
||||
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 async createProject(): Promise<void> {
|
||||
const name = this.newName.trim();
|
||||
if (!name) return;
|
||||
const project = await this.projectsStore.upsert({
|
||||
id: 0, name, owner: this.newOwner.trim(), status: 'Nouveau', progress: 0,
|
||||
});
|
||||
this.showForm.set(false);
|
||||
this.router.navigate(['/projects', project.id, 'dashboard']);
|
||||
}
|
||||
|
||||
protected statusClass(status: ProjectEntity['status']): string {
|
||||
const map: Record<ProjectEntity['status'], string> = {
|
||||
Actif: 'badge-actif',
|
||||
'En attente': 'badge-attente',
|
||||
Nouveau: 'badge-nouveau',
|
||||
};
|
||||
|
||||
this.projects.update((currentProjects) => [...currentProjects, newProject]);
|
||||
this.nextId += 1;
|
||||
return map[status] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,5 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type StatusEntity = {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
color: string;
|
||||
order: number;
|
||||
countsAsCompleted: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_STATUSES: StatusEntity[] = [
|
||||
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
|
||||
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
|
||||
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false },
|
||||
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'bonsai_statuses';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StatusesStore {
|
||||
private readonly data = signal<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();
|
||||
}
|
||||
}
|
||||
// Ré-exporte le store unique de référence pour que tous les composants
|
||||
// qui importent depuis ce chemin obtiennent le même singleton Angular.
|
||||
export { StatusesStore } from '../../statuses/statuses.store';
|
||||
export type { StatusEntity } from '../../statuses/statuses.store';
|
||||
export { DEFAULT_STATUSES } from '../../statuses/statuses.store';
|
||||
|
||||
@@ -129,4 +129,47 @@ describe('StatusesStore', () => {
|
||||
expect(store.getById('todo')?.label).toBe('À FAIRE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('load (project-scoped)', () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem('bonsai_statuses_1');
|
||||
localStorage.removeItem('bonsai_statuses_2');
|
||||
});
|
||||
|
||||
it('loads default statuses when project has no saved statuses', () => {
|
||||
store.load(1);
|
||||
expect(store.statuses()).toEqual(DEFAULT_STATUSES);
|
||||
});
|
||||
|
||||
it('loads statuses from the project-specific key', () => {
|
||||
const custom = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0, countsAsCompleted: false }];
|
||||
localStorage.setItem('bonsai_statuses_1', JSON.stringify(custom));
|
||||
store.load(1);
|
||||
expect(store.statuses().length).toBe(1);
|
||||
expect(store.statuses()[0].id).toBe('custom');
|
||||
});
|
||||
|
||||
it('does not reload if the same projectId is requested again', () => {
|
||||
store.load(1);
|
||||
store.create({ id: 'added', label: 'ADD', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
store.load(1);
|
||||
expect(store.getById('added')).toBeDefined();
|
||||
});
|
||||
|
||||
it('reloads when switching to a different projectId', () => {
|
||||
store.load(1);
|
||||
store.create({ id: 'proj1-status', label: 'P1', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
store.load(2);
|
||||
expect(store.getById('proj1-status')).toBeUndefined();
|
||||
expect(store.statuses()).toEqual(DEFAULT_STATUSES);
|
||||
});
|
||||
|
||||
it('saves create() to the project-scoped key', () => {
|
||||
store.load(1);
|
||||
store.create({ id: 'scoped', label: 'SCOPED', bg: '#fff', color: '#000', countsAsCompleted: false });
|
||||
const stored = JSON.parse(localStorage.getItem('bonsai_statuses_1')!);
|
||||
expect(stored.some((s: { id: string }) => s.id === 'scoped')).toBe(true);
|
||||
expect(localStorage.getItem('bonsai_statuses')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,14 +16,21 @@ export const DEFAULT_STATUSES: StatusEntity[] = [
|
||||
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'bonsai_statuses';
|
||||
const BASE_STORAGE_KEY = 'bonsai_statuses';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StatusesStore {
|
||||
private readonly data = signal<StatusEntity[]>(this.loadFromStorage());
|
||||
private currentProjectId: number | null = null;
|
||||
private readonly data = signal<StatusEntity[]>(this.readFromStorage(null));
|
||||
|
||||
readonly statuses = this.data.asReadonly();
|
||||
|
||||
load(projectId: number): void {
|
||||
if (this.currentProjectId === projectId) return;
|
||||
this.currentProjectId = projectId;
|
||||
this.data.set(this.readFromStorage(projectId));
|
||||
}
|
||||
|
||||
getById(id: string): StatusEntity | undefined {
|
||||
return this.data().find((s) => s.id === id);
|
||||
}
|
||||
@@ -50,13 +57,17 @@ export class StatusesStore {
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
private saveToStorage(): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data()));
|
||||
private storageKey(projectId: number | null): string {
|
||||
return projectId != null ? `${BASE_STORAGE_KEY}_${projectId}` : BASE_STORAGE_KEY;
|
||||
}
|
||||
|
||||
private loadFromStorage(): StatusEntity[] {
|
||||
private saveToStorage(): void {
|
||||
localStorage.setItem(this.storageKey(this.currentProjectId), JSON.stringify(this.data()));
|
||||
}
|
||||
|
||||
private readFromStorage(projectId: number | null): StatusEntity[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(this.storageKey(projectId));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as StatusEntity[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { VersionApiService } from './version-api.service';
|
||||
|
||||
describe('VersionApiService', () => {
|
||||
let service: VersionApiService;
|
||||
let httpGetMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
httpGetMock = vi.fn();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
VersionApiService,
|
||||
{ provide: HttpClient, useValue: { get: httpGetMock } },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(VersionApiService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls GET /api/version and returns the response', () => {
|
||||
httpGetMock.mockReturnValue(of({ version: '1.2.3' }));
|
||||
|
||||
let result: any;
|
||||
service.getVersion().subscribe((r) => (result = r));
|
||||
|
||||
expect(httpGetMock).toHaveBeenCalledWith('/api/version');
|
||||
expect(result).toEqual({ version: '1.2.3' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
export interface VersionResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VersionApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getVersion(): Observable<VersionResponse> {
|
||||
return this.http.get<VersionResponse>(`${API_BASE_URL}/version`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user