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:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitea
|
||||
coverage
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -23,6 +23,11 @@
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/assets",
|
||||
"output": "assets"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bonsai-webapp",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bonsai-webapp",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
"keycloak-js": "^26.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -6072,6 +6073,15 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bonsai-webapp",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.5",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
"keycloak-js": "^26.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
|
||||
+10
-2
@@ -1,8 +1,16 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
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)],
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideAppInitializer(() => inject(KeycloakService).init()),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,14 +3,15 @@ import { Home } from './home/home';
|
||||
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
||||
import { Issues } from './issues/issues';
|
||||
import { Projects } from './projects/projects';
|
||||
import { authGuard } from './auth/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'home' },
|
||||
{ path: 'home', component: Home },
|
||||
{ path: 'project', component: Projects },
|
||||
{ path: 'project', component: Projects, canActivate: [authGuard] },
|
||||
{ path: 'projects', redirectTo: 'project' },
|
||||
{ path: 'issues/new', component: IssueDetail },
|
||||
{ path: 'issues/:id', component: IssueDetail },
|
||||
{ path: 'issues', component: Issues },
|
||||
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
||||
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
|
||||
{ path: 'issues', component: Issues, canActivate: [authGuard] },
|
||||
{ path: '**', redirectTo: 'home' },
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}` } }));
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { 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', () => {
|
||||
let component: IssueComments;
|
||||
let fixture: ComponentFixture<IssueComments>;
|
||||
let store: IssuesStore;
|
||||
let store: FakeIssuesStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
store = new FakeIssuesStore();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IssueComments],
|
||||
providers: [{ provide: IssuesStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
store = TestBed.inject(IssuesStore);
|
||||
fixture = TestBed.createComponent(IssueComments);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('issueId', 1);
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -68,9 +131,9 @@ describe('IssueComments', () => {
|
||||
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).addComment();
|
||||
await (component as any).addComment();
|
||||
expect((component as any).newCommentText).toBe('');
|
||||
});
|
||||
|
||||
@@ -147,9 +210,9 @@ describe('IssueComments', () => {
|
||||
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).saveEditComment();
|
||||
await (component as any).saveEditComment();
|
||||
expect((component as any).editingCommentId).toBeNull();
|
||||
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();
|
||||
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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { provideRouter } from '@angular/router';
|
||||
@@ -23,6 +24,63 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
...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') {
|
||||
return {
|
||||
snapshot: {
|
||||
@@ -37,30 +95,26 @@ function makeRoute(id = '1', path = 'issues/:id') {
|
||||
describe('IssueDetail — existing issue', () => {
|
||||
let component: IssueDetail;
|
||||
let fixture: ComponentFixture<IssueDetail>;
|
||||
let store: IssuesStore;
|
||||
let store: FakeIssuesStore;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
store = new FakeIssuesStore();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IssueDetail],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
store = TestBed.inject(IssuesStore);
|
||||
router = TestBed.inject(Router);
|
||||
fixture = TestBed.createComponent(IssueDetail);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -115,7 +169,7 @@ describe('IssueDetail — existing issue', () => {
|
||||
describe('deleteIssue', () => {
|
||||
it('removes the issue and navigates to /issues', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(component as any).deleteIssue();
|
||||
await (component as any).deleteIssue();
|
||||
expect(store.getById(1)).toBeUndefined();
|
||||
expect(spy).toHaveBeenCalledWith(['/issues']);
|
||||
});
|
||||
@@ -169,9 +223,9 @@ describe('IssueDetail — existing issue', () => {
|
||||
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).confirmAddDependency();
|
||||
await (component as any).confirmAddDependency();
|
||||
expect((component as any).issue.dependsOnIds).toContain(2);
|
||||
expect(store.getById(1)?.dependsOnIds).toContain(2);
|
||||
expect((component as any).showAddDependency).toBe(false);
|
||||
@@ -370,9 +424,9 @@ describe('IssueDetail — existing issue', () => {
|
||||
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).confirmCreateInEpic();
|
||||
await (component as any).confirmCreateInEpic();
|
||||
expect((component as any).showCreateInEpic).toBe(false);
|
||||
expect((component as any).newIssueName).toBe('');
|
||||
});
|
||||
@@ -405,9 +459,9 @@ describe('IssueDetail — existing issue', () => {
|
||||
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).confirmAddToEpic();
|
||||
await (component as any).confirmAddToEpic();
|
||||
expect(store.getById(2)?.epic).toBe('My Epic');
|
||||
expect((component as any).showAddToEpic).toBe(false);
|
||||
});
|
||||
@@ -436,11 +490,11 @@ describe('IssueDetail — existing issue', () => {
|
||||
describe('IssueDetail — new issue route', () => {
|
||||
let component: IssueDetail;
|
||||
let fixture: ComponentFixture<IssueDetail>;
|
||||
let store: IssuesStore;
|
||||
let store: FakeIssuesStore;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
store = new FakeIssuesStore();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IssueDetail],
|
||||
providers: [
|
||||
@@ -456,20 +510,16 @@ describe('IssueDetail — new issue route', () => {
|
||||
paramMap: of(convertToParamMap({})),
|
||||
},
|
||||
},
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
store = TestBed.inject(IssuesStore);
|
||||
router = TestBed.inject(Router);
|
||||
fixture = TestBed.createComponent(IssueDetail);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -486,14 +536,14 @@ describe('IssueDetail — new issue route', () => {
|
||||
it('saveIssue without explicit flag does nothing for new route', () => {
|
||||
(component as any).issue.name = 'Draft Name';
|
||||
const countBefore = store.issues().length;
|
||||
(component as any).saveIssue(); // explicit = false
|
||||
(component as any).saveIssue();
|
||||
expect(store.issues().length).toBe(countBefore);
|
||||
});
|
||||
|
||||
it('saveIssue with explicit=true creates the issue and navigates', async () => {
|
||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
(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(spy).toHaveBeenCalledWith(['/issues', 10]);
|
||||
});
|
||||
|
||||
@@ -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,12 @@ 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) {
|
||||
const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0);
|
||||
return {
|
||||
id: safeId,
|
||||
id: draftId,
|
||||
type: 'Story',
|
||||
assignee: '',
|
||||
epic: '',
|
||||
@@ -286,6 +296,7 @@ export class IssueDetail {
|
||||
};
|
||||
}
|
||||
|
||||
const safeId = Number(idParam ?? 0);
|
||||
const existingIssue = this.issuesStore.getById(safeId);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { provideRouter } from '@angular/router';
|
||||
@@ -22,30 +23,93 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
...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', () => {
|
||||
let component: Issues;
|
||||
let fixture: ComponentFixture<Issues>;
|
||||
let store: IssuesStore;
|
||||
let store: FakeIssuesStore;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
store = new FakeIssuesStore();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Issues],
|
||||
providers: [provideRouter([])],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: IssuesStore, useValue: store },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
store = TestBed.inject(IssuesStore);
|
||||
router = TestBed.inject(Router);
|
||||
fixture = TestBed.createComponent(Issues);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -65,7 +129,6 @@ describe('Issues', () => {
|
||||
it('returns empty array when no issues match the selected type', () => {
|
||||
(component as any).selectedType = 'Epic';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -97,13 +160,10 @@ describe('Issues', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
(component as any).createIssue();
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
['/issues/new'],
|
||||
expect.objectContaining({ queryParams: expect.objectContaining({ mode: 'edit' }) }),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(['/issues/new']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { API_BASE_URL } from './issues-api.service';
|
||||
|
||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
id: 99,
|
||||
@@ -20,29 +23,59 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||
|
||||
describe('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(() => {
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({});
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
store = TestBed.inject(IssuesStore);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(store).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load default issues when localStorage is empty', () => {
|
||||
expect(store.issues().length).toBeGreaterThan(0);
|
||||
describe('load', () => {
|
||||
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', () => {
|
||||
beforeEach(async () => {
|
||||
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
|
||||
});
|
||||
|
||||
it('returns the issue with the given id', () => {
|
||||
const issue = store.getById(1);
|
||||
expect(issue?.id).toBe(1);
|
||||
expect(store.getById(1)?.id).toBe(1);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown id', () => {
|
||||
@@ -51,122 +84,108 @@ describe('IssuesStore', () => {
|
||||
});
|
||||
|
||||
describe('getNextId', () => {
|
||||
it('returns max id + 1', () => {
|
||||
const ids = store.issues().map((i) => i.id);
|
||||
const expectedNext = Math.max(...ids) + 1;
|
||||
expect(store.getNextId()).toBe(expectedNext);
|
||||
it('returns max id + 1', async () => {
|
||||
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 5 }), makeIssue({ id: 3 })]);
|
||||
expect(store.getNextId()).toBe(6);
|
||||
});
|
||||
|
||||
it('returns 1 when there are no issues', () => {
|
||||
store.deleteById(1);
|
||||
store.deleteById(2);
|
||||
store.deleteById(3);
|
||||
it('returns 1 when there are no issues', async () => {
|
||||
await loadWith([]);
|
||||
expect(store.getNextId()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert', () => {
|
||||
it('adds a new issue when the id does not exist', () => {
|
||||
const before = store.issues().length;
|
||||
store.upsert(makeIssue({ id: 999 }));
|
||||
expect(store.issues().length).toBe(before + 1);
|
||||
expect(store.getById(999)?.name).toBe('Test Issue');
|
||||
beforeEach(async () => {
|
||||
await loadWith([makeIssue({ id: 1, name: 'Existing' }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]);
|
||||
});
|
||||
|
||||
it('updates an existing issue', () => {
|
||||
store.upsert(makeIssue({ id: 1, name: 'Updated Name' }));
|
||||
it('creates a new issue via POST when id is 0', async () => {
|
||||
const before = store.issues().length;
|
||||
const p = store.upsert(makeIssue({ id: 0, name: 'New Issue' }));
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 999, name: 'New Issue' }));
|
||||
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.issues().filter((i) => i.id === 1).length).toBe(1);
|
||||
});
|
||||
|
||||
it('persists the issue list to localStorage', () => {
|
||||
store.upsert(makeIssue({ id: 999 }));
|
||||
const raw = localStorage.getItem('bonsai.issues');
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw!);
|
||||
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);
|
||||
it('normalizes legacy dependsOnId to dependsOnIds array', async () => {
|
||||
const issue = { id: 0, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] }));
|
||||
await p;
|
||||
expect(store.getById(998)?.dependsOnIds).toEqual([1]);
|
||||
});
|
||||
|
||||
it('filters non-number values from dependsOnIds', () => {
|
||||
store.upsert({ ...makeIssue({ id: 997 }), dependsOnIds: [1, 'two', null] } as any);
|
||||
it('filters non-number values from dependsOnIds', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), dependsOnIds: [1, 'two', null] } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] }));
|
||||
await p;
|
||||
expect(store.getById(997)?.dependsOnIds).toEqual([1]);
|
||||
});
|
||||
|
||||
it('ensures comments is always an array when missing', () => {
|
||||
store.upsert({ ...makeIssue({ id: 996 }), comments: undefined } as any);
|
||||
it('ensures comments is always an array when missing', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), comments: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 996, comments: [] }));
|
||||
await p;
|
||||
expect(store.getById(996)?.comments).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets default type to Story when type is missing', () => {
|
||||
store.upsert({ ...makeIssue({ id: 995 }), type: undefined } as any);
|
||||
it('sets default type to Story when type is missing', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), type: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 995, type: 'Story' }));
|
||||
await p;
|
||||
expect(store.getById(995)?.type).toBe('Story');
|
||||
});
|
||||
|
||||
it('sets estimatedTime to null when missing', () => {
|
||||
store.upsert({ ...makeIssue({ id: 994 }), estimatedTime: undefined } as any);
|
||||
it('sets estimatedTime to null when missing in API response', async () => {
|
||||
const issue = { ...makeIssue({ id: 0 }), estimatedTime: undefined } as any;
|
||||
const p = store.upsert(issue);
|
||||
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined });
|
||||
await p;
|
||||
expect(store.getById(994)?.estimatedTime).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteById', () => {
|
||||
it('removes the issue from the store', () => {
|
||||
store.upsert(makeIssue({ id: 999 }));
|
||||
store.deleteById(999);
|
||||
expect(store.getById(999)).toBeUndefined();
|
||||
beforeEach(async () => {
|
||||
await loadWith([
|
||||
makeIssue({ id: 1 }),
|
||||
makeIssue({ id: 100 }),
|
||||
makeIssue({ id: 101, dependsOnIds: [100] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes the deleted id from dependsOnIds of other issues', () => {
|
||||
store.upsert(makeIssue({ id: 100 }));
|
||||
store.upsert(makeIssue({ id: 101, dependsOnIds: [100] }));
|
||||
store.deleteById(100);
|
||||
it('removes the issue from the store', async () => {
|
||||
const p = store.deleteById(1);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||
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([]);
|
||||
});
|
||||
|
||||
it('persists the updated list to localStorage', () => {
|
||||
store.upsert(makeIssue({ id: 999 }));
|
||||
store.deleteById(999);
|
||||
const raw = localStorage.getItem('bonsai.issues');
|
||||
const parsed = JSON.parse(raw!);
|
||||
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);
|
||||
it('does not affect issues with unrelated dependsOnIds', async () => {
|
||||
const p = store.deleteById(1);
|
||||
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||
await p;
|
||||
expect(store.getById(101)?.dependsOnIds).toEqual([100]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+47
-117
@@ -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,65 @@ 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;
|
||||
const ids = this.data().map((i) => i.id);
|
||||
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 {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
const updated = [...issues];
|
||||
updated[existingIndex] = normalizedIssue;
|
||||
this.persistToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
deleteById(id: number): void {
|
||||
} else {
|
||||
const updated = this.normalizeIssue(
|
||||
await firstValueFrom(this.api.update(normalized.id, normalized)),
|
||||
);
|
||||
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;
|
||||
const idx = issues.findIndex((i) => i.id === normalized.id);
|
||||
if (idx === -1) return issues;
|
||||
const copy = [...issues];
|
||||
copy[idx] = updated;
|
||||
return copy;
|
||||
});
|
||||
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 +106,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
font-size: 0.72rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -63,3 +68,59 @@
|
||||
border-left-color: #2563eb;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
<circle cx="18" cy="16" r="6" fill="#48BB78"/>
|
||||
</svg>
|
||||
<span class="sidebar-logo-text">Bonsai</span>
|
||||
<span class="sidebar-version">v{{ version }}</span>
|
||||
</div>
|
||||
|
||||
@if (keycloak.isAuthenticated()) {
|
||||
<nav class="sidebar-nav">
|
||||
@for (item of menuItems; track item.path) {
|
||||
<a
|
||||
@@ -39,4 +41,12 @@
|
||||
</a>
|
||||
}
|
||||
</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>
|
||||
|
||||
+10
-1
@@ -1,5 +1,7 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { version } from '../../../package.json';
|
||||
import { KeycloakService } from '../auth/keycloak.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-menu',
|
||||
@@ -8,9 +10,16 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
styleUrl: './menu.css',
|
||||
})
|
||||
export class Menu {
|
||||
protected readonly version = version;
|
||||
protected readonly keycloak = inject(KeycloakService);
|
||||
|
||||
protected readonly menuItems = [
|
||||
{ label: 'Accueil', path: '/home' },
|
||||
{ label: 'Projet', path: '/project' },
|
||||
{ label: 'Issues', path: '/issues' },
|
||||
];
|
||||
|
||||
protected logout(): void {
|
||||
this.keycloak.logout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
"module": "preserve",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
|
||||
Reference in New Issue
Block a user