Affichage version

Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
2026-05-31 16:06:34 +02:00
parent 9f5012e9ea
commit 11aba5dbd0
7 changed files with 288 additions and 2 deletions
+110
View File
@@ -14,6 +14,7 @@
}
.sidebar-logo {
position: relative;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
@@ -34,11 +35,120 @@
letter-spacing: -0.02em;
}
.sidebar-version-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.sidebar-version {
font-size: 0.72rem;
color: #9ca3af;
}
.sidebar-info-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
font-size: 0.65rem;
font-weight: 700;
font-style: italic;
line-height: 1;
color: #9ca3af;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 50%;
cursor: pointer;
padding: 0;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.sidebar-info-btn:hover {
background: #f3f4f6;
color: #374151;
border-color: #9ca3af;
}
.sidebar-info-btn--warn {
color: #92400e;
background: #fef3c7;
border-color: #f59e0b;
}
.sidebar-info-btn--warn:hover {
background: #fde68a;
color: #78350f;
border-color: #d97706;
}
.version-popover {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: calc(100% + 0.5rem);
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.version-popover-close {
position: absolute;
top: 0.4rem;
right: 0.5rem;
background: transparent;
border: none;
font-size: 1rem;
color: #9ca3af;
cursor: pointer;
line-height: 1;
padding: 0;
}
.version-popover-close:hover {
color: #374151;
}
.version-popover-title {
font-size: 0.75rem;
font-weight: 700;
color: #374151;
margin-bottom: 0.5rem;
}
.version-popover-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
padding: 0.2rem 0;
}
.version-popover-label {
color: #6b7280;
}
.version-popover-value {
font-weight: 600;
color: #111827;
font-family: monospace;
}
.version-popover-warn {
margin-top: 0.5rem;
font-size: 0.7rem;
color: #92400e;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
text-align: center;
}
.sidebar-nav {
display: flex;
flex-direction: column;
+27 -1
View File
@@ -25,7 +25,33 @@
<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 class="sidebar-version-row">
<span class="sidebar-version">v{{ version }}</span>
<button
class="sidebar-info-btn"
[class.sidebar-info-btn--warn]="versionMismatch()"
(click)="toggleInfo()"
type="button"
aria-label="Informations de version"
>i</button>
</div>
@if (showInfo()) {
<div class="version-popover" role="dialog" aria-label="Versions">
<button class="version-popover-close" (click)="closeInfo()" type="button" aria-label="Fermer">×</button>
<div class="version-popover-title">Versions</div>
<div class="version-popover-row">
<span class="version-popover-label">Interface</span>
<span class="version-popover-value">v{{ version }}</span>
</div>
<div class="version-popover-row">
<span class="version-popover-label">API</span>
<span class="version-popover-value">{{ apiVersion() !== null ? 'v' + apiVersion() : '...' }}</span>
</div>
@if (versionMismatch()) {
<div class="version-popover-warn">Versions différentes</div>
}
</div>
}
</div>
@if (keycloak.isAuthenticated()) {
+67
View File
@@ -1,10 +1,12 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { KeycloakService } from '../auth/keycloak.service';
import { ProjectContextService } from '../projects/project-context.service';
import { ProjectEntity } from '../projects/projects.store';
import { VersionApiService } from '../version/version-api.service';
import { Menu } from './menu';
describe('Menu', () => {
@@ -18,6 +20,7 @@ describe('Menu', () => {
const keycloakMock = { isAuthenticated, username, logout: vi.fn(), login: vi.fn() };
const projectContextMock = { projectId, project };
const versionApiMock = { getVersion: vi.fn() };
beforeEach(async () => {
isAuthenticated.set(false);
@@ -25,6 +28,7 @@ describe('Menu', () => {
project.set(null);
keycloakMock.logout = vi.fn();
keycloakMock.login = vi.fn();
versionApiMock.getVersion = vi.fn();
await TestBed.configureTestingModule({
imports: [Menu],
@@ -32,6 +36,7 @@ describe('Menu', () => {
provideRouter([]),
{ provide: KeycloakService, useValue: keycloakMock },
{ provide: ProjectContextService, useValue: projectContextMock },
{ provide: VersionApiService, useValue: versionApiMock },
],
}).compileComponents();
@@ -73,4 +78,66 @@ describe('Menu', () => {
const hrefs = Array.from(links).map((l: any) => l.getAttribute('href') ?? '');
expect(hrefs.some((h) => h === '/projects')).toBe(true);
});
describe('info button', () => {
it('toggleInfo opens the popover and fetches API version', () => {
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
(component as any).toggleInfo();
expect((component as any).showInfo()).toBe(true);
expect((component as any).apiVersion()).toBe('1.2.3');
});
it('toggleInfo closes the popover when already open', () => {
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
(component as any).toggleInfo();
(component as any).toggleInfo();
expect((component as any).showInfo()).toBe(false);
});
it('does not call getVersion again when popover is reopened', () => {
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
(component as any).toggleInfo();
(component as any).toggleInfo();
(component as any).toggleInfo();
expect(versionApiMock.getVersion).toHaveBeenCalledTimes(1);
});
it('sets apiVersion to — on API error', () => {
versionApiMock.getVersion.mockReturnValue(throwError(() => new Error('network')));
(component as any).toggleInfo();
expect((component as any).apiVersion()).toBe('—');
});
it('closeInfo hides the popover', () => {
versionApiMock.getVersion.mockReturnValue(of({ version: '1.2.3' }));
(component as any).toggleInfo();
(component as any).closeInfo();
expect((component as any).showInfo()).toBe(false);
});
it('versionMismatch is false when versions match', () => {
versionApiMock.getVersion.mockReturnValue(of({ version: (component as any).version }));
(component as any).toggleInfo();
expect((component as any).versionMismatch()).toBe(false);
});
it('versionMismatch is true when versions differ', () => {
versionApiMock.getVersion.mockReturnValue(of({ version: '99.99.99' }));
(component as any).toggleInfo();
expect((component as any).versionMismatch()).toBe(true);
});
it('versionMismatch is false before API responds', () => {
expect((component as any).versionMismatch()).toBe(false);
});
});
});
+24 -1
View File
@@ -1,8 +1,9 @@
import { Component, computed, inject } from '@angular/core';
import { Component, computed, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../package.json';
import { KeycloakService } from '../auth/keycloak.service';
import { ProjectContextService } from '../projects/project-context.service';
import { VersionApiService } from '../version/version-api.service';
@Component({
selector: 'app-menu',
@@ -14,6 +15,14 @@ export class Menu {
protected readonly version = version;
protected readonly keycloak = inject(KeycloakService);
protected readonly projectContext = inject(ProjectContextService);
private readonly versionApi = inject(VersionApiService);
protected readonly showInfo = signal(false);
protected readonly apiVersion = signal<string | null>(null);
protected readonly versionMismatch = computed(() => {
const api = this.apiVersion();
return api !== null && api !== this.version;
});
protected readonly projectMenuItems = computed(() => {
const pid = this.projectContext.projectId();
@@ -26,6 +35,20 @@ export class Menu {
];
});
protected toggleInfo(): void {
if (!this.showInfo() && this.apiVersion() === null) {
this.versionApi.getVersion().subscribe({
next: (r) => this.apiVersion.set(r.version),
error: () => this.apiVersion.set('—'),
});
}
this.showInfo.update((v) => !v);
}
protected closeInfo(): void {
this.showInfo.set(false);
}
protected logout(): void {
this.keycloak.logout();
}
@@ -0,0 +1,37 @@
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { vi } from 'vitest';
import { VersionApiService } from './version-api.service';
describe('VersionApiService', () => {
let service: VersionApiService;
let httpGetMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
httpGetMock = vi.fn();
TestBed.configureTestingModule({
providers: [
VersionApiService,
{ provide: HttpClient, useValue: { get: httpGetMock } },
],
});
service = TestBed.inject(VersionApiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('calls GET /api/version and returns the response', () => {
httpGetMock.mockReturnValue(of({ version: '1.2.3' }));
let result: any;
service.getVersion().subscribe((r) => (result = r));
expect(httpGetMock).toHaveBeenCalledWith('/api/version');
expect(result).toEqual({ version: '1.2.3' });
});
});
+18
View File
@@ -0,0 +1,18 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
const API_BASE_URL = '/api';
export interface VersionResponse {
version: string;
}
@Injectable({ providedIn: 'root' })
export class VersionApiService {
private readonly http = inject(HttpClient);
getVersion(): Observable<VersionResponse> {
return this.http.get<VersionResponse>(`${API_BASE_URL}/version`);
}
}