diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a0a4f22..85ab538 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -27,6 +27,11 @@ jobs: id: repo run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + - name: Bump version in package.json + run: | + VERSION=$(echo "${{ gitea.ref_name }}" | sed 's/^v//') + sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" package.json + - name: Build and push uses: https://github.com/docker/build-push-action@v6 with: diff --git a/src/app/menu/menu.css b/src/app/menu/menu.css index d9f8766..e651926 100644 --- a/src/app/menu/menu.css +++ b/src/app/menu/menu.css @@ -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; diff --git a/src/app/menu/menu.html b/src/app/menu/menu.html index 15f4d16..b8011e2 100644 --- a/src/app/menu/menu.html +++ b/src/app/menu/menu.html @@ -25,7 +25,33 @@ Bonsai - v{{ version }} + + @if (showInfo()) { + + } @if (keycloak.isAuthenticated()) { diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts index bd536c8..d9d2ce5 100644 --- a/src/app/menu/menu.spec.ts +++ b/src/app/menu/menu.spec.ts @@ -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); + }); + }); }); diff --git a/src/app/menu/menu.ts b/src/app/menu/menu.ts index f251149..796be6c 100644 --- a/src/app/menu/menu.ts +++ b/src/app/menu/menu.ts @@ -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(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(); } diff --git a/src/app/version/version-api.service.spec.ts b/src/app/version/version-api.service.spec.ts new file mode 100644 index 0000000..5c86db8 --- /dev/null +++ b/src/app/version/version-api.service.spec.ts @@ -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; + + 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' }); + }); +}); diff --git a/src/app/version/version-api.service.ts b/src/app/version/version-api.service.ts new file mode 100644 index 0000000..9fe986f --- /dev/null +++ b/src/app/version/version-api.service.ts @@ -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 { + return this.http.get(`${API_BASE_URL}/version`); + } +}