diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1d72b43 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npm list *)", + "Bash(./node_modules/.bin/ng test *)", + "Bash(npx ng *)" + ] + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 164478f..3dbb622 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,13 +1,16 @@ import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { KeycloakService } from './auth/keycloak.service'; +import { authInterceptor } from './auth/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), + provideHttpClient(withInterceptors([authInterceptor])), provideAppInitializer(() => inject(KeycloakService).init()), ], }; diff --git a/src/app/auth/auth.interceptor.ts b/src/app/auth/auth.interceptor.ts new file mode 100644 index 0000000..dfd07e3 --- /dev/null +++ b/src/app/auth/auth.interceptor.ts @@ -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}` } })); + }), + ); +}; diff --git a/src/app/auth/keycloak.service.ts b/src/app/auth/keycloak.service.ts index 27d4294..30e56c4 100644 --- a/src/app/auth/keycloak.service.ts +++ b/src/app/auth/keycloak.service.ts @@ -38,4 +38,13 @@ export class KeycloakService { isLoggedIn(): boolean { return this.keycloak.authenticated ?? false; } + + async getToken(): Promise { + try { + await this.keycloak.updateToken(30); + return this.keycloak.token; + } catch { + return undefined; + } + } } diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index a916e52..a092319 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -39,14 +39,14 @@ export class IssueComments { }); } - protected addComment(): void { + protected async addComment(): Promise { const text = this.newCommentText.trim(); if (!text) return; const issue = this.issuesStore.getById(this.issueId()); if (!issue) return; const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1; 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 = ''; } @@ -55,7 +55,7 @@ export class IssueComments { this.editingCommentText = comment.text; } - protected saveEditComment(): void { + protected async saveEditComment(): Promise { const text = this.editingCommentText.trim(); if (!text || this.editingCommentId === null) return; const issue = this.issuesStore.getById(this.issueId()); @@ -63,7 +63,7 @@ export class IssueComments { const updatedComments = issue.comments.map((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.editingCommentText = ''; } @@ -73,9 +73,9 @@ export class IssueComments { this.editingCommentText = ''; } - protected deleteComment(id: number): void { + protected async deleteComment(id: number): Promise { const issue = this.issuesStore.getById(this.issueId()); 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) }); } } diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 0fac2d0..c37b335 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -25,6 +25,16 @@ export class IssueDetail { protected moreMenuOpen = false; 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) => { const id = Number(params.get('id')); if (!id || isNaN(id)) return; @@ -38,6 +48,7 @@ export class IssueDetail { } }); } + protected showAddDependency = false; protected selectedCandidateId: number | null = null; protected editingDescription = false; @@ -86,18 +97,18 @@ export class IssueDetail { this.selectedCandidateId = null; } - protected confirmAddDependency(): void { + protected async confirmAddDependency(): Promise { if (this.selectedCandidateId !== null) { this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId]; - this.saveIssue(); + await this.saveIssue(); } this.showAddDependency = false; this.selectedCandidateId = null; } - protected removeDependency(id: number): void { + protected async removeDependency(id: number): Promise { this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id); - this.saveIssue(); + await this.saveIssue(); } protected get estimatedTimeValue(): number | null { @@ -146,11 +157,11 @@ export class IssueDetail { this.newIssueName = ''; } - protected confirmCreateInEpic(): void { + protected async confirmCreateInEpic(): Promise { const name = this.newIssueName.trim(); if (!name) return; - this.issuesStore.upsert({ - id: this.issuesStore.getNextId(), + await this.issuesStore.upsert({ + id: 0, type: 'Story', assignee: '', epic: this.issue.name, @@ -178,11 +189,11 @@ export class IssueDetail { this.selectedEpicCandidateId = null; } - protected confirmAddToEpic(): void { + protected async confirmAddToEpic(): Promise { if (this.selectedEpicCandidateId !== null) { const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId); if (target) { - this.issuesStore.upsert({ ...target, epic: this.issue.name }); + await this.issuesStore.upsert({ ...target, epic: this.issue.name }); } } this.showAddToEpic = false; @@ -229,12 +240,13 @@ export class IssueDetail { } } - protected saveIssue(explicit = false): void { + protected async saveIssue(explicit = false): Promise { if (this.isNewIssueRoute && !explicit) 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) { - this.router.navigate(['/issues', this.issue.id]); + this.router.navigate(['/issues', saved.id]); } } @@ -242,14 +254,15 @@ export class IssueDetail { this.router.navigate(['/issues']); } - protected deleteIssue(): void { - this.issuesStore.deleteById(this.issue.id); + protected async deleteIssue(): Promise { + await this.issuesStore.deleteById(this.issue.id); this.router.navigate(['/issues']); } - protected updateStatus(status: IssueEntity['status']): void { + protected async updateStatus(status: IssueEntity['status']): Promise { this.issue.status = status; - this.issuesStore.upsert(this.issue); + const saved = await this.issuesStore.upsert(this.issue); + this.issue = { ...saved }; } protected toggleMoreMenu(): void { @@ -262,15 +275,11 @@ export class IssueDetail { private buildIssue(): IssueEntity { 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 resolvedId = Number(idParam ?? draftId ?? 0); - const safeId = Number.isNaN(resolvedId) ? 0 : resolvedId; - if (isNewIssueRoute) { return { - id: safeId, + id: 0, type: 'Story', assignee: '', epic: '', @@ -286,6 +295,7 @@ export class IssueDetail { }; } + const safeId = Number(idParam ?? 0); const existingIssue = this.issuesStore.getById(safeId); return ( diff --git a/src/app/issues/issues-api.service.ts b/src/app/issues/issues-api.service.ts new file mode 100644 index 0000000..c54c79b --- /dev/null +++ b/src/app/issues/issues-api.service.ts @@ -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 { + return this.http.get(`${API_BASE_URL}/issues`); + } + + create(issue: Omit): Observable { + return this.http.post(`${API_BASE_URL}/issues`, issue); + } + + update(id: number, issue: IssueEntity): Observable { + return this.http.put(`${API_BASE_URL}/issues/${id}`, issue); + } + + remove(id: number): Observable { + return this.http.delete(`${API_BASE_URL}/issues/${id}`); + } +} diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 908c3df..0899917 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -1,6 +1,6 @@ -import { Injectable, signal } from '@angular/core'; - -const ISSUES_STORAGE_KEY = 'bonsai.issues'; +import { Injectable, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { IssuesApiService } from './issues-api.service'; export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress'; export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute'; @@ -29,108 +29,60 @@ export type IssueEntity = { 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' }) export class IssuesStore { - private readonly data = signal(DEFAULT_ISSUES); - - constructor() { - const cachedIssues = this.readFromStorage(); - if (cachedIssues) { - this.data.set(cachedIssues.map((issue) => this.normalizeIssue(issue))); - } - } + private readonly api = inject(IssuesApiService); + private readonly data = signal([]); + readonly loading = signal(false); + readonly loaded = signal(false); readonly issues = this.data.asReadonly(); getById(id: number): IssueEntity | undefined { - return this.data().find((issue) => issue.id === id); + return this.data().find((i) => i.id === id); } - getNextId(): number { - const ids = this.data().map((issue) => issue.id); - return ids.length > 0 ? Math.max(...ids) + 1 : 1; + async load(): Promise { + 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 { - const normalizedIssue = this.normalizeIssue(issue); - - this.data.update((issues) => { - const existingIndex = issues.findIndex((current) => current.id === issue.id); - - if (existingIndex === -1) { - const created = [...issues, normalizedIssue]; - this.persistToStorage(created); - return created; - } - - const updated = [...issues]; - updated[existingIndex] = normalizedIssue; - this.persistToStorage(updated); + async upsert(issue: IssueEntity): Promise { + const normalized = this.normalizeIssue(issue); + if (!normalized.id) { + const { id: _id, ...body } = normalized; + const created = this.normalizeIssue(await firstValueFrom(this.api.create(body))); + this.data.update((issues) => [...issues, created]); + return created; + } else { + const updated = this.normalizeIssue( + await firstValueFrom(this.api.update(normalized.id, normalized)), + ); + this.data.update((issues) => { + const idx = issues.findIndex((i) => i.id === normalized.id); + if (idx === -1) return issues; + const copy = [...issues]; + copy[idx] = updated; + return copy; + }); return updated; - }); + } } - deleteById(id: number): void { - this.data.update((issues) => { - const updated = issues - .filter((issue) => issue.id !== id) - .map((issue) => ({ - ...issue, - dependsOnIds: issue.dependsOnIds.filter((dependencyId) => dependencyId !== id), - })); - - this.persistToStorage(updated); - return updated; - }); + async deleteById(id: number): Promise { + 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( @@ -149,31 +101,4 @@ export class IssuesStore { comments: Array.isArray(issue.comments) ? issue.comments : [], } 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)); - } } - diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index e5ee32d..4486d2f 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -12,6 +12,10 @@ export class Issues { private readonly router = inject(Router); private readonly issuesStore = inject(IssuesStore); + constructor() { + this.issuesStore.load(); + } + protected readonly issues = this.issuesStore.issues; protected selectedType: IssueEntity['type'] | null = null; @@ -29,10 +33,7 @@ export class Issues { } protected createIssue(): void { - const nextId = this.issuesStore.getNextId(); - this.router.navigate(['/issues/new'], { - queryParams: { draftId: nextId, mode: 'edit' }, - }); + this.router.navigate(['/issues/new']); } protected openIssue(issueId: number): void {