This commit is contained in:
2026-05-24 09:27:01 +02:00
parent e946436a42
commit 14156a23fb
9 changed files with 154 additions and 150 deletions
+3
View File
@@ -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()),
],
};
+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}` } }));
}),
);
};
+9
View File
@@ -38,4 +38,13 @@ export class KeycloakService {
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;
}
}
}
@@ -39,14 +39,14 @@ export class IssueComments {
});
}
protected addComment(): void {
protected async addComment(): Promise<void> {
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<void> {
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<void> {
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) });
}
}
+31 -21
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.issuesStore.deleteById(this.issue.id);
this.router.navigate(['/issues']);
}
protected updateStatus(status: IssueEntity['status']): void {
protected async updateStatus(status: IssueEntity['status']): Promise<void> {
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 (
+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}`);
}
}
+44 -119
View File
@@ -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<IssueEntity[]>(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<IssueEntity[]>([]);
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<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 {
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<IssueEntity> {
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<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(
@@ -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));
}
}
+5 -4
View File
@@ -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 {