Merge pull request 'Release/0.1.5' (#4) from release/0.1.5 into main

Reviewed-on: Bonsai/Bonsai-webapp#4
This commit is contained in:
2026-05-24 15:13:24 +02:00
31 changed files with 1012 additions and 309 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npm list *)",
"Bash(./node_modules/.bin/ng test *)",
"Bash(npx ng *)",
"Bash(npm start *)"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitea
coverage
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
push:
branches:
- '**'
pull_request:
branches:
- main
jobs:
test:
name: Tests & couverture
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Node.js
uses: https://github.com/actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test -- --watch=false
+37
View File
@@ -0,0 +1,37 @@
name: Release
on:
push:
branches:
- main
jobs:
bump-version:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Bump patch version
run: npm version patch -m "chore: bump version to %s [skip ci]"
- name: Push version commit and tag
run: |
git push
git push --tags
+13
View File
@@ -0,0 +1,13 @@
# Stage 1 — Build Angular
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — Serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist/Bonsai-webapp/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+5
View File
@@ -23,6 +23,11 @@
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
} }
], ],
"styles": [ "styles": [
+12
View File
@@ -0,0 +1,12 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
}
+12 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "bonsai-webapp", "name": "bonsai-webapp",
"version": "0.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bonsai-webapp", "name": "bonsai-webapp",
"version": "0.0.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@angular/common": "^21.2.0", "@angular/common": "^21.2.0",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.0",
@@ -15,6 +15,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"keycloak-js": "^26.2.4",
"marked": "^18.0.4", "marked": "^18.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
@@ -6072,6 +6073,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/keycloak-js": {
"version": "26.2.4",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz",
"integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==",
"license": "Apache-2.0",
"workspaces": [
"test"
]
},
"node_modules/listr2": { "node_modules/listr2": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bonsai-webapp", "name": "bonsai-webapp",
"version": "0.0.0", "version": "0.1.5",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
@@ -18,6 +18,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"keycloak-js": "^26.2.4",
"marked": "^18.0.4", "marked": "^18.0.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
+10 -2
View File
@@ -1,8 +1,16 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { KeycloakService } from './auth/keycloak.service';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideAppInitializer(() => inject(KeycloakService).init()),
],
}; };
+5 -4
View File
@@ -3,14 +3,15 @@ import { Home } from './home/home';
import { IssueDetail } from './issues/issue-detail/issue-detail'; import { IssueDetail } from './issues/issue-detail/issue-detail';
import { Issues } from './issues/issues'; import { Issues } from './issues/issues';
import { Projects } from './projects/projects'; import { Projects } from './projects/projects';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'home' }, { path: '', pathMatch: 'full', redirectTo: 'home' },
{ path: 'home', component: Home }, { path: 'home', component: Home },
{ path: 'project', component: Projects }, { path: 'project', component: Projects, canActivate: [authGuard] },
{ path: 'projects', redirectTo: 'project' }, { path: 'projects', redirectTo: 'project' },
{ path: 'issues/new', component: IssueDetail }, { path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
{ path: 'issues/:id', component: IssueDetail }, { path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
{ path: 'issues', component: Issues }, { path: 'issues', component: Issues, canActivate: [authGuard] },
{ path: '**', redirectTo: 'home' }, { path: '**', redirectTo: 'home' },
]; ];
+47
View File
@@ -0,0 +1,47 @@
import { vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { authGuard } from './auth.guard';
import { KeycloakService } from './keycloak.service';
describe('authGuard', () => {
let mockKeycloak: { isLoggedIn: ReturnType<typeof vi.fn>; login: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockKeycloak = {
isLoggedIn: vi.fn().mockReturnValue(true),
login: vi.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [{ provide: KeycloakService, useValue: mockKeycloak }],
});
});
const runGuard = () =>
TestBed.runInInjectionContext(() =>
authGuard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot),
);
it('returns true when the user is logged in', () => {
mockKeycloak.isLoggedIn.mockReturnValue(true);
expect(runGuard()).toBe(true);
});
it('returns false when the user is not logged in', () => {
mockKeycloak.isLoggedIn.mockReturnValue(false);
expect(runGuard()).toBe(false);
});
it('calls login() when the user is not logged in', () => {
mockKeycloak.isLoggedIn.mockReturnValue(false);
runGuard();
expect(mockKeycloak.login).toHaveBeenCalled();
});
it('does not call login() when the user is already logged in', () => {
mockKeycloak.isLoggedIn.mockReturnValue(true);
runGuard();
expect(mockKeycloak.login).not.toHaveBeenCalled();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { KeycloakService } from './keycloak.service';
export const authGuard: CanActivateFn = () => {
const keycloak = inject(KeycloakService);
if (keycloak.isLoggedIn()) {
return true;
}
keycloak.login();
return false;
};
+55
View File
@@ -0,0 +1,55 @@
import { vi } from 'vitest';
import { HttpRequest } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of } from 'rxjs';
import { authInterceptor } from './auth.interceptor';
import { KeycloakService } from './keycloak.service';
import { API_BASE_URL } from '../issues/issues-api.service';
describe('authInterceptor', () => {
let mockKeycloak: { getToken: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockKeycloak = { getToken: vi.fn().mockResolvedValue('test-token') };
TestBed.configureTestingModule({
providers: [{ provide: KeycloakService, useValue: mockKeycloak }],
});
});
const intercept = (req: HttpRequest<unknown>) => {
const captured: HttpRequest<unknown>[] = [];
const next = vi.fn((r: HttpRequest<unknown>) => { captured.push(r); return of(null as any); });
const obs = TestBed.runInInjectionContext(() => authInterceptor(req, next as any));
return { obs, next, captured };
};
it('skips token logic for requests outside API_BASE_URL', () => {
const req = new HttpRequest('GET', 'http://other.example.com/data');
const { next } = intercept(req);
expect(mockKeycloak.getToken).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledWith(req);
});
it('calls getToken for requests to API_BASE_URL', () => {
const req = new HttpRequest('GET', `${API_BASE_URL}/issues`);
intercept(req);
expect(mockKeycloak.getToken).toHaveBeenCalled();
});
it('adds Authorization header when token is available', async () => {
mockKeycloak.getToken.mockResolvedValue('my-token');
const req = new HttpRequest('GET', `${API_BASE_URL}/issues`);
const { obs, captured } = intercept(req);
await firstValueFrom(obs);
expect(captured[0].headers.get('Authorization')).toBe('Bearer my-token');
});
it('forwards the request without Authorization header when token is undefined', async () => {
mockKeycloak.getToken.mockResolvedValue(undefined);
const req = new HttpRequest('GET', `${API_BASE_URL}/issues`);
const { obs, captured } = intercept(req);
await firstValueFrom(obs);
expect(captured[0].headers.has('Authorization')).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { from, switchMap } from 'rxjs';
import { KeycloakService } from './keycloak.service';
import { API_BASE_URL } from '../issues/issues-api.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.startsWith(API_BASE_URL)) {
return next(req);
}
const keycloak = inject(KeycloakService);
return from(keycloak.getToken()).pipe(
switchMap((token) => {
if (!token) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
}),
);
};
+137
View File
@@ -0,0 +1,137 @@
import { vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
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 (() => void) | undefined,
}));
vi.mock('keycloak-js', () => ({ default: vi.fn(function () { return mockKc; }) }));
describe('KeycloakService', () => {
let service: KeycloakService;
beforeEach(() => {
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;
vi.clearAllMocks();
mockKc.init.mockResolvedValue(true);
mockKc.login.mockResolvedValue(undefined);
mockKc.logout.mockResolvedValue(undefined);
mockKc.updateToken.mockResolvedValue(true);
TestBed.configureTestingModule({});
service = TestBed.inject(KeycloakService);
});
describe('init', () => {
it('sets isAuthenticated to true when authenticated', async () => {
await service.init();
expect(service.isAuthenticated()).toBe(true);
});
it('sets username from tokenParsed when authenticated', async () => {
await service.init();
expect(service.username()).toBe('testuser');
});
it('registers an onTokenExpired handler when authenticated', async () => {
await service.init();
expect(mockKc.onTokenExpired).toBeTypeOf('function');
});
it('onTokenExpired calls logout when updateToken fails', async () => {
mockKc.updateToken.mockRejectedValue(new Error('expired'));
await service.init();
await mockKc.onTokenExpired!();
expect(mockKc.logout).toHaveBeenCalled();
});
it('sets isAuthenticated to false when not authenticated', async () => {
mockKc.init.mockResolvedValue(false);
await service.init();
expect(service.isAuthenticated()).toBe(false);
});
it('leaves username undefined when not authenticated', async () => {
mockKc.init.mockResolvedValue(false);
await service.init();
expect(service.username()).toBeUndefined();
});
it('handles init failure gracefully without throwing', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockKc.init.mockRejectedValue(new Error('connection refused'));
await expect(service.init()).resolves.toBeUndefined();
spy.mockRestore();
});
it('logs an error when init throws', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockKc.init.mockRejectedValue(new Error('fail'));
await service.init();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('login', () => {
it('delegates to keycloak.login()', async () => {
await service.login();
expect(mockKc.login).toHaveBeenCalled();
});
});
describe('logout', () => {
it('delegates to keycloak.logout() with window.location.origin as redirectUri', async () => {
await service.logout();
expect(mockKc.logout).toHaveBeenCalledWith({ redirectUri: window.location.origin });
});
});
describe('isLoggedIn', () => {
it('returns true when keycloak.authenticated is true', () => {
mockKc.authenticated = true;
expect(service.isLoggedIn()).toBe(true);
});
it('returns false when keycloak.authenticated is false', () => {
mockKc.authenticated = false;
expect(service.isLoggedIn()).toBe(false);
});
it('returns false when keycloak.authenticated is undefined', () => {
mockKc.authenticated = undefined;
expect(service.isLoggedIn()).toBe(false);
});
});
describe('getToken', () => {
it('calls updateToken(30) and returns the token', async () => {
mockKc.token = 'fresh-token';
const token = await service.getToken();
expect(mockKc.updateToken).toHaveBeenCalledWith(30);
expect(token).toBe('fresh-token');
});
it('returns undefined when updateToken fails', async () => {
mockKc.updateToken.mockRejectedValue(new Error('session expired'));
const token = await service.getToken();
expect(token).toBeUndefined();
});
});
});
+50
View File
@@ -0,0 +1,50 @@
import { Injectable, signal } from '@angular/core';
import Keycloak from 'keycloak-js';
@Injectable({ providedIn: 'root' })
export class KeycloakService {
private readonly keycloak = new Keycloak({
url: 'https://auth.goutailler-olivier.com',
realm: 'bonsai',
clientId: 'bonsai-webapp',
});
readonly isAuthenticated = signal(false);
readonly username = signal<string | undefined>(undefined);
async init(): Promise<void> {
try {
const authenticated = await this.keycloak.init({
pkceMethod: 'S256',
});
this.isAuthenticated.set(authenticated);
if (authenticated) {
this.username.set(this.keycloak.tokenParsed?.['preferred_username']);
this.keycloak.onTokenExpired = () => this.keycloak.updateToken(30).catch(() => this.logout());
}
} catch {
console.error('Échec de l\'initialisation Keycloak');
}
}
login(): Promise<void> {
return this.keycloak.login();
}
logout(): Promise<void> {
return this.keycloak.logout({ redirectUri: window.location.origin });
}
isLoggedIn(): boolean {
return this.keycloak.authenticated ?? false;
}
async getToken(): Promise<string | undefined> {
try {
await this.keycloak.updateToken(30);
return this.keycloak.token;
} catch {
return undefined;
}
}
}
@@ -1,29 +1,92 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IssueComments } from './issue-comments'; import { IssueComments } from './issue-comments';
import { IssuesStore } from '../issues.store'; import { IssueEntity, IssuesStore } from '../issues.store';
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
id: 1,
type: 'Story',
assignee: '',
epic: '',
name: 'Test Issue',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'Moyenne',
status: 'draft',
progress: 0,
...overrides,
});
class FakeIssuesStore {
private _data = signal<IssueEntity[]>([makeIssue({ id: 1 })]);
readonly issues = this._data.asReadonly();
readonly loading = signal(false);
readonly loaded = signal(true);
getById(id: number): IssueEntity | undefined {
return this._data().find((i) => i.id === id);
}
getNextId(): number {
const ids = this._data().map((i) => i.id);
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
}
load(): Promise<void> {
return Promise.resolve();
}
upsert(issue: any): Promise<IssueEntity> {
const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue;
const normalized: IssueEntity = {
...makeIssue(),
...rest,
dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : [],
comments: Array.isArray(c) ? c : [],
estimatedTime: et ?? null,
};
this._data.update((issues) => {
const idx = issues.findIndex((i) => i.id === normalized.id);
if (idx === -1) return [...issues, normalized];
const copy = [...issues];
copy[idx] = normalized;
return copy;
});
return Promise.resolve(normalized);
}
deleteById(id: number): Promise<void> {
this._data.update((issues) =>
issues
.filter((i) => i.id !== id)
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
);
return Promise.resolve();
}
}
describe('IssueComments', () => { describe('IssueComments', () => {
let component: IssueComments; let component: IssueComments;
let fixture: ComponentFixture<IssueComments>; let fixture: ComponentFixture<IssueComments>;
let store: IssuesStore; let store: FakeIssuesStore;
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); store = new FakeIssuesStore();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [IssueComments], imports: [IssueComments],
providers: [{ provide: IssuesStore, useValue: store }],
}).compileComponents(); }).compileComponents();
store = TestBed.inject(IssuesStore);
fixture = TestBed.createComponent(IssueComments); fixture = TestBed.createComponent(IssueComments);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.componentRef.setInput('issueId', 1); fixture.componentRef.setInput('issueId', 1);
await fixture.whenStable(); await fixture.whenStable();
}); });
afterEach(() => {
localStorage.clear();
});
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
@@ -68,9 +131,9 @@ describe('IssueComments', () => {
expect(store.getById(1)?.comments[0].text).toBe('Test comment'); expect(store.getById(1)?.comments[0].text).toBe('Test comment');
}); });
it('clears newCommentText after adding', () => { it('clears newCommentText after adding', async () => {
(component as any).newCommentText = 'Some text'; (component as any).newCommentText = 'Some text';
(component as any).addComment(); await (component as any).addComment();
expect((component as any).newCommentText).toBe(''); expect((component as any).newCommentText).toBe('');
}); });
@@ -147,9 +210,9 @@ describe('IssueComments', () => {
expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull(); expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull();
}); });
it('resets editing state after saving', () => { it('resets editing state after saving', async () => {
(component as any).editingCommentText = 'Done'; (component as any).editingCommentText = 'Done';
(component as any).saveEditComment(); await (component as any).saveEditComment();
expect((component as any).editingCommentId).toBeNull(); expect((component as any).editingCommentId).toBeNull();
expect((component as any).editingCommentText).toBe(''); expect((component as any).editingCommentText).toBe('');
}); });
@@ -39,14 +39,14 @@ export class IssueComments {
}); });
} }
protected addComment(): void { protected async addComment(): Promise<void> {
const text = this.newCommentText.trim(); const text = this.newCommentText.trim();
if (!text) return; if (!text) return;
const issue = this.issuesStore.getById(this.issueId()); const issue = this.issuesStore.getById(this.issueId());
if (!issue) return; if (!issue) return;
const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1; const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1;
const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null }; const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null };
this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] }); await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
this.newCommentText = ''; this.newCommentText = '';
} }
@@ -55,7 +55,7 @@ export class IssueComments {
this.editingCommentText = comment.text; this.editingCommentText = comment.text;
} }
protected saveEditComment(): void { protected async saveEditComment(): Promise<void> {
const text = this.editingCommentText.trim(); const text = this.editingCommentText.trim();
if (!text || this.editingCommentId === null) return; if (!text || this.editingCommentId === null) return;
const issue = this.issuesStore.getById(this.issueId()); const issue = this.issuesStore.getById(this.issueId());
@@ -63,7 +63,7 @@ export class IssueComments {
const updatedComments = issue.comments.map((c) => const updatedComments = issue.comments.map((c) =>
c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c, c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c,
); );
this.issuesStore.upsert({ ...issue, comments: updatedComments }); await this.issuesStore.upsert({ ...issue, comments: updatedComments });
this.editingCommentId = null; this.editingCommentId = null;
this.editingCommentText = ''; this.editingCommentText = '';
} }
@@ -73,9 +73,9 @@ export class IssueComments {
this.editingCommentText = ''; this.editingCommentText = '';
} }
protected deleteComment(id: number): void { protected async deleteComment(id: number): Promise<void> {
const issue = this.issuesStore.getById(this.issueId()); const issue = this.issuesStore.getById(this.issueId());
if (!issue) return; if (!issue) return;
this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) }); await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
} }
} }
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
@@ -23,6 +24,63 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
...overrides, ...overrides,
}); });
class FakeIssuesStore {
private _data = signal<IssueEntity[]>([
makeIssue({ id: 1, name: 'Issue 1' }),
makeIssue({ id: 2, name: 'Issue 2' }),
makeIssue({ id: 3, name: 'Issue 3' }),
]);
readonly issues = this._data.asReadonly();
readonly loading = signal(false);
readonly loaded = signal(true);
getById(id: number): IssueEntity | undefined {
return this._data().find((i) => i.id === id);
}
getNextId(): number {
const ids = this._data().map((i) => i.id);
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
}
load(): Promise<void> {
return Promise.resolve();
}
upsert(issue: any): Promise<IssueEntity> {
const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue;
const normalized: IssueEntity = {
...makeIssue(),
...rest,
dependsOnIds: Array.isArray(deps)
? deps.filter((v: unknown) => typeof v === 'number')
: typeof legacy === 'number'
? [legacy]
: [],
comments: Array.isArray(c) ? c : [],
estimatedTime: et ?? null,
};
this._data.update((issues) => {
const idx = issues.findIndex((i) => i.id === normalized.id);
if (idx === -1) return [...issues, normalized];
const copy = [...issues];
copy[idx] = normalized;
return copy;
});
return Promise.resolve(normalized);
}
deleteById(id: number): Promise<void> {
this._data.update((issues) =>
issues
.filter((i) => i.id !== id)
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
);
return Promise.resolve();
}
}
function makeRoute(id = '1', path = 'issues/:id') { function makeRoute(id = '1', path = 'issues/:id') {
return { return {
snapshot: { snapshot: {
@@ -37,30 +95,26 @@ function makeRoute(id = '1', path = 'issues/:id') {
describe('IssueDetail — existing issue', () => { describe('IssueDetail — existing issue', () => {
let component: IssueDetail; let component: IssueDetail;
let fixture: ComponentFixture<IssueDetail>; let fixture: ComponentFixture<IssueDetail>;
let store: IssuesStore; let store: FakeIssuesStore;
let router: Router; let router: Router;
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); store = new FakeIssuesStore();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [IssueDetail], imports: [IssueDetail],
providers: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: ActivatedRoute, useValue: makeRoute('1') }, { provide: ActivatedRoute, useValue: makeRoute('1') },
{ provide: IssuesStore, useValue: store },
], ],
}).compileComponents(); }).compileComponents();
store = TestBed.inject(IssuesStore);
router = TestBed.inject(Router); router = TestBed.inject(Router);
fixture = TestBed.createComponent(IssueDetail); fixture = TestBed.createComponent(IssueDetail);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });
afterEach(() => {
localStorage.clear();
});
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
@@ -115,7 +169,7 @@ describe('IssueDetail — existing issue', () => {
describe('deleteIssue', () => { describe('deleteIssue', () => {
it('removes the issue and navigates to /issues', async () => { it('removes the issue and navigates to /issues', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).deleteIssue(); await (component as any).deleteIssue();
expect(store.getById(1)).toBeUndefined(); expect(store.getById(1)).toBeUndefined();
expect(spy).toHaveBeenCalledWith(['/issues']); expect(spy).toHaveBeenCalledWith(['/issues']);
}); });
@@ -169,9 +223,9 @@ describe('IssueDetail — existing issue', () => {
expect((component as any).selectedCandidateId).toBeNull(); expect((component as any).selectedCandidateId).toBeNull();
}); });
it('confirmAddDependency adds the selected id and saves', () => { it('confirmAddDependency adds the selected id and saves', async () => {
(component as any).selectedCandidateId = 2; (component as any).selectedCandidateId = 2;
(component as any).confirmAddDependency(); await (component as any).confirmAddDependency();
expect((component as any).issue.dependsOnIds).toContain(2); expect((component as any).issue.dependsOnIds).toContain(2);
expect(store.getById(1)?.dependsOnIds).toContain(2); expect(store.getById(1)?.dependsOnIds).toContain(2);
expect((component as any).showAddDependency).toBe(false); expect((component as any).showAddDependency).toBe(false);
@@ -370,9 +424,9 @@ describe('IssueDetail — existing issue', () => {
expect(created?.type).toBe('Story'); expect(created?.type).toBe('Story');
}); });
it('confirmCreateInEpic resets the form', () => { it('confirmCreateInEpic resets the form', async () => {
(component as any).newIssueName = 'Child Issue'; (component as any).newIssueName = 'Child Issue';
(component as any).confirmCreateInEpic(); await (component as any).confirmCreateInEpic();
expect((component as any).showCreateInEpic).toBe(false); expect((component as any).showCreateInEpic).toBe(false);
expect((component as any).newIssueName).toBe(''); expect((component as any).newIssueName).toBe('');
}); });
@@ -405,9 +459,9 @@ describe('IssueDetail — existing issue', () => {
expect((component as any).selectedEpicCandidateId).toBeNull(); expect((component as any).selectedEpicCandidateId).toBeNull();
}); });
it('confirmAddToEpic assigns the epic name to the selected issue', () => { it('confirmAddToEpic assigns the epic name to the selected issue', async () => {
(component as any).selectedEpicCandidateId = 2; (component as any).selectedEpicCandidateId = 2;
(component as any).confirmAddToEpic(); await (component as any).confirmAddToEpic();
expect(store.getById(2)?.epic).toBe('My Epic'); expect(store.getById(2)?.epic).toBe('My Epic');
expect((component as any).showAddToEpic).toBe(false); expect((component as any).showAddToEpic).toBe(false);
}); });
@@ -436,11 +490,11 @@ describe('IssueDetail — existing issue', () => {
describe('IssueDetail — new issue route', () => { describe('IssueDetail — new issue route', () => {
let component: IssueDetail; let component: IssueDetail;
let fixture: ComponentFixture<IssueDetail>; let fixture: ComponentFixture<IssueDetail>;
let store: IssuesStore; let store: FakeIssuesStore;
let router: Router; let router: Router;
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); store = new FakeIssuesStore();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [IssueDetail], imports: [IssueDetail],
providers: [ providers: [
@@ -456,20 +510,16 @@ describe('IssueDetail — new issue route', () => {
paramMap: of(convertToParamMap({})), paramMap: of(convertToParamMap({})),
}, },
}, },
{ provide: IssuesStore, useValue: store },
], ],
}).compileComponents(); }).compileComponents();
store = TestBed.inject(IssuesStore);
router = TestBed.inject(Router); router = TestBed.inject(Router);
fixture = TestBed.createComponent(IssueDetail); fixture = TestBed.createComponent(IssueDetail);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });
afterEach(() => {
localStorage.clear();
});
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
@@ -486,14 +536,14 @@ describe('IssueDetail — new issue route', () => {
it('saveIssue without explicit flag does nothing for new route', () => { it('saveIssue without explicit flag does nothing for new route', () => {
(component as any).issue.name = 'Draft Name'; (component as any).issue.name = 'Draft Name';
const countBefore = store.issues().length; const countBefore = store.issues().length;
(component as any).saveIssue(); // explicit = false (component as any).saveIssue();
expect(store.issues().length).toBe(countBefore); expect(store.issues().length).toBe(countBefore);
}); });
it('saveIssue with explicit=true creates the issue and navigates', async () => { it('saveIssue with explicit=true creates the issue and navigates', async () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).issue.name = 'Brand New Issue'; (component as any).issue.name = 'Brand New Issue';
(component as any).saveIssue(true); await (component as any).saveIssue(true);
expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true); expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true);
expect(spy).toHaveBeenCalledWith(['/issues', 10]); expect(spy).toHaveBeenCalledWith(['/issues', 10]);
}); });
+32 -21
View File
@@ -25,6 +25,16 @@ export class IssueDetail {
protected moreMenuOpen = false; protected moreMenuOpen = false;
constructor() { constructor() {
const idParam = this.route.snapshot.paramMap.get('id');
const safeId = Number(idParam ?? 0);
this.issuesStore.load().then(() => {
if (safeId) {
const found = this.issuesStore.getById(safeId);
if (found) this.issue = { ...found };
}
});
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => { this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const id = Number(params.get('id')); const id = Number(params.get('id'));
if (!id || isNaN(id)) return; if (!id || isNaN(id)) return;
@@ -38,6 +48,7 @@ export class IssueDetail {
} }
}); });
} }
protected showAddDependency = false; protected showAddDependency = false;
protected selectedCandidateId: number | null = null; protected selectedCandidateId: number | null = null;
protected editingDescription = false; protected editingDescription = false;
@@ -86,18 +97,18 @@ export class IssueDetail {
this.selectedCandidateId = null; this.selectedCandidateId = null;
} }
protected confirmAddDependency(): void { protected async confirmAddDependency(): Promise<void> {
if (this.selectedCandidateId !== null) { if (this.selectedCandidateId !== null) {
this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId]; this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId];
this.saveIssue(); await this.saveIssue();
} }
this.showAddDependency = false; this.showAddDependency = false;
this.selectedCandidateId = null; this.selectedCandidateId = null;
} }
protected removeDependency(id: number): void { protected async removeDependency(id: number): Promise<void> {
this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id); this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id);
this.saveIssue(); await this.saveIssue();
} }
protected get estimatedTimeValue(): number | null { protected get estimatedTimeValue(): number | null {
@@ -146,11 +157,11 @@ export class IssueDetail {
this.newIssueName = ''; this.newIssueName = '';
} }
protected confirmCreateInEpic(): void { protected async confirmCreateInEpic(): Promise<void> {
const name = this.newIssueName.trim(); const name = this.newIssueName.trim();
if (!name) return; if (!name) return;
this.issuesStore.upsert({ await this.issuesStore.upsert({
id: this.issuesStore.getNextId(), id: 0,
type: 'Story', type: 'Story',
assignee: '', assignee: '',
epic: this.issue.name, epic: this.issue.name,
@@ -178,11 +189,11 @@ export class IssueDetail {
this.selectedEpicCandidateId = null; this.selectedEpicCandidateId = null;
} }
protected confirmAddToEpic(): void { protected async confirmAddToEpic(): Promise<void> {
if (this.selectedEpicCandidateId !== null) { if (this.selectedEpicCandidateId !== null) {
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId); const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
if (target) { if (target) {
this.issuesStore.upsert({ ...target, epic: this.issue.name }); await this.issuesStore.upsert({ ...target, epic: this.issue.name });
} }
} }
this.showAddToEpic = false; this.showAddToEpic = false;
@@ -229,12 +240,13 @@ export class IssueDetail {
} }
} }
protected saveIssue(explicit = false): void { protected async saveIssue(explicit = false): Promise<void> {
if (this.isNewIssueRoute && !explicit) return; if (this.isNewIssueRoute && !explicit) return;
if (!this.issue.name.trim()) return; if (!this.issue.name.trim()) return;
this.issuesStore.upsert(this.issue); const saved = await this.issuesStore.upsert(this.issue);
this.issue = { ...saved };
if (this.isNewIssueRoute) { if (this.isNewIssueRoute) {
this.router.navigate(['/issues', this.issue.id]); this.router.navigate(['/issues', saved.id]);
} }
} }
@@ -242,14 +254,15 @@ export class IssueDetail {
this.router.navigate(['/issues']); this.router.navigate(['/issues']);
} }
protected deleteIssue(): void { protected async deleteIssue(): Promise<void> {
this.issuesStore.deleteById(this.issue.id); await this.issuesStore.deleteById(this.issue.id);
this.router.navigate(['/issues']); this.router.navigate(['/issues']);
} }
protected updateStatus(status: IssueEntity['status']): void { protected async updateStatus(status: IssueEntity['status']): Promise<void> {
this.issue.status = status; this.issue.status = status;
this.issuesStore.upsert(this.issue); const saved = await this.issuesStore.upsert(this.issue);
this.issue = { ...saved };
} }
protected toggleMoreMenu(): void { protected toggleMoreMenu(): void {
@@ -262,15 +275,12 @@ export class IssueDetail {
private buildIssue(): IssueEntity { private buildIssue(): IssueEntity {
const idParam = this.route.snapshot.paramMap.get('id'); const idParam = this.route.snapshot.paramMap.get('id');
const draftId = this.route.snapshot.queryParamMap.get('draftId');
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new'; const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
const resolvedId = Number(idParam ?? draftId ?? 0);
const safeId = Number.isNaN(resolvedId) ? 0 : resolvedId;
if (isNewIssueRoute) { if (isNewIssueRoute) {
const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0);
return { return {
id: safeId, id: draftId,
type: 'Story', type: 'Story',
assignee: '', assignee: '',
epic: '', epic: '',
@@ -286,6 +296,7 @@ export class IssueDetail {
}; };
} }
const safeId = Number(idParam ?? 0);
const existingIssue = this.issuesStore.getById(safeId); const existingIssue = this.issuesStore.getById(safeId);
return ( return (
+27
View File
@@ -0,0 +1,27 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { IssueEntity } from './issues.store';
export const API_BASE_URL = 'http://localhost:8080';
@Injectable({ providedIn: 'root' })
export class IssuesApiService {
private readonly http = inject(HttpClient);
getAll(): Observable<IssueEntity[]> {
return this.http.get<IssueEntity[]>(`${API_BASE_URL}/issues`);
}
create(issue: Omit<IssueEntity, 'id'>): Observable<IssueEntity> {
return this.http.post<IssueEntity>(`${API_BASE_URL}/issues`, issue);
}
update(id: number, issue: IssueEntity): Observable<IssueEntity> {
return this.http.put<IssueEntity>(`${API_BASE_URL}/issues/${id}`, issue);
}
remove(id: number): Observable<void> {
return this.http.delete<void>(`${API_BASE_URL}/issues/${id}`);
}
}
+74 -14
View File
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
@@ -22,30 +23,93 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
...overrides, ...overrides,
}); });
class FakeIssuesStore {
private _data = signal<IssueEntity[]>([
makeIssue({ id: 1, name: 'Issue 1', progress: 0 }),
makeIssue({ id: 2, name: 'Issue 2', progress: 0 }),
makeIssue({ id: 3, name: 'Issue 3', progress: 0 }),
]);
readonly issues = this._data.asReadonly();
readonly loading = signal(false);
readonly loaded = signal(true);
getById(id: number): IssueEntity | undefined {
return this._data().find((i) => i.id === id);
}
getNextId(): number {
const ids = this._data().map((i) => i.id);
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
}
load(): Promise<void> {
return Promise.resolve();
}
upsert(issue: any): Promise<IssueEntity> {
const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue;
const normalized: IssueEntity = {
type: 'Story',
assignee: '',
epic: '',
name: '',
dueDate: '',
description: '',
estimatedTime: et ?? null,
comments: Array.isArray(c) ? c : [],
priority: 'Moyenne',
status: 'draft',
progress: 0,
...rest,
dependsOnIds: Array.isArray(deps)
? deps.filter((v: unknown) => typeof v === 'number')
: typeof legacy === 'number'
? [legacy]
: [],
};
this._data.update((issues) => {
const idx = issues.findIndex((i) => i.id === normalized.id);
if (idx === -1) return [...issues, normalized];
const copy = [...issues];
copy[idx] = normalized;
return copy;
});
return Promise.resolve(normalized);
}
deleteById(id: number): Promise<void> {
this._data.update((issues) =>
issues
.filter((i) => i.id !== id)
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
);
return Promise.resolve();
}
}
describe('Issues', () => { describe('Issues', () => {
let component: Issues; let component: Issues;
let fixture: ComponentFixture<Issues>; let fixture: ComponentFixture<Issues>;
let store: IssuesStore; let store: FakeIssuesStore;
let router: Router; let router: Router;
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); store = new FakeIssuesStore();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [Issues], imports: [Issues],
providers: [provideRouter([])], providers: [
provideRouter([]),
{ provide: IssuesStore, useValue: store },
],
}).compileComponents(); }).compileComponents();
store = TestBed.inject(IssuesStore);
router = TestBed.inject(Router); router = TestBed.inject(Router);
fixture = TestBed.createComponent(Issues); fixture = TestBed.createComponent(Issues);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
}); });
afterEach(() => {
localStorage.clear();
});
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
@@ -65,7 +129,6 @@ describe('Issues', () => {
it('returns empty array when no issues match the selected type', () => { it('returns empty array when no issues match the selected type', () => {
(component as any).selectedType = 'Epic'; (component as any).selectedType = 'Epic';
const filtered: IssueEntity[] = (component as any).filteredIssues; const filtered: IssueEntity[] = (component as any).filteredIssues;
// Default store has no Epics, so this should be empty
expect(filtered.every((i) => i.type === 'Epic')).toBe(true); expect(filtered.every((i) => i.type === 'Epic')).toBe(true);
}); });
}); });
@@ -97,13 +160,10 @@ describe('Issues', () => {
}); });
describe('createIssue', () => { describe('createIssue', () => {
it('navigates to /issues/new with a draftId query param', async () => { it('navigates to /issues/new', () => {
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
(component as any).createIssue(); (component as any).createIssue();
expect(spy).toHaveBeenCalledWith( expect(spy).toHaveBeenCalledWith(['/issues/new']);
['/issues/new'],
expect.objectContaining({ queryParams: expect.objectContaining({ mode: 'edit' }) }),
);
}); });
}); });
+107 -88
View File
@@ -1,5 +1,8 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { IssueEntity, IssuesStore } from './issues.store'; import { IssueEntity, IssuesStore } from './issues.store';
import { API_BASE_URL } from './issues-api.service';
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
id: 99, id: 99,
@@ -20,29 +23,59 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
describe('IssuesStore', () => { describe('IssuesStore', () => {
let store: IssuesStore; let store: IssuesStore;
let httpMock: HttpTestingController;
const loadWith = async (issues: IssueEntity[]) => {
const p = store.load();
httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues);
await p;
};
beforeEach(() => { beforeEach(() => {
localStorage.clear(); TestBed.configureTestingModule({
TestBed.configureTestingModule({}); providers: [provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(IssuesStore); store = TestBed.inject(IssuesStore);
httpMock = TestBed.inject(HttpTestingController);
}); });
afterEach(() => { afterEach(() => {
localStorage.clear(); httpMock.verify();
}); });
it('should be created', () => { it('should be created', () => {
expect(store).toBeTruthy(); expect(store).toBeTruthy();
}); });
it('should load default issues when localStorage is empty', () => { describe('load', () => {
expect(store.issues().length).toBeGreaterThan(0); it('populates issues from the API', async () => {
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
expect(store.issues().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_BASE_URL}/issues`).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_BASE_URL}/issues`);
});
}); });
describe('getById', () => { describe('getById', () => {
beforeEach(async () => {
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
});
it('returns the issue with the given id', () => { it('returns the issue with the given id', () => {
const issue = store.getById(1); expect(store.getById(1)?.id).toBe(1);
expect(issue?.id).toBe(1);
}); });
it('returns undefined for an unknown id', () => { it('returns undefined for an unknown id', () => {
@@ -51,122 +84,108 @@ describe('IssuesStore', () => {
}); });
describe('getNextId', () => { describe('getNextId', () => {
it('returns max id + 1', () => { it('returns max id + 1', async () => {
const ids = store.issues().map((i) => i.id); await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 5 }), makeIssue({ id: 3 })]);
const expectedNext = Math.max(...ids) + 1; expect(store.getNextId()).toBe(6);
expect(store.getNextId()).toBe(expectedNext);
}); });
it('returns 1 when there are no issues', () => { it('returns 1 when there are no issues', async () => {
store.deleteById(1); await loadWith([]);
store.deleteById(2);
store.deleteById(3);
expect(store.getNextId()).toBe(1); expect(store.getNextId()).toBe(1);
}); });
}); });
describe('upsert', () => { describe('upsert', () => {
it('adds a new issue when the id does not exist', () => { beforeEach(async () => {
const before = store.issues().length; await loadWith([makeIssue({ id: 1, name: 'Existing' }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]);
store.upsert(makeIssue({ id: 999 }));
expect(store.issues().length).toBe(before + 1);
expect(store.getById(999)?.name).toBe('Test Issue');
}); });
it('updates an existing issue', () => { it('creates a new issue via POST when id is 0', async () => {
store.upsert(makeIssue({ id: 1, name: 'Updated Name' })); 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' }));
await p;
expect(store.issues().length).toBe(before + 1);
expect(store.getById(999)?.name).toBe('New Issue');
});
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' }));
await p;
expect(store.getById(1)?.name).toBe('Updated Name'); expect(store.getById(1)?.name).toBe('Updated Name');
expect(store.issues().filter((i) => i.id === 1).length).toBe(1); expect(store.issues().filter((i) => i.id === 1).length).toBe(1);
}); });
it('persists the issue list to localStorage', () => { it('normalizes legacy dependsOnId to dependsOnIds array', async () => {
store.upsert(makeIssue({ id: 999 })); const issue = { id: 0, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any;
const raw = localStorage.getItem('bonsai.issues'); const p = store.upsert(issue);
expect(raw).not.toBeNull(); httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] }));
const parsed = JSON.parse(raw!); await p;
expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(true);
});
it('normalizes legacy dependsOnId (single number) to dependsOnIds array when dependsOnIds is absent', () => {
// dependsOnIds must be omitted (not an array) for the legacy field to take effect
store.upsert({ id: 998, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any);
expect(store.getById(998)?.dependsOnIds).toEqual([1]); expect(store.getById(998)?.dependsOnIds).toEqual([1]);
}); });
it('filters non-number values from dependsOnIds', () => { it('filters non-number values from dependsOnIds', async () => {
store.upsert({ ...makeIssue({ id: 997 }), dependsOnIds: [1, 'two', null] } as any); 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] }));
await p;
expect(store.getById(997)?.dependsOnIds).toEqual([1]); expect(store.getById(997)?.dependsOnIds).toEqual([1]);
}); });
it('ensures comments is always an array when missing', () => { it('ensures comments is always an array when missing', async () => {
store.upsert({ ...makeIssue({ id: 996 }), comments: undefined } as any); 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: [] }));
await p;
expect(store.getById(996)?.comments).toEqual([]); expect(store.getById(996)?.comments).toEqual([]);
}); });
it('sets default type to Story when type is missing', () => { it('sets default type to Story when type is missing', async () => {
store.upsert({ ...makeIssue({ id: 995 }), type: undefined } as any); 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' }));
await p;
expect(store.getById(995)?.type).toBe('Story'); expect(store.getById(995)?.type).toBe('Story');
}); });
it('sets estimatedTime to null when missing', () => { it('sets estimatedTime to null when missing in API response', async () => {
store.upsert({ ...makeIssue({ id: 994 }), estimatedTime: undefined } as any); 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 });
await p;
expect(store.getById(994)?.estimatedTime).toBeNull(); expect(store.getById(994)?.estimatedTime).toBeNull();
}); });
}); });
describe('deleteById', () => { describe('deleteById', () => {
it('removes the issue from the store', () => { beforeEach(async () => {
store.upsert(makeIssue({ id: 999 })); await loadWith([
store.deleteById(999); makeIssue({ id: 1 }),
expect(store.getById(999)).toBeUndefined(); makeIssue({ id: 100 }),
makeIssue({ id: 101, dependsOnIds: [100] }),
]);
}); });
it('removes the deleted id from dependsOnIds of other issues', () => { it('removes the issue from the store', async () => {
store.upsert(makeIssue({ id: 100 })); const p = store.deleteById(1);
store.upsert(makeIssue({ id: 101, dependsOnIds: [100] })); httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
store.deleteById(100); 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);
await p;
expect(store.getById(101)?.dependsOnIds).toEqual([]); expect(store.getById(101)?.dependsOnIds).toEqual([]);
}); });
it('persists the updated list to localStorage', () => { it('does not affect issues with unrelated dependsOnIds', async () => {
store.upsert(makeIssue({ id: 999 })); const p = store.deleteById(1);
store.deleteById(999); httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
const raw = localStorage.getItem('bonsai.issues'); await p;
const parsed = JSON.parse(raw!); expect(store.getById(101)?.dependsOnIds).toEqual([100]);
expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(false);
});
});
describe('localStorage persistence', () => {
it('loads issues from localStorage on construction', () => {
const saved: IssueEntity[] = [makeIssue({ id: 42, name: 'From storage' })];
localStorage.setItem('bonsai.issues', JSON.stringify(saved));
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(IssuesStore);
expect(freshStore.getById(42)?.name).toBe('From storage');
});
it('falls back to defaults when localStorage contains invalid JSON', () => {
localStorage.setItem('bonsai.issues', 'not-valid-json');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(IssuesStore);
expect(freshStore.issues().length).toBeGreaterThan(0);
});
it('falls back to defaults when localStorage contains a non-array', () => {
localStorage.setItem('bonsai.issues', '{"key":"value"}');
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
const freshStore = TestBed.inject(IssuesStore);
expect(freshStore.issues().length).toBeGreaterThan(0);
}); });
}); });
}); });
+47 -117
View File
@@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
const ISSUES_STORAGE_KEY = 'bonsai.issues'; import { IssuesApiService } from './issues-api.service';
export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress'; export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress';
export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute'; export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute';
@@ -29,108 +29,65 @@ export type IssueEntity = {
progress: number; progress: number;
}; };
const DEFAULT_ISSUES: IssueEntity[] = [
{
id: 1,
type: 'Bug',
assignee: 'Marie',
epic: 'EPIC-UI',
name: 'Bug affichage menu mobile',
dueDate: '2026-06-10',
description: 'Corriger le comportement du menu sur petits ecrans.',
estimatedTime: 8,
dependsOnIds: [],
comments: [],
priority: 'Haute',
status: 'in-progress',
progress: 35,
},
{
id: 2,
type: 'Study',
assignee: 'Nabil',
epic: 'EPIC-FORM',
name: 'Erreur validation formulaire projet',
dueDate: '2026-06-12',
description: 'Fiabiliser les regles de validation du formulaire projet.',
estimatedTime: 16,
dependsOnIds: [],
comments: [],
priority: 'Moyenne',
status: 'todo',
progress: 20,
},
{
id: 3,
type: 'Story',
assignee: 'Sonia',
epic: 'EPIC-CONTENT',
name: 'Mise a jour message de bienvenue',
dueDate: '2026-06-18',
description: 'Mettre a jour le wording d accueil selon la charte produit.',
estimatedTime: 4,
dependsOnIds: [],
comments: [],
priority: 'Basse',
status: 'done',
progress: 100,
},
];
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class IssuesStore { export class IssuesStore {
private readonly data = signal<IssueEntity[]>(DEFAULT_ISSUES); private readonly api = inject(IssuesApiService);
private readonly data = signal<IssueEntity[]>([]);
constructor() {
const cachedIssues = this.readFromStorage();
if (cachedIssues) {
this.data.set(cachedIssues.map((issue) => this.normalizeIssue(issue)));
}
}
readonly loading = signal(false);
readonly loaded = signal(false);
readonly issues = this.data.asReadonly(); readonly issues = this.data.asReadonly();
getById(id: number): IssueEntity | undefined { getById(id: number): IssueEntity | undefined {
return this.data().find((issue) => issue.id === id); return this.data().find((i) => i.id === id);
} }
getNextId(): number { getNextId(): number {
const ids = this.data().map((issue) => issue.id); const ids = this.data().map((i) => i.id);
return ids.length > 0 ? Math.max(...ids) + 1 : 1; return ids.length === 0 ? 1 : Math.max(...ids) + 1;
} }
async load(): Promise<void> {
if (this.loaded()) return;
this.loading.set(true);
try {
const issues = await firstValueFrom(this.api.getAll());
this.data.set(issues.map((i) => this.normalizeIssue(i)));
this.loaded.set(true);
} finally {
this.loading.set(false);
}
}
upsert(issue: IssueEntity): void { async upsert(issue: IssueEntity): Promise<IssueEntity> {
const normalizedIssue = this.normalizeIssue(issue); const normalized = this.normalizeIssue(issue);
if (!normalized.id) {
this.data.update((issues) => { const { id: _id, ...body } = normalized;
const existingIndex = issues.findIndex((current) => current.id === issue.id); const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
this.data.update((issues) => [...issues, created]);
if (existingIndex === -1) {
const created = [...issues, normalizedIssue];
this.persistToStorage(created);
return created; return created;
} } else {
const updated = this.normalizeIssue(
const updated = [...issues]; await firstValueFrom(this.api.update(normalized.id, normalized)),
updated[existingIndex] = normalizedIssue; );
this.persistToStorage(updated);
return updated;
});
}
deleteById(id: number): void {
this.data.update((issues) => { this.data.update((issues) => {
const updated = issues const idx = issues.findIndex((i) => i.id === normalized.id);
.filter((issue) => issue.id !== id) if (idx === -1) return issues;
.map((issue) => ({ const copy = [...issues];
...issue, copy[idx] = updated;
dependsOnIds: issue.dependsOnIds.filter((dependencyId) => dependencyId !== id), return copy;
}));
this.persistToStorage(updated);
return updated;
}); });
return updated;
}
}
async deleteById(id: number): Promise<void> {
await firstValueFrom(this.api.remove(id));
this.data.update((issues) =>
issues
.filter((i) => i.id !== id)
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
);
} }
private normalizeIssue( private normalizeIssue(
@@ -149,31 +106,4 @@ export class IssuesStore {
comments: Array.isArray(issue.comments) ? issue.comments : [], comments: Array.isArray(issue.comments) ? issue.comments : [],
} as IssueEntity; } as IssueEntity;
} }
private readFromStorage(): IssueEntity[] | null {
if (typeof window === 'undefined') {
return null;
} }
const rawIssues = window.localStorage.getItem(ISSUES_STORAGE_KEY);
if (!rawIssues) {
return null;
}
try {
const parsed = JSON.parse(rawIssues);
return Array.isArray(parsed) ? (parsed as IssueEntity[]) : null;
} catch {
return null;
}
}
private persistToStorage(issues: IssueEntity[]): void {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(ISSUES_STORAGE_KEY, JSON.stringify(issues));
}
}
+5 -4
View File
@@ -12,6 +12,10 @@ export class Issues {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly issuesStore = inject(IssuesStore); private readonly issuesStore = inject(IssuesStore);
constructor() {
this.issuesStore.load();
}
protected readonly issues = this.issuesStore.issues; protected readonly issues = this.issuesStore.issues;
protected selectedType: IssueEntity['type'] | null = null; protected selectedType: IssueEntity['type'] | null = null;
@@ -29,10 +33,7 @@ export class Issues {
} }
protected createIssue(): void { protected createIssue(): void {
const nextId = this.issuesStore.getNextId(); this.router.navigate(['/issues/new']);
this.router.navigate(['/issues/new'], {
queryParams: { draftId: nextId, mode: 'edit' },
});
} }
protected openIssue(issueId: number): void { protected openIssue(issueId: number): void {
+61
View File
@@ -34,6 +34,11 @@
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.sidebar-version {
font-size: 0.72rem;
color: #9ca3af;
}
.sidebar-nav { .sidebar-nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -63,3 +68,59 @@
border-left-color: #2563eb; border-left-color: #2563eb;
font-weight: 600; font-weight: 600;
} }
.sidebar-login {
margin-top: auto;
width: 100%;
padding: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #ffffff;
background: #2563eb;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.1s;
}
.sidebar-login:hover {
background: #1d4ed8;
}
.sidebar-user {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem 0.75rem;
border-top: 1px solid #e5e7eb;
margin-top: auto;
}
.sidebar-user-name {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-logout {
width: 100%;
padding: 0.35rem 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.sidebar-logout:hover {
background: #fee2e2;
color: #dc2626;
border-color: #fca5a5;
}
+10
View File
@@ -25,8 +25,10 @@
<circle cx="18" cy="16" r="6" fill="#48BB78"/> <circle cx="18" cy="16" r="6" fill="#48BB78"/>
</svg> </svg>
<span class="sidebar-logo-text">Bonsai</span> <span class="sidebar-logo-text">Bonsai</span>
<span class="sidebar-version">v{{ version }}</span>
</div> </div>
@if (keycloak.isAuthenticated()) {
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@for (item of menuItems; track item.path) { @for (item of menuItems; track item.path) {
<a <a
@@ -39,4 +41,12 @@
</a> </a>
} }
</nav> </nav>
<div class="sidebar-user">
<span class="sidebar-user-name">{{ keycloak.username() }}</span>
<button class="sidebar-logout" (click)="logout()">Déconnexion</button>
</div>
} @else {
<button class="sidebar-login" (click)="keycloak.login()">Connexion</button>
}
</aside> </aside>
+10 -1
View File
@@ -1,5 +1,7 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../package.json';
import { KeycloakService } from '../auth/keycloak.service';
@Component({ @Component({
selector: 'app-menu', selector: 'app-menu',
@@ -8,9 +10,16 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
styleUrl: './menu.css', styleUrl: './menu.css',
}) })
export class Menu { export class Menu {
protected readonly version = version;
protected readonly keycloak = inject(KeycloakService);
protected readonly menuItems = [ protected readonly menuItems = [
{ label: 'Accueil', path: '/home' }, { label: 'Accueil', path: '/home' },
{ label: 'Projet', path: '/project' }, { label: 'Projet', path: '/project' },
{ label: 'Issues', path: '/issues' }, { label: 'Issues', path: '/issues' },
]; ];
protected logout(): void {
this.keycloak.logout();
}
} }
+8
View File
@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
+2 -1
View File
@@ -13,7 +13,8 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "preserve" "module": "preserve",
"resolveJsonModule": true
}, },
"angularCompilerOptions": { "angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false, "enableI18nLegacyMessageIdFormat": false,