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": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public"
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets",
|
||||||
|
"output": "assets"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"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",
|
"name": "bonsai-webapp",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bonsai-webapp",
|
"name": "bonsai-webapp",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.0",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.0",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
|
"keycloak-js": "^26.2.4",
|
||||||
"marked": "^18.0.4",
|
"marked": "^18.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
@@ -6072,6 +6073,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/keycloak-js": {
|
||||||
|
"version": "26.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz",
|
||||||
|
"integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"workspaces": [
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/listr2": {
|
"node_modules/listr2": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bonsai-webapp",
|
"name": "bonsai-webapp",
|
||||||
"version": "0.0.0",
|
"version": "0.1.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
|
"keycloak-js": "^26.2.4",
|
||||||
"marked": "^18.0.4",
|
"marked": "^18.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
|
|||||||
+10
-2
@@ -1,8 +1,16 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, inject, provideBrowserGlobalErrorListeners, provideAppInitializer } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { KeycloakService } from './auth/keycloak.service';
|
||||||
|
import { authInterceptor } from './auth/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
provideAppInitializer(() => inject(KeycloakService).init()),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { Home } from './home/home';
|
|||||||
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
import { IssueDetail } from './issues/issue-detail/issue-detail';
|
||||||
import { Issues } from './issues/issues';
|
import { Issues } from './issues/issues';
|
||||||
import { Projects } from './projects/projects';
|
import { Projects } from './projects/projects';
|
||||||
|
import { authGuard } from './auth/auth.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', pathMatch: 'full', redirectTo: 'home' },
|
{ path: '', pathMatch: 'full', redirectTo: 'home' },
|
||||||
{ path: 'home', component: Home },
|
{ path: 'home', component: Home },
|
||||||
{ path: 'project', component: Projects },
|
{ path: 'project', component: Projects, canActivate: [authGuard] },
|
||||||
{ path: 'projects', redirectTo: 'project' },
|
{ path: 'projects', redirectTo: 'project' },
|
||||||
{ path: 'issues/new', component: IssueDetail },
|
{ path: 'issues/new', component: IssueDetail, canActivate: [authGuard] },
|
||||||
{ path: 'issues/:id', component: IssueDetail },
|
{ path: 'issues/:id', component: IssueDetail, canActivate: [authGuard] },
|
||||||
{ path: 'issues', component: Issues },
|
{ path: 'issues', component: Issues, canActivate: [authGuard] },
|
||||||
{ path: '**', redirectTo: 'home' },
|
{ path: '**', redirectTo: 'home' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { IssueComments } from './issue-comments';
|
import { IssueComments } from './issue-comments';
|
||||||
import { IssuesStore } from '../issues.store';
|
import { IssueEntity, IssuesStore } from '../issues.store';
|
||||||
|
|
||||||
|
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||||
|
id: 1,
|
||||||
|
type: 'Story',
|
||||||
|
assignee: '',
|
||||||
|
epic: '',
|
||||||
|
name: 'Test Issue',
|
||||||
|
dueDate: '',
|
||||||
|
description: '',
|
||||||
|
estimatedTime: null,
|
||||||
|
dependsOnIds: [],
|
||||||
|
comments: [],
|
||||||
|
priority: 'Moyenne',
|
||||||
|
status: 'draft',
|
||||||
|
progress: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeIssuesStore {
|
||||||
|
private _data = signal<IssueEntity[]>([makeIssue({ id: 1 })]);
|
||||||
|
|
||||||
|
readonly issues = this._data.asReadonly();
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly loaded = signal(true);
|
||||||
|
|
||||||
|
getById(id: number): IssueEntity | undefined {
|
||||||
|
return this._data().find((i) => i.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextId(): number {
|
||||||
|
const ids = this._data().map((i) => i.id);
|
||||||
|
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(issue: any): Promise<IssueEntity> {
|
||||||
|
const { comments: c, estimatedTime: et, dependsOnIds: deps, ...rest } = issue;
|
||||||
|
const normalized: IssueEntity = {
|
||||||
|
...makeIssue(),
|
||||||
|
...rest,
|
||||||
|
dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : [],
|
||||||
|
comments: Array.isArray(c) ? c : [],
|
||||||
|
estimatedTime: et ?? null,
|
||||||
|
};
|
||||||
|
this._data.update((issues) => {
|
||||||
|
const idx = issues.findIndex((i) => i.id === normalized.id);
|
||||||
|
if (idx === -1) return [...issues, normalized];
|
||||||
|
const copy = [...issues];
|
||||||
|
copy[idx] = normalized;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
return Promise.resolve(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteById(id: number): Promise<void> {
|
||||||
|
this._data.update((issues) =>
|
||||||
|
issues
|
||||||
|
.filter((i) => i.id !== id)
|
||||||
|
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('IssueComments', () => {
|
describe('IssueComments', () => {
|
||||||
let component: IssueComments;
|
let component: IssueComments;
|
||||||
let fixture: ComponentFixture<IssueComments>;
|
let fixture: ComponentFixture<IssueComments>;
|
||||||
let store: IssuesStore;
|
let store: FakeIssuesStore;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
store = new FakeIssuesStore();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [IssueComments],
|
imports: [IssueComments],
|
||||||
|
providers: [{ provide: IssuesStore, useValue: store }],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
store = TestBed.inject(IssuesStore);
|
|
||||||
fixture = TestBed.createComponent(IssueComments);
|
fixture = TestBed.createComponent(IssueComments);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.componentRef.setInput('issueId', 1);
|
fixture.componentRef.setInput('issueId', 1);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -68,9 +131,9 @@ describe('IssueComments', () => {
|
|||||||
expect(store.getById(1)?.comments[0].text).toBe('Test comment');
|
expect(store.getById(1)?.comments[0].text).toBe('Test comment');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears newCommentText after adding', () => {
|
it('clears newCommentText after adding', async () => {
|
||||||
(component as any).newCommentText = 'Some text';
|
(component as any).newCommentText = 'Some text';
|
||||||
(component as any).addComment();
|
await (component as any).addComment();
|
||||||
expect((component as any).newCommentText).toBe('');
|
expect((component as any).newCommentText).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,9 +210,9 @@ describe('IssueComments', () => {
|
|||||||
expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull();
|
expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets editing state after saving', () => {
|
it('resets editing state after saving', async () => {
|
||||||
(component as any).editingCommentText = 'Done';
|
(component as any).editingCommentText = 'Done';
|
||||||
(component as any).saveEditComment();
|
await (component as any).saveEditComment();
|
||||||
expect((component as any).editingCommentId).toBeNull();
|
expect((component as any).editingCommentId).toBeNull();
|
||||||
expect((component as any).editingCommentText).toBe('');
|
expect((component as any).editingCommentText).toBe('');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ export class IssueComments {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected addComment(): void {
|
protected async addComment(): Promise<void> {
|
||||||
const text = this.newCommentText.trim();
|
const text = this.newCommentText.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const issue = this.issuesStore.getById(this.issueId());
|
const issue = this.issuesStore.getById(this.issueId());
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1;
|
const nextId = Math.max(0, ...issue.comments.map((c) => c.id)) + 1;
|
||||||
const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null };
|
const comment: IssueComment = { id: nextId, text, createdAt: new Date().toISOString(), updatedAt: null };
|
||||||
this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
|
await this.issuesStore.upsert({ ...issue, comments: [...issue.comments, comment] });
|
||||||
this.newCommentText = '';
|
this.newCommentText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export class IssueComments {
|
|||||||
this.editingCommentText = comment.text;
|
this.editingCommentText = comment.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveEditComment(): void {
|
protected async saveEditComment(): Promise<void> {
|
||||||
const text = this.editingCommentText.trim();
|
const text = this.editingCommentText.trim();
|
||||||
if (!text || this.editingCommentId === null) return;
|
if (!text || this.editingCommentId === null) return;
|
||||||
const issue = this.issuesStore.getById(this.issueId());
|
const issue = this.issuesStore.getById(this.issueId());
|
||||||
@@ -63,7 +63,7 @@ export class IssueComments {
|
|||||||
const updatedComments = issue.comments.map((c) =>
|
const updatedComments = issue.comments.map((c) =>
|
||||||
c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c,
|
c.id === this.editingCommentId ? { ...c, text, updatedAt: new Date().toISOString() } : c,
|
||||||
);
|
);
|
||||||
this.issuesStore.upsert({ ...issue, comments: updatedComments });
|
await this.issuesStore.upsert({ ...issue, comments: updatedComments });
|
||||||
this.editingCommentId = null;
|
this.editingCommentId = null;
|
||||||
this.editingCommentText = '';
|
this.editingCommentText = '';
|
||||||
}
|
}
|
||||||
@@ -73,9 +73,9 @@ export class IssueComments {
|
|||||||
this.editingCommentText = '';
|
this.editingCommentText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected deleteComment(id: number): void {
|
protected async deleteComment(id: number): Promise<void> {
|
||||||
const issue = this.issuesStore.getById(this.issueId());
|
const issue = this.issuesStore.getById(this.issueId());
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
|
await this.issuesStore.upsert({ ...issue, comments: issue.comments.filter((c) => c.id !== id) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
@@ -23,6 +24,63 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class FakeIssuesStore {
|
||||||
|
private _data = signal<IssueEntity[]>([
|
||||||
|
makeIssue({ id: 1, name: 'Issue 1' }),
|
||||||
|
makeIssue({ id: 2, name: 'Issue 2' }),
|
||||||
|
makeIssue({ id: 3, name: 'Issue 3' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly issues = this._data.asReadonly();
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly loaded = signal(true);
|
||||||
|
|
||||||
|
getById(id: number): IssueEntity | undefined {
|
||||||
|
return this._data().find((i) => i.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextId(): number {
|
||||||
|
const ids = this._data().map((i) => i.id);
|
||||||
|
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(issue: any): Promise<IssueEntity> {
|
||||||
|
const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue;
|
||||||
|
const normalized: IssueEntity = {
|
||||||
|
...makeIssue(),
|
||||||
|
...rest,
|
||||||
|
dependsOnIds: Array.isArray(deps)
|
||||||
|
? deps.filter((v: unknown) => typeof v === 'number')
|
||||||
|
: typeof legacy === 'number'
|
||||||
|
? [legacy]
|
||||||
|
: [],
|
||||||
|
comments: Array.isArray(c) ? c : [],
|
||||||
|
estimatedTime: et ?? null,
|
||||||
|
};
|
||||||
|
this._data.update((issues) => {
|
||||||
|
const idx = issues.findIndex((i) => i.id === normalized.id);
|
||||||
|
if (idx === -1) return [...issues, normalized];
|
||||||
|
const copy = [...issues];
|
||||||
|
copy[idx] = normalized;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
return Promise.resolve(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteById(id: number): Promise<void> {
|
||||||
|
this._data.update((issues) =>
|
||||||
|
issues
|
||||||
|
.filter((i) => i.id !== id)
|
||||||
|
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeRoute(id = '1', path = 'issues/:id') {
|
function makeRoute(id = '1', path = 'issues/:id') {
|
||||||
return {
|
return {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
@@ -37,30 +95,26 @@ function makeRoute(id = '1', path = 'issues/:id') {
|
|||||||
describe('IssueDetail — existing issue', () => {
|
describe('IssueDetail — existing issue', () => {
|
||||||
let component: IssueDetail;
|
let component: IssueDetail;
|
||||||
let fixture: ComponentFixture<IssueDetail>;
|
let fixture: ComponentFixture<IssueDetail>;
|
||||||
let store: IssuesStore;
|
let store: FakeIssuesStore;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
store = new FakeIssuesStore();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [IssueDetail],
|
imports: [IssueDetail],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
||||||
|
{ provide: IssuesStore, useValue: store },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
store = TestBed.inject(IssuesStore);
|
|
||||||
router = TestBed.inject(Router);
|
router = TestBed.inject(Router);
|
||||||
fixture = TestBed.createComponent(IssueDetail);
|
fixture = TestBed.createComponent(IssueDetail);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -115,7 +169,7 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
describe('deleteIssue', () => {
|
describe('deleteIssue', () => {
|
||||||
it('removes the issue and navigates to /issues', async () => {
|
it('removes the issue and navigates to /issues', async () => {
|
||||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
(component as any).deleteIssue();
|
await (component as any).deleteIssue();
|
||||||
expect(store.getById(1)).toBeUndefined();
|
expect(store.getById(1)).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(['/issues']);
|
expect(spy).toHaveBeenCalledWith(['/issues']);
|
||||||
});
|
});
|
||||||
@@ -169,9 +223,9 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
expect((component as any).selectedCandidateId).toBeNull();
|
expect((component as any).selectedCandidateId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('confirmAddDependency adds the selected id and saves', () => {
|
it('confirmAddDependency adds the selected id and saves', async () => {
|
||||||
(component as any).selectedCandidateId = 2;
|
(component as any).selectedCandidateId = 2;
|
||||||
(component as any).confirmAddDependency();
|
await (component as any).confirmAddDependency();
|
||||||
expect((component as any).issue.dependsOnIds).toContain(2);
|
expect((component as any).issue.dependsOnIds).toContain(2);
|
||||||
expect(store.getById(1)?.dependsOnIds).toContain(2);
|
expect(store.getById(1)?.dependsOnIds).toContain(2);
|
||||||
expect((component as any).showAddDependency).toBe(false);
|
expect((component as any).showAddDependency).toBe(false);
|
||||||
@@ -370,9 +424,9 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
expect(created?.type).toBe('Story');
|
expect(created?.type).toBe('Story');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('confirmCreateInEpic resets the form', () => {
|
it('confirmCreateInEpic resets the form', async () => {
|
||||||
(component as any).newIssueName = 'Child Issue';
|
(component as any).newIssueName = 'Child Issue';
|
||||||
(component as any).confirmCreateInEpic();
|
await (component as any).confirmCreateInEpic();
|
||||||
expect((component as any).showCreateInEpic).toBe(false);
|
expect((component as any).showCreateInEpic).toBe(false);
|
||||||
expect((component as any).newIssueName).toBe('');
|
expect((component as any).newIssueName).toBe('');
|
||||||
});
|
});
|
||||||
@@ -405,9 +459,9 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
expect((component as any).selectedEpicCandidateId).toBeNull();
|
expect((component as any).selectedEpicCandidateId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('confirmAddToEpic assigns the epic name to the selected issue', () => {
|
it('confirmAddToEpic assigns the epic name to the selected issue', async () => {
|
||||||
(component as any).selectedEpicCandidateId = 2;
|
(component as any).selectedEpicCandidateId = 2;
|
||||||
(component as any).confirmAddToEpic();
|
await (component as any).confirmAddToEpic();
|
||||||
expect(store.getById(2)?.epic).toBe('My Epic');
|
expect(store.getById(2)?.epic).toBe('My Epic');
|
||||||
expect((component as any).showAddToEpic).toBe(false);
|
expect((component as any).showAddToEpic).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -436,11 +490,11 @@ describe('IssueDetail — existing issue', () => {
|
|||||||
describe('IssueDetail — new issue route', () => {
|
describe('IssueDetail — new issue route', () => {
|
||||||
let component: IssueDetail;
|
let component: IssueDetail;
|
||||||
let fixture: ComponentFixture<IssueDetail>;
|
let fixture: ComponentFixture<IssueDetail>;
|
||||||
let store: IssuesStore;
|
let store: FakeIssuesStore;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
store = new FakeIssuesStore();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [IssueDetail],
|
imports: [IssueDetail],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -456,20 +510,16 @@ describe('IssueDetail — new issue route', () => {
|
|||||||
paramMap: of(convertToParamMap({})),
|
paramMap: of(convertToParamMap({})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ provide: IssuesStore, useValue: store },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
store = TestBed.inject(IssuesStore);
|
|
||||||
router = TestBed.inject(Router);
|
router = TestBed.inject(Router);
|
||||||
fixture = TestBed.createComponent(IssueDetail);
|
fixture = TestBed.createComponent(IssueDetail);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -486,14 +536,14 @@ describe('IssueDetail — new issue route', () => {
|
|||||||
it('saveIssue without explicit flag does nothing for new route', () => {
|
it('saveIssue without explicit flag does nothing for new route', () => {
|
||||||
(component as any).issue.name = 'Draft Name';
|
(component as any).issue.name = 'Draft Name';
|
||||||
const countBefore = store.issues().length;
|
const countBefore = store.issues().length;
|
||||||
(component as any).saveIssue(); // explicit = false
|
(component as any).saveIssue();
|
||||||
expect(store.issues().length).toBe(countBefore);
|
expect(store.issues().length).toBe(countBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saveIssue with explicit=true creates the issue and navigates', async () => {
|
it('saveIssue with explicit=true creates the issue and navigates', async () => {
|
||||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
(component as any).issue.name = 'Brand New Issue';
|
(component as any).issue.name = 'Brand New Issue';
|
||||||
(component as any).saveIssue(true);
|
await (component as any).saveIssue(true);
|
||||||
expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true);
|
expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true);
|
||||||
expect(spy).toHaveBeenCalledWith(['/issues', 10]);
|
expect(spy).toHaveBeenCalledWith(['/issues', 10]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export class IssueDetail {
|
|||||||
protected moreMenuOpen = false;
|
protected moreMenuOpen = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
const idParam = this.route.snapshot.paramMap.get('id');
|
||||||
|
const safeId = Number(idParam ?? 0);
|
||||||
|
|
||||||
|
this.issuesStore.load().then(() => {
|
||||||
|
if (safeId) {
|
||||||
|
const found = this.issuesStore.getById(safeId);
|
||||||
|
if (found) this.issue = { ...found };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
const id = Number(params.get('id'));
|
const id = Number(params.get('id'));
|
||||||
if (!id || isNaN(id)) return;
|
if (!id || isNaN(id)) return;
|
||||||
@@ -38,6 +48,7 @@ export class IssueDetail {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected showAddDependency = false;
|
protected showAddDependency = false;
|
||||||
protected selectedCandidateId: number | null = null;
|
protected selectedCandidateId: number | null = null;
|
||||||
protected editingDescription = false;
|
protected editingDescription = false;
|
||||||
@@ -86,18 +97,18 @@ export class IssueDetail {
|
|||||||
this.selectedCandidateId = null;
|
this.selectedCandidateId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected confirmAddDependency(): void {
|
protected async confirmAddDependency(): Promise<void> {
|
||||||
if (this.selectedCandidateId !== null) {
|
if (this.selectedCandidateId !== null) {
|
||||||
this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId];
|
this.issue.dependsOnIds = [...this.issue.dependsOnIds, this.selectedCandidateId];
|
||||||
this.saveIssue();
|
await this.saveIssue();
|
||||||
}
|
}
|
||||||
this.showAddDependency = false;
|
this.showAddDependency = false;
|
||||||
this.selectedCandidateId = null;
|
this.selectedCandidateId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected removeDependency(id: number): void {
|
protected async removeDependency(id: number): Promise<void> {
|
||||||
this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id);
|
this.issue.dependsOnIds = this.issue.dependsOnIds.filter((depId) => depId !== id);
|
||||||
this.saveIssue();
|
await this.saveIssue();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get estimatedTimeValue(): number | null {
|
protected get estimatedTimeValue(): number | null {
|
||||||
@@ -146,11 +157,11 @@ export class IssueDetail {
|
|||||||
this.newIssueName = '';
|
this.newIssueName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected confirmCreateInEpic(): void {
|
protected async confirmCreateInEpic(): Promise<void> {
|
||||||
const name = this.newIssueName.trim();
|
const name = this.newIssueName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
this.issuesStore.upsert({
|
await this.issuesStore.upsert({
|
||||||
id: this.issuesStore.getNextId(),
|
id: 0,
|
||||||
type: 'Story',
|
type: 'Story',
|
||||||
assignee: '',
|
assignee: '',
|
||||||
epic: this.issue.name,
|
epic: this.issue.name,
|
||||||
@@ -178,11 +189,11 @@ export class IssueDetail {
|
|||||||
this.selectedEpicCandidateId = null;
|
this.selectedEpicCandidateId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected confirmAddToEpic(): void {
|
protected async confirmAddToEpic(): Promise<void> {
|
||||||
if (this.selectedEpicCandidateId !== null) {
|
if (this.selectedEpicCandidateId !== null) {
|
||||||
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
const target = this.issues().find((i) => i.id === this.selectedEpicCandidateId);
|
||||||
if (target) {
|
if (target) {
|
||||||
this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
await this.issuesStore.upsert({ ...target, epic: this.issue.name });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.showAddToEpic = false;
|
this.showAddToEpic = false;
|
||||||
@@ -229,12 +240,13 @@ export class IssueDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveIssue(explicit = false): void {
|
protected async saveIssue(explicit = false): Promise<void> {
|
||||||
if (this.isNewIssueRoute && !explicit) return;
|
if (this.isNewIssueRoute && !explicit) return;
|
||||||
if (!this.issue.name.trim()) return;
|
if (!this.issue.name.trim()) return;
|
||||||
this.issuesStore.upsert(this.issue);
|
const saved = await this.issuesStore.upsert(this.issue);
|
||||||
|
this.issue = { ...saved };
|
||||||
if (this.isNewIssueRoute) {
|
if (this.isNewIssueRoute) {
|
||||||
this.router.navigate(['/issues', this.issue.id]);
|
this.router.navigate(['/issues', saved.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,14 +254,15 @@ export class IssueDetail {
|
|||||||
this.router.navigate(['/issues']);
|
this.router.navigate(['/issues']);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected deleteIssue(): void {
|
protected async deleteIssue(): Promise<void> {
|
||||||
this.issuesStore.deleteById(this.issue.id);
|
await this.issuesStore.deleteById(this.issue.id);
|
||||||
this.router.navigate(['/issues']);
|
this.router.navigate(['/issues']);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updateStatus(status: IssueEntity['status']): void {
|
protected async updateStatus(status: IssueEntity['status']): Promise<void> {
|
||||||
this.issue.status = status;
|
this.issue.status = status;
|
||||||
this.issuesStore.upsert(this.issue);
|
const saved = await this.issuesStore.upsert(this.issue);
|
||||||
|
this.issue = { ...saved };
|
||||||
}
|
}
|
||||||
|
|
||||||
protected toggleMoreMenu(): void {
|
protected toggleMoreMenu(): void {
|
||||||
@@ -262,15 +275,12 @@ export class IssueDetail {
|
|||||||
|
|
||||||
private buildIssue(): IssueEntity {
|
private buildIssue(): IssueEntity {
|
||||||
const idParam = this.route.snapshot.paramMap.get('id');
|
const idParam = this.route.snapshot.paramMap.get('id');
|
||||||
const draftId = this.route.snapshot.queryParamMap.get('draftId');
|
|
||||||
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
const isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||||
|
|
||||||
const resolvedId = Number(idParam ?? draftId ?? 0);
|
|
||||||
const safeId = Number.isNaN(resolvedId) ? 0 : resolvedId;
|
|
||||||
|
|
||||||
if (isNewIssueRoute) {
|
if (isNewIssueRoute) {
|
||||||
|
const draftId = Number(this.route.snapshot.queryParamMap.get('draftId') ?? 0);
|
||||||
return {
|
return {
|
||||||
id: safeId,
|
id: draftId,
|
||||||
type: 'Story',
|
type: 'Story',
|
||||||
assignee: '',
|
assignee: '',
|
||||||
epic: '',
|
epic: '',
|
||||||
@@ -286,6 +296,7 @@ export class IssueDetail {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeId = Number(idParam ?? 0);
|
||||||
const existingIssue = this.issuesStore.getById(safeId);
|
const existingIssue = this.issuesStore.getById(safeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
@@ -22,30 +23,93 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class FakeIssuesStore {
|
||||||
|
private _data = signal<IssueEntity[]>([
|
||||||
|
makeIssue({ id: 1, name: 'Issue 1', progress: 0 }),
|
||||||
|
makeIssue({ id: 2, name: 'Issue 2', progress: 0 }),
|
||||||
|
makeIssue({ id: 3, name: 'Issue 3', progress: 0 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
readonly issues = this._data.asReadonly();
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly loaded = signal(true);
|
||||||
|
|
||||||
|
getById(id: number): IssueEntity | undefined {
|
||||||
|
return this._data().find((i) => i.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextId(): number {
|
||||||
|
const ids = this._data().map((i) => i.id);
|
||||||
|
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(issue: any): Promise<IssueEntity> {
|
||||||
|
const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue;
|
||||||
|
const normalized: IssueEntity = {
|
||||||
|
type: 'Story',
|
||||||
|
assignee: '',
|
||||||
|
epic: '',
|
||||||
|
name: '',
|
||||||
|
dueDate: '',
|
||||||
|
description: '',
|
||||||
|
estimatedTime: et ?? null,
|
||||||
|
comments: Array.isArray(c) ? c : [],
|
||||||
|
priority: 'Moyenne',
|
||||||
|
status: 'draft',
|
||||||
|
progress: 0,
|
||||||
|
...rest,
|
||||||
|
dependsOnIds: Array.isArray(deps)
|
||||||
|
? deps.filter((v: unknown) => typeof v === 'number')
|
||||||
|
: typeof legacy === 'number'
|
||||||
|
? [legacy]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
this._data.update((issues) => {
|
||||||
|
const idx = issues.findIndex((i) => i.id === normalized.id);
|
||||||
|
if (idx === -1) return [...issues, normalized];
|
||||||
|
const copy = [...issues];
|
||||||
|
copy[idx] = normalized;
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
return Promise.resolve(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteById(id: number): Promise<void> {
|
||||||
|
this._data.update((issues) =>
|
||||||
|
issues
|
||||||
|
.filter((i) => i.id !== id)
|
||||||
|
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('Issues', () => {
|
describe('Issues', () => {
|
||||||
let component: Issues;
|
let component: Issues;
|
||||||
let fixture: ComponentFixture<Issues>;
|
let fixture: ComponentFixture<Issues>;
|
||||||
let store: IssuesStore;
|
let store: FakeIssuesStore;
|
||||||
let router: Router;
|
let router: Router;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
store = new FakeIssuesStore();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Issues],
|
imports: [Issues],
|
||||||
providers: [provideRouter([])],
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: IssuesStore, useValue: store },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
store = TestBed.inject(IssuesStore);
|
|
||||||
router = TestBed.inject(Router);
|
router = TestBed.inject(Router);
|
||||||
fixture = TestBed.createComponent(Issues);
|
fixture = TestBed.createComponent(Issues);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -65,7 +129,6 @@ describe('Issues', () => {
|
|||||||
it('returns empty array when no issues match the selected type', () => {
|
it('returns empty array when no issues match the selected type', () => {
|
||||||
(component as any).selectedType = 'Epic';
|
(component as any).selectedType = 'Epic';
|
||||||
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
const filtered: IssueEntity[] = (component as any).filteredIssues;
|
||||||
// Default store has no Epics, so this should be empty
|
|
||||||
expect(filtered.every((i) => i.type === 'Epic')).toBe(true);
|
expect(filtered.every((i) => i.type === 'Epic')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -97,13 +160,10 @@ describe('Issues', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createIssue', () => {
|
describe('createIssue', () => {
|
||||||
it('navigates to /issues/new with a draftId query param', async () => {
|
it('navigates to /issues/new', () => {
|
||||||
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||||
(component as any).createIssue();
|
(component as any).createIssue();
|
||||||
expect(spy).toHaveBeenCalledWith(
|
expect(spy).toHaveBeenCalledWith(['/issues/new']);
|
||||||
['/issues/new'],
|
|
||||||
expect.objectContaining({ queryParams: expect.objectContaining({ mode: 'edit' }) }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { IssueEntity, IssuesStore } from './issues.store';
|
import { IssueEntity, IssuesStore } from './issues.store';
|
||||||
|
import { API_BASE_URL } from './issues-api.service';
|
||||||
|
|
||||||
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
||||||
id: 99,
|
id: 99,
|
||||||
@@ -20,29 +23,59 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
|||||||
|
|
||||||
describe('IssuesStore', () => {
|
describe('IssuesStore', () => {
|
||||||
let store: IssuesStore;
|
let store: IssuesStore;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
const loadWith = async (issues: IssueEntity[]) => {
|
||||||
|
const p = store.load();
|
||||||
|
httpMock.expectOne(`${API_BASE_URL}/issues`).flush(issues);
|
||||||
|
await p;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
TestBed.configureTestingModule({
|
||||||
TestBed.configureTestingModule({});
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
store = TestBed.inject(IssuesStore);
|
store = TestBed.inject(IssuesStore);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.clear();
|
httpMock.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
expect(store).toBeTruthy();
|
expect(store).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load default issues when localStorage is empty', () => {
|
describe('load', () => {
|
||||||
expect(store.issues().length).toBeGreaterThan(0);
|
it('populates issues from the API', async () => {
|
||||||
|
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
|
||||||
|
expect(store.issues().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets loading to true during load and false after', async () => {
|
||||||
|
const p = store.load();
|
||||||
|
expect(store.loading()).toBe(true);
|
||||||
|
httpMock.expectOne(`${API_BASE_URL}/issues`).flush([]);
|
||||||
|
await p;
|
||||||
|
expect(store.loading()).toBe(false);
|
||||||
|
expect(store.loaded()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reload if already loaded', async () => {
|
||||||
|
await loadWith([]);
|
||||||
|
await store.load();
|
||||||
|
httpMock.expectNone(`${API_BASE_URL}/issues`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getById', () => {
|
describe('getById', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 2 })]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the issue with the given id', () => {
|
it('returns the issue with the given id', () => {
|
||||||
const issue = store.getById(1);
|
expect(store.getById(1)?.id).toBe(1);
|
||||||
expect(issue?.id).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an unknown id', () => {
|
it('returns undefined for an unknown id', () => {
|
||||||
@@ -51,122 +84,108 @@ describe('IssuesStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getNextId', () => {
|
describe('getNextId', () => {
|
||||||
it('returns max id + 1', () => {
|
it('returns max id + 1', async () => {
|
||||||
const ids = store.issues().map((i) => i.id);
|
await loadWith([makeIssue({ id: 1 }), makeIssue({ id: 5 }), makeIssue({ id: 3 })]);
|
||||||
const expectedNext = Math.max(...ids) + 1;
|
expect(store.getNextId()).toBe(6);
|
||||||
expect(store.getNextId()).toBe(expectedNext);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 1 when there are no issues', () => {
|
it('returns 1 when there are no issues', async () => {
|
||||||
store.deleteById(1);
|
await loadWith([]);
|
||||||
store.deleteById(2);
|
|
||||||
store.deleteById(3);
|
|
||||||
expect(store.getNextId()).toBe(1);
|
expect(store.getNextId()).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('upsert', () => {
|
describe('upsert', () => {
|
||||||
it('adds a new issue when the id does not exist', () => {
|
beforeEach(async () => {
|
||||||
const before = store.issues().length;
|
await loadWith([makeIssue({ id: 1, name: 'Existing' }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]);
|
||||||
store.upsert(makeIssue({ id: 999 }));
|
|
||||||
expect(store.issues().length).toBe(before + 1);
|
|
||||||
expect(store.getById(999)?.name).toBe('Test Issue');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates an existing issue', () => {
|
it('creates a new issue via POST when id is 0', async () => {
|
||||||
store.upsert(makeIssue({ id: 1, name: 'Updated Name' }));
|
const before = store.issues().length;
|
||||||
|
const p = store.upsert(makeIssue({ id: 0, name: 'New Issue' }));
|
||||||
|
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 999, name: 'New Issue' }));
|
||||||
|
await p;
|
||||||
|
expect(store.issues().length).toBe(before + 1);
|
||||||
|
expect(store.getById(999)?.name).toBe('New Issue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an existing issue via PUT', async () => {
|
||||||
|
const p = store.upsert(makeIssue({ id: 1, name: 'Updated Name' }));
|
||||||
|
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, name: 'Updated Name' }));
|
||||||
|
await p;
|
||||||
expect(store.getById(1)?.name).toBe('Updated Name');
|
expect(store.getById(1)?.name).toBe('Updated Name');
|
||||||
expect(store.issues().filter((i) => i.id === 1).length).toBe(1);
|
expect(store.issues().filter((i) => i.id === 1).length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists the issue list to localStorage', () => {
|
it('normalizes legacy dependsOnId to dependsOnIds array', async () => {
|
||||||
store.upsert(makeIssue({ id: 999 }));
|
const issue = { id: 0, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any;
|
||||||
const raw = localStorage.getItem('bonsai.issues');
|
const p = store.upsert(issue);
|
||||||
expect(raw).not.toBeNull();
|
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 998, dependsOnIds: [1] }));
|
||||||
const parsed = JSON.parse(raw!);
|
await p;
|
||||||
expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes legacy dependsOnId (single number) to dependsOnIds array when dependsOnIds is absent', () => {
|
|
||||||
// dependsOnIds must be omitted (not an array) for the legacy field to take effect
|
|
||||||
store.upsert({ id: 998, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any);
|
|
||||||
expect(store.getById(998)?.dependsOnIds).toEqual([1]);
|
expect(store.getById(998)?.dependsOnIds).toEqual([1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters non-number values from dependsOnIds', () => {
|
it('filters non-number values from dependsOnIds', async () => {
|
||||||
store.upsert({ ...makeIssue({ id: 997 }), dependsOnIds: [1, 'two', null] } as any);
|
const issue = { ...makeIssue({ id: 0 }), dependsOnIds: [1, 'two', null] } as any;
|
||||||
|
const p = store.upsert(issue);
|
||||||
|
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 997, dependsOnIds: [1] }));
|
||||||
|
await p;
|
||||||
expect(store.getById(997)?.dependsOnIds).toEqual([1]);
|
expect(store.getById(997)?.dependsOnIds).toEqual([1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ensures comments is always an array when missing', () => {
|
it('ensures comments is always an array when missing', async () => {
|
||||||
store.upsert({ ...makeIssue({ id: 996 }), comments: undefined } as any);
|
const issue = { ...makeIssue({ id: 0 }), comments: undefined } as any;
|
||||||
|
const p = store.upsert(issue);
|
||||||
|
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 996, comments: [] }));
|
||||||
|
await p;
|
||||||
expect(store.getById(996)?.comments).toEqual([]);
|
expect(store.getById(996)?.comments).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets default type to Story when type is missing', () => {
|
it('sets default type to Story when type is missing', async () => {
|
||||||
store.upsert({ ...makeIssue({ id: 995 }), type: undefined } as any);
|
const issue = { ...makeIssue({ id: 0 }), type: undefined } as any;
|
||||||
|
const p = store.upsert(issue);
|
||||||
|
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 995, type: 'Story' }));
|
||||||
|
await p;
|
||||||
expect(store.getById(995)?.type).toBe('Story');
|
expect(store.getById(995)?.type).toBe('Story');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets estimatedTime to null when missing', () => {
|
it('sets estimatedTime to null when missing in API response', async () => {
|
||||||
store.upsert({ ...makeIssue({ id: 994 }), estimatedTime: undefined } as any);
|
const issue = { ...makeIssue({ id: 0 }), estimatedTime: undefined } as any;
|
||||||
|
const p = store.upsert(issue);
|
||||||
|
httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 994 }), estimatedTime: undefined });
|
||||||
|
await p;
|
||||||
expect(store.getById(994)?.estimatedTime).toBeNull();
|
expect(store.getById(994)?.estimatedTime).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteById', () => {
|
describe('deleteById', () => {
|
||||||
it('removes the issue from the store', () => {
|
beforeEach(async () => {
|
||||||
store.upsert(makeIssue({ id: 999 }));
|
await loadWith([
|
||||||
store.deleteById(999);
|
makeIssue({ id: 1 }),
|
||||||
expect(store.getById(999)).toBeUndefined();
|
makeIssue({ id: 100 }),
|
||||||
|
makeIssue({ id: 101, dependsOnIds: [100] }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes the deleted id from dependsOnIds of other issues', () => {
|
it('removes the issue from the store', async () => {
|
||||||
store.upsert(makeIssue({ id: 100 }));
|
const p = store.deleteById(1);
|
||||||
store.upsert(makeIssue({ id: 101, dependsOnIds: [100] }));
|
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||||
store.deleteById(100);
|
await p;
|
||||||
|
expect(store.getById(1)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the deleted id from dependsOnIds of other issues', async () => {
|
||||||
|
const p = store.deleteById(100);
|
||||||
|
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/100` }).flush(null);
|
||||||
|
await p;
|
||||||
expect(store.getById(101)?.dependsOnIds).toEqual([]);
|
expect(store.getById(101)?.dependsOnIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists the updated list to localStorage', () => {
|
it('does not affect issues with unrelated dependsOnIds', async () => {
|
||||||
store.upsert(makeIssue({ id: 999 }));
|
const p = store.deleteById(1);
|
||||||
store.deleteById(999);
|
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
|
||||||
const raw = localStorage.getItem('bonsai.issues');
|
await p;
|
||||||
const parsed = JSON.parse(raw!);
|
expect(store.getById(101)?.dependsOnIds).toEqual([100]);
|
||||||
expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('localStorage persistence', () => {
|
|
||||||
it('loads issues from localStorage on construction', () => {
|
|
||||||
const saved: IssueEntity[] = [makeIssue({ id: 42, name: 'From storage' })];
|
|
||||||
localStorage.setItem('bonsai.issues', JSON.stringify(saved));
|
|
||||||
|
|
||||||
TestBed.resetTestingModule();
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
const freshStore = TestBed.inject(IssuesStore);
|
|
||||||
|
|
||||||
expect(freshStore.getById(42)?.name).toBe('From storage');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaults when localStorage contains invalid JSON', () => {
|
|
||||||
localStorage.setItem('bonsai.issues', 'not-valid-json');
|
|
||||||
|
|
||||||
TestBed.resetTestingModule();
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
const freshStore = TestBed.inject(IssuesStore);
|
|
||||||
|
|
||||||
expect(freshStore.issues().length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to defaults when localStorage contains a non-array', () => {
|
|
||||||
localStorage.setItem('bonsai.issues', '{"key":"value"}');
|
|
||||||
|
|
||||||
TestBed.resetTestingModule();
|
|
||||||
TestBed.configureTestingModule({});
|
|
||||||
const freshStore = TestBed.inject(IssuesStore);
|
|
||||||
|
|
||||||
expect(freshStore.issues().length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+47
-117
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
const ISSUES_STORAGE_KEY = 'bonsai.issues';
|
import { IssuesApiService } from './issues-api.service';
|
||||||
|
|
||||||
export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress';
|
export type IssueStatus = 'draft' | 'todo' | 'done' | 'in-progress';
|
||||||
export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute';
|
export type IssuePriority = 'Basse' | 'Moyenne' | 'Haute';
|
||||||
@@ -29,108 +29,65 @@ export type IssueEntity = {
|
|||||||
progress: number;
|
progress: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ISSUES: IssueEntity[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: 'Bug',
|
|
||||||
assignee: 'Marie',
|
|
||||||
epic: 'EPIC-UI',
|
|
||||||
name: 'Bug affichage menu mobile',
|
|
||||||
dueDate: '2026-06-10',
|
|
||||||
description: 'Corriger le comportement du menu sur petits ecrans.',
|
|
||||||
estimatedTime: 8,
|
|
||||||
dependsOnIds: [],
|
|
||||||
comments: [],
|
|
||||||
priority: 'Haute',
|
|
||||||
status: 'in-progress',
|
|
||||||
progress: 35,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: 'Study',
|
|
||||||
assignee: 'Nabil',
|
|
||||||
epic: 'EPIC-FORM',
|
|
||||||
name: 'Erreur validation formulaire projet',
|
|
||||||
dueDate: '2026-06-12',
|
|
||||||
description: 'Fiabiliser les regles de validation du formulaire projet.',
|
|
||||||
estimatedTime: 16,
|
|
||||||
dependsOnIds: [],
|
|
||||||
comments: [],
|
|
||||||
priority: 'Moyenne',
|
|
||||||
status: 'todo',
|
|
||||||
progress: 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
type: 'Story',
|
|
||||||
assignee: 'Sonia',
|
|
||||||
epic: 'EPIC-CONTENT',
|
|
||||||
name: 'Mise a jour message de bienvenue',
|
|
||||||
dueDate: '2026-06-18',
|
|
||||||
description: 'Mettre a jour le wording d accueil selon la charte produit.',
|
|
||||||
estimatedTime: 4,
|
|
||||||
dependsOnIds: [],
|
|
||||||
comments: [],
|
|
||||||
priority: 'Basse',
|
|
||||||
status: 'done',
|
|
||||||
progress: 100,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class IssuesStore {
|
export class IssuesStore {
|
||||||
private readonly data = signal<IssueEntity[]>(DEFAULT_ISSUES);
|
private readonly api = inject(IssuesApiService);
|
||||||
|
private readonly data = signal<IssueEntity[]>([]);
|
||||||
constructor() {
|
|
||||||
const cachedIssues = this.readFromStorage();
|
|
||||||
if (cachedIssues) {
|
|
||||||
this.data.set(cachedIssues.map((issue) => this.normalizeIssue(issue)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly loaded = signal(false);
|
||||||
readonly issues = this.data.asReadonly();
|
readonly issues = this.data.asReadonly();
|
||||||
|
|
||||||
getById(id: number): IssueEntity | undefined {
|
getById(id: number): IssueEntity | undefined {
|
||||||
return this.data().find((issue) => issue.id === id);
|
return this.data().find((i) => i.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getNextId(): number {
|
getNextId(): number {
|
||||||
const ids = this.data().map((issue) => issue.id);
|
const ids = this.data().map((i) => i.id);
|
||||||
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
return ids.length === 0 ? 1 : Math.max(...ids) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
if (this.loaded()) return;
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const issues = await firstValueFrom(this.api.getAll());
|
||||||
|
this.data.set(issues.map((i) => this.normalizeIssue(i)));
|
||||||
|
this.loaded.set(true);
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
upsert(issue: IssueEntity): void {
|
async upsert(issue: IssueEntity): Promise<IssueEntity> {
|
||||||
const normalizedIssue = this.normalizeIssue(issue);
|
const normalized = this.normalizeIssue(issue);
|
||||||
|
if (!normalized.id) {
|
||||||
this.data.update((issues) => {
|
const { id: _id, ...body } = normalized;
|
||||||
const existingIndex = issues.findIndex((current) => current.id === issue.id);
|
const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
|
||||||
|
this.data.update((issues) => [...issues, created]);
|
||||||
if (existingIndex === -1) {
|
|
||||||
const created = [...issues, normalizedIssue];
|
|
||||||
this.persistToStorage(created);
|
|
||||||
return created;
|
return created;
|
||||||
}
|
} else {
|
||||||
|
const updated = this.normalizeIssue(
|
||||||
const updated = [...issues];
|
await firstValueFrom(this.api.update(normalized.id, normalized)),
|
||||||
updated[existingIndex] = normalizedIssue;
|
);
|
||||||
this.persistToStorage(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteById(id: number): void {
|
|
||||||
this.data.update((issues) => {
|
this.data.update((issues) => {
|
||||||
const updated = issues
|
const idx = issues.findIndex((i) => i.id === normalized.id);
|
||||||
.filter((issue) => issue.id !== id)
|
if (idx === -1) return issues;
|
||||||
.map((issue) => ({
|
const copy = [...issues];
|
||||||
...issue,
|
copy[idx] = updated;
|
||||||
dependsOnIds: issue.dependsOnIds.filter((dependencyId) => dependencyId !== id),
|
return copy;
|
||||||
}));
|
|
||||||
|
|
||||||
this.persistToStorage(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteById(id: number): Promise<void> {
|
||||||
|
await firstValueFrom(this.api.remove(id));
|
||||||
|
this.data.update((issues) =>
|
||||||
|
issues
|
||||||
|
.filter((i) => i.id !== id)
|
||||||
|
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeIssue(
|
private normalizeIssue(
|
||||||
@@ -149,31 +106,4 @@ export class IssuesStore {
|
|||||||
comments: Array.isArray(issue.comments) ? issue.comments : [],
|
comments: Array.isArray(issue.comments) ? issue.comments : [],
|
||||||
} as IssueEntity;
|
} as IssueEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readFromStorage(): IssueEntity[] | null {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawIssues = window.localStorage.getItem(ISSUES_STORAGE_KEY);
|
|
||||||
if (!rawIssues) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawIssues);
|
|
||||||
return Array.isArray(parsed) ? (parsed as IssueEntity[]) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private persistToStorage(issues: IssueEntity[]): void {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(ISSUES_STORAGE_KEY, JSON.stringify(issues));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export class Issues {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly issuesStore = inject(IssuesStore);
|
private readonly issuesStore = inject(IssuesStore);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.issuesStore.load();
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly issues = this.issuesStore.issues;
|
protected readonly issues = this.issuesStore.issues;
|
||||||
protected selectedType: IssueEntity['type'] | null = null;
|
protected selectedType: IssueEntity['type'] | null = null;
|
||||||
|
|
||||||
@@ -29,10 +33,7 @@ export class Issues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected createIssue(): void {
|
protected createIssue(): void {
|
||||||
const nextId = this.issuesStore.getNextId();
|
this.router.navigate(['/issues/new']);
|
||||||
this.router.navigate(['/issues/new'], {
|
|
||||||
queryParams: { draftId: nextId, mode: 'edit' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected openIssue(issueId: number): void {
|
protected openIssue(issueId: number): void {
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-version {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -63,3 +68,59 @@
|
|||||||
border-left-color: #2563eb;
|
border-left-color: #2563eb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-login {
|
||||||
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #2563eb;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-login:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
<circle cx="18" cy="16" r="6" fill="#48BB78"/>
|
<circle cx="18" cy="16" r="6" fill="#48BB78"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sidebar-logo-text">Bonsai</span>
|
<span class="sidebar-logo-text">Bonsai</span>
|
||||||
|
<span class="sidebar-version">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (keycloak.isAuthenticated()) {
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
@for (item of menuItems; track item.path) {
|
@for (item of menuItems; track item.path) {
|
||||||
<a
|
<a
|
||||||
@@ -39,4 +41,12 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<span class="sidebar-user-name">{{ keycloak.username() }}</span>
|
||||||
|
<button class="sidebar-logout" (click)="logout()">Déconnexion</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<button class="sidebar-login" (click)="keycloak.login()">Connexion</button>
|
||||||
|
}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { version } from '../../../package.json';
|
||||||
|
import { KeycloakService } from '../auth/keycloak.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-menu',
|
selector: 'app-menu',
|
||||||
@@ -8,9 +10,16 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
styleUrl: './menu.css',
|
styleUrl: './menu.css',
|
||||||
})
|
})
|
||||||
export class Menu {
|
export class Menu {
|
||||||
|
protected readonly version = version;
|
||||||
|
protected readonly keycloak = inject(KeycloakService);
|
||||||
|
|
||||||
protected readonly menuItems = [
|
protected readonly menuItems = [
|
||||||
{ label: 'Accueil', path: '/home' },
|
{ label: 'Accueil', path: '/home' },
|
||||||
{ label: 'Projet', path: '/project' },
|
{ label: 'Projet', path: '/project' },
|
||||||
{ label: 'Issues', path: '/issues' },
|
{ label: 'Issues', path: '/issues' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected logout(): void {
|
||||||
|
this.keycloak.logout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"experimentalDecorators": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "preserve"
|
"module": "preserve",
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user