diff --git a/code/frontend/src/app/app.icons.ts b/code/frontend/src/app/app.icons.ts index dc2621cf..33a54e8a 100644 --- a/code/frontend/src/app/app.icons.ts +++ b/code/frontend/src/app/app.icons.ts @@ -1,4 +1,5 @@ import { + tablerAdjustmentsHorizontal, tablerLayoutDashboard, tablerFileText, tablerBell, @@ -50,9 +51,11 @@ import { tablerGripVertical, tablerFilter, tablerPalette, + tablerApps, } from '@ng-icons/tabler-icons'; export const APP_ICONS = { + tablerAdjustmentsHorizontal, tablerLayoutDashboard, tablerFileText, tablerBell, @@ -104,4 +107,5 @@ export const APP_ICONS = { tablerGripVertical, tablerFilter, tablerPalette, + tablerApps, }; diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index a11a8c3e..3a496580 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { ShellComponent } from '@layout/shell/shell.component'; import { AuthLayoutComponent } from '@layout/auth-layout/auth-layout.component'; import { authGuard, setupIncompleteGuard, loginGuard } from '@core/auth/auth.guard'; import { pendingChangesGuard } from '@core/guards/pending-changes.guard'; +import { legacyArrRedirect } from '@features/settings/arr/legacy-arr-redirect'; export const routes: Routes = [ { @@ -98,13 +99,17 @@ export const routes: Routes = [ canDeactivate: [pendingChangesGuard], }, { - path: 'arr/:type', + path: 'arr', loadComponent: () => import( - '@features/settings/arr/arr-settings.component' - ).then((m) => m.ArrSettingsComponent), + '@features/settings/arr/arr-apps.component' + ).then((m) => m.ArrAppsComponent), canDeactivate: [pendingChangesGuard], }, + { + path: 'arr/:type', + redirectTo: legacyArrRedirect, + }, { path: 'download-clients', loadComponent: () => diff --git a/code/frontend/src/app/features/settings/arr/arr-app-meta.model.ts b/code/frontend/src/app/features/settings/arr/arr-app-meta.model.ts new file mode 100644 index 00000000..e3802254 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-app-meta.model.ts @@ -0,0 +1,9 @@ +import type { SelectOption } from '@ui'; + +export interface ArrAppMeta { + displayName: string; + versionOptions: SelectOption[]; + defaultPort: number; + experimental: boolean; + iconSrc: string; +} diff --git a/code/frontend/src/app/features/settings/arr/arr-app-meta.ts b/code/frontend/src/app/features/settings/arr/arr-app-meta.ts new file mode 100644 index 00000000..63d98568 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-app-meta.ts @@ -0,0 +1,64 @@ +import type { ArrType } from '@shared/models/enums'; +import type { ArrAppMeta } from './arr-app-meta.model'; + +export const ARR_TYPES: ArrType[] = [ + 'sonarr', + 'radarr', + 'lidarr', + 'readarr', + 'whisparr', + 'sportarr', + 'lazylibrarian', +]; + +export const ARR_APP_META: Record = { + sonarr: { + displayName: 'Sonarr', + versionOptions: [{ label: 'v4', value: 4 }], + defaultPort: 8989, + experimental: false, + iconSrc: 'icons/ext/sonarr-light.svg', + }, + radarr: { + displayName: 'Radarr', + versionOptions: [{ label: 'v6', value: 6 }], + defaultPort: 7878, + experimental: false, + iconSrc: 'icons/ext/radarr-light.svg', + }, + lidarr: { + displayName: 'Lidarr', + versionOptions: [{ label: 'v3', value: 3 }], + defaultPort: 8686, + experimental: false, + iconSrc: 'icons/ext/lidarr-light.svg', + }, + readarr: { + displayName: 'Readarr', + versionOptions: [{ label: 'v0.4', value: 0.4 }], + defaultPort: 8787, + experimental: false, + iconSrc: 'icons/ext/readarr-light.svg', + }, + whisparr: { + displayName: 'Whisparr', + versionOptions: [{ label: 'v2', value: 2 }, { label: 'v3', value: 3 }], + defaultPort: 6969, + experimental: false, + iconSrc: 'icons/ext/whisparr-light.svg', + }, + sportarr: { + displayName: 'Sportarr', + versionOptions: [{ label: 'v3', value: 3 }], + defaultPort: 1867, + experimental: false, + iconSrc: 'icons/ext/sportarr-light.svg', + }, + lazylibrarian: { + displayName: 'LazyLibrarian', + versionOptions: [{ label: 'latest', value: 1 }], + defaultPort: 5299, + experimental: true, + iconSrc: 'icons/ext/lazylibrarian-light.svg', + }, +}; diff --git a/code/frontend/src/app/features/settings/arr/arr-apps.component.html b/code/frontend/src/app/features/settings/arr/arr-apps.component.html new file mode 100644 index 00000000..e24b5f04 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-apps.component.html @@ -0,0 +1,101 @@ + + +@if (loadError()) { + + + Retry + + +} @else if (loader.showSpinner() && !configResource.hasValue()) { + +} @else if (configResource.hasValue()) { +
+ @for (entry of configured(); track entry.type) { + + } @empty { + + } + + @if (unconfigured().length > 0) { + +
+

Add another app

+
+ +
+ @for (type of unconfigured(); track type) { + + } +
+
+ } +
+} + + + + +
+ + Test + + + Save + +
+
diff --git a/code/frontend/src/app/features/settings/arr/arr-apps.component.scss b/code/frontend/src/app/features/settings/arr/arr-apps.component.scss new file mode 100644 index 00000000..e698cfd3 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-apps.component.scss @@ -0,0 +1,12 @@ +@use 'settings-layout' as *; +@use 'list-layout' as *; + +:host { @include settings-page; } + +.settings-form { @include settings-form; } +.modal-form { @include modal-form; } + +.list-header { @include list-header; } + +.selection-grid { @include selection-grid; } +.selection-card { @include selection-card; } diff --git a/code/frontend/src/app/features/settings/arr/arr-apps.component.spec.ts b/code/frontend/src/app/features/settings/arr/arr-apps.component.spec.ts new file mode 100644 index 00000000..f1c96027 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-apps.component.spec.ts @@ -0,0 +1,605 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { Subject, of, throwError } from 'rxjs'; +import { ArrApi } from '@core/api/arr.api'; +import { ConfirmService } from '@core/services/confirm.service'; +import { ThemeService } from '@core/services/theme.service'; +import { ToastService } from '@core/services/toast.service'; +import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model'; +import { ArrAppsComponent } from './arr-apps.component'; + +const SONARR_INSTANCE: ArrInstance = { + id: 'sonarr-1', + enabled: true, + name: 'Main Sonarr', + url: 'http://localhost:8989', + externalUrl: 'https://sonarr.example.com', + apiKey: 'sonarr-key', + version: 4, +}; + +const RADARR_INSTANCE: ArrInstance = { + id: 'radarr-1', + enabled: true, + name: 'Main Radarr', + url: 'http://localhost:7878', + apiKey: 'radarr-key', + version: 6, +}; + +const SONARR_CONFIG: ArrConfig = { + failedImportMaxStrikes: 3, + instances: [SONARR_INSTANCE], +}; + +const RADARR_CONFIG: ArrConfig = { + failedImportMaxStrikes: -1, + instances: [RADARR_INSTANCE], +}; + +const EMPTY_CONFIG: ArrConfig = { + failedImportMaxStrikes: -1, + instances: [], +}; + +function createApi(configs: ArrConfig[] = [ + SONARR_CONFIG, RADARR_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, +]) { + return { + getConfig: vi.fn((type: string) => { + const index = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'sportarr', 'lazylibrarian'].indexOf(type); + return of(configs[index] ?? EMPTY_CONFIG); + }), + updateConfig: vi.fn(() => of(undefined)), + createInstance: vi.fn(() => of(SONARR_INSTANCE)), + updateInstance: vi.fn(() => of(SONARR_INSTANCE)), + deleteInstance: vi.fn(() => of(undefined)), + testInstance: vi.fn(() => of({ message: 'Connected' })), + }; +} + +function createToast() { + return { success: vi.fn(), error: vi.fn() }; +} + +interface Setup { + fixture: ComponentFixture; + component: ArrAppsComponent; + api: ReturnType; + toast: ReturnType; + confirm: ConfirmService; +} + +describe('ArrAppsComponent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function setup(api = createApi(), toast = createToast()): Setup { + TestBed.configureTestingModule({ + providers: [ + { provide: ArrApi, useValue: api }, + { provide: ToastService, useValue: toast }, + { provide: ThemeService, useValue: { themedIconSrc: (src: string) => src } }, + { + provide: ActivatedRoute, + useValue: { snapshot: { fragment: null } }, + }, + ], + }); + + const fixture = TestBed.createComponent(ArrAppsComponent); + fixture.detectChanges(); + + return { + fixture, + component: fixture.componentInstance, + api, + toast, + confirm: TestBed.inject(ConfirmService), + }; + } + + it('loads all arr types and separates configured from unconfigured', () => { + const { component, api } = setup(); + + expect(api.getConfig).toHaveBeenCalledTimes(7); + expect(component.configured()).toHaveLength(2); + expect(component.unconfigured()).toHaveLength(5); + expect(component.configured().map(e => e.type)).toEqual(['sonarr', 'radarr']); + expect(component.unconfigured()).toEqual(['lidarr', 'readarr', 'whisparr', 'sportarr', 'lazylibrarian']); + }); + + it('renders configured apps as arr-section components', () => { + const { fixture } = setup(); + + const sections = fixture.nativeElement.querySelectorAll('app-arr-section'); + expect(sections.length).toBe(2); + }); + + it('renders unconfigured apps as selection cards in add strip', () => { + const { fixture } = setup(); + + const addCard = fixture.nativeElement.querySelector('.list-header__title'); + expect(addCard.textContent).toContain('Add another app'); + + const cards = fixture.nativeElement.querySelectorAll('.selection-card'); + expect(cards.length).toBe(5); + }); + + it('treats a config missing the instances field as unconfigured', () => { + const configWithoutInstances = { failedImportMaxStrikes: -1 } as ArrConfig; + const api = createApi([ + configWithoutInstances, RADARR_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, + ]); + const { component } = setup(api); + + expect(component.configured().map((e) => e.type)).toEqual(['radarr']); + expect(component.unconfigured()).toContain('sonarr'); + }); + + it('shows the experimental badge on the lazylibrarian selection card only', () => { + const { fixture } = setup(); + + const cards = Array.from(fixture.nativeElement.querySelectorAll('.selection-card')) as HTMLElement[]; + const lazyLibrarianCard = cards.find((el) => el.textContent!.includes('LazyLibrarian'))!; + const otherCards = cards.filter((el) => el !== lazyLibrarianCard); + + expect(lazyLibrarianCard.querySelector('app-badge')).not.toBeNull(); + expect(lazyLibrarianCard.textContent).toContain('Experimental'); + otherCards.forEach((card) => expect(card.querySelector('app-badge')).toBeNull()); + }); + + it('scrolls to the fragment from the route once settings load', async () => { + const scrollIntoView = vi.fn(); + (Element.prototype as unknown as { scrollIntoView: typeof scrollIntoView }).scrollIntoView = scrollIntoView; + + try { + TestBed.configureTestingModule({ + providers: [ + { provide: ArrApi, useValue: createApi() }, + { provide: ToastService, useValue: createToast() }, + { provide: ThemeService, useValue: { themedIconSrc: (src: string) => src } }, + { provide: ActivatedRoute, useValue: { snapshot: { fragment: 'radarr' } } }, + ], + }); + + const fixture = TestBed.createComponent(ArrAppsComponent); + fixture.detectChanges(); + await Promise.resolve(); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' }); + } finally { + delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView; + } + }); + + it('shows the loading state while the initial config request is pending', () => { + vi.useFakeTimers(); + try { + const pending = new Subject(); + const api = createApi(); + api.getConfig.mockReturnValue(pending); + + TestBed.configureTestingModule({ + providers: [ + { provide: ArrApi, useValue: api }, + { provide: ToastService, useValue: createToast() }, + { provide: ThemeService, useValue: { themedIconSrc: (src: string) => src } }, + { provide: ActivatedRoute, useValue: { snapshot: { fragment: null } } }, + ], + }); + + const fixture = TestBed.createComponent(ArrAppsComponent); + fixture.detectChanges(); + + expect(fixture.componentInstance.configured()).toEqual([]); + expect(fixture.componentInstance.unconfigured()).toEqual([]); + + vi.advanceTimersByTime(200); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-loading-state')).not.toBeNull(); + + pending.next(EMPTY_CONFIG); + pending.complete(); + } finally { + vi.useRealTimers(); + } + }); + + it('retries the load when the Retry button is clicked', () => { + const api = createApi(); + api.getConfig.mockReturnValue(throwError(() => new Error('offline'))); + const { fixture } = setup(api); + + api.getConfig.mockReturnValue(of(EMPTY_CONFIG)); + const retryButton = Array.from(fixture.nativeElement.querySelectorAll('button')) + .find((el) => (el as HTMLElement).textContent!.trim() === 'Retry') as HTMLButtonElement; + retryButton.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.loadError()).toBe(false); + }); + + it('hides the "Add another app" card when every app is configured', () => { + const api = createApi([ + SONARR_CONFIG, RADARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, SONARR_CONFIG, + ]); + const { fixture } = setup(api); + + expect(fixture.nativeElement.querySelector('.list-header__title')).toBeNull(); + }); + + it('shows empty state when no apps are configured', () => { + const api = createApi([ + EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, EMPTY_CONFIG, + ]); + const { fixture: f } = setup(api); + + expect(f.nativeElement.textContent).toContain('No media managers yet'); + expect(f.nativeElement.textContent).toContain('Pick an app below to connect it.'); + }); + + it('opens add modal for selected unconfigured app type', () => { + const { fixture, component } = setup(); + + component.openAddModal('lidarr'); + fixture.detectChanges(); + + expect(component.modalVisible()).toBe(true); + expect(component.modalType()).toBe('lidarr'); + expect(component.editingInstance()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Add Lidarr Instance'); + }); + + it('sets version to first option when opening add modal', () => { + const { component } = setup(); + + component.openAddModal('whisparr'); + + expect(component.instanceModel().version).toBe(2); + }); + + it('creates an instance and reloads the page', () => { + const { fixture, component, api, toast } = setup(); + + component.openAddModal('sonarr'); + component.instanceForm.name().value.set('New Sonarr'); + component.instanceForm.url().value.set('http://localhost:8989'); + component.instanceForm.apiKey().value.set('key'); + fixture.detectChanges(); + + component.saveInstance(); + fixture.detectChanges(); + + expect(api.createInstance).toHaveBeenCalledWith('sonarr', { + name: 'New Sonarr', + url: 'http://localhost:8989', + externalUrl: undefined, + apiKey: 'key', + version: 4, + enabled: true, + }); + expect(toast.success).toHaveBeenCalledWith('Instance added'); + expect(component.modalVisible()).toBe(false); + }); + + it('opens edit modal for a selected instance', () => { + const { fixture, component } = setup(); + + component.openEditModal('sonarr', SONARR_INSTANCE); + fixture.detectChanges(); + + expect(component.modalVisible()).toBe(true); + expect(component.modalType()).toBe('sonarr'); + expect(component.editingInstance()).toBe(SONARR_INSTANCE); + expect(fixture.nativeElement.textContent).toContain('Edit Sonarr Instance'); + expect(component.instanceModel().name).toBe('Main Sonarr'); + }); + + it('defaults externalUrl to an empty string when the instance has none', () => { + const { component } = setup(); + + component.openEditModal('radarr', RADARR_INSTANCE); + + expect(component.instanceModel().externalUrl).toBe(''); + }); + + it('keeps a non-default version when editing after another type', () => { + const { fixture, component } = setup(); + const whisparrV3: ArrInstance = { ...SONARR_INSTANCE, id: 'whisparr-1', version: 3 }; + + component.openEditModal('sonarr', SONARR_INSTANCE); + fixture.detectChanges(); + component.modalVisible.set(false); + component.openEditModal('whisparr', whisparrV3); + fixture.detectChanges(); + + expect(component.instanceModel().version).toBe(3); + }); + + it('updates an instance by id', () => { + const { fixture, component, api, toast } = setup(); + + component.openEditModal('sonarr', SONARR_INSTANCE); + component.instanceForm.name().value.set('Renamed'); + fixture.detectChanges(); + + component.saveInstance(); + fixture.detectChanges(); + + expect(api.updateInstance).toHaveBeenCalledWith('sonarr', 'sonarr-1', { + name: 'Renamed', + url: 'http://localhost:8989', + externalUrl: 'https://sonarr.example.com', + apiKey: 'sonarr-key', + version: 4, + enabled: true, + }); + expect(toast.success).toHaveBeenCalledWith('Instance updated'); + }); + + it('tests connection with instance id when editing', () => { + const { fixture, component, api } = setup(); + + component.openEditModal('sonarr', SONARR_INSTANCE); + fixture.detectChanges(); + + component.testConnection(); + fixture.detectChanges(); + + expect(api.testInstance).toHaveBeenCalledWith('sonarr', { + url: 'http://localhost:8989', + apiKey: 'sonarr-key', + version: 4, + instanceId: 'sonarr-1', + }); + }); + + it('falls back to a default success message when the API returns none', () => { + const api = createApi(); + api.testInstance.mockReturnValue(of({ message: '' })); + const { fixture, component, toast } = setup(api); + + component.openAddModal('sonarr'); + fixture.detectChanges(); + + component.testConnection(); + fixture.detectChanges(); + + expect(toast.success).toHaveBeenCalledWith('Connection successful'); + }); + + it('tests connection without instance id when adding', () => { + const { fixture, component, api } = setup(); + + component.openAddModal('radarr'); + fixture.detectChanges(); + + component.testConnection(); + fixture.detectChanges(); + + expect(api.testInstance).toHaveBeenCalledWith('radarr', { + url: '', + apiKey: '', + version: 6, + instanceId: undefined, + }); + }); + + it('does not call the API when deleting an instance without an id', async () => { + const { component, api } = setup(); + const instanceWithoutId: ArrInstance = { ...SONARR_INSTANCE, id: '' }; + + await component.deleteInstance('sonarr', instanceWithoutId); + + expect(api.deleteInstance).not.toHaveBeenCalled(); + }); + + it('deletes an instance after confirmation', async () => { + const { fixture, component, api, toast, confirm } = setup(); + + const cancelled = component.deleteInstance('sonarr', SONARR_INSTANCE); + confirm.cancel(); + await cancelled; + + expect(api.deleteInstance).not.toHaveBeenCalled(); + + const accepted = component.deleteInstance('sonarr', SONARR_INSTANCE); + confirm.accept(); + await accepted; + fixture.detectChanges(); + + expect(api.deleteInstance).toHaveBeenCalledWith('sonarr', 'sonarr-1'); + expect(toast.success).toHaveBeenCalledWith('Instance deleted'); + }); + + it('reports pending changes while modal is edited', () => { + const { fixture, component } = setup(); + + expect(component.hasPendingChanges()).toBe(false); + + component.openAddModal('sonarr'); + fixture.detectChanges(); + expect(component.hasPendingChanges()).toBe(false); + + component.instanceForm.name().value.set('New'); + fixture.detectChanges(); + expect(component.hasPendingChanges()).toBe(true); + + component.instanceForm.url().value.set('http://localhost:8989'); + component.instanceForm.apiKey().value.set('key'); + component.saveInstance(); + fixture.detectChanges(); + + expect(component.hasPendingChanges()).toBe(false); + }); + + it('keeps an arr-section instance mounted and its unsaved edits while a delete reload is in flight', async () => { + const api = createApi(); + const { fixture, component, confirm } = setup(api); + + const sectionBefore = component.sections()[0]; + const sectionElBefore = fixture.nativeElement.querySelectorAll('app-arr-section')[0]; + sectionBefore.failedImportForm.override().value.set(true); + sectionBefore.failedImportForm.maxStrikes().value.set(9); + fixture.detectChanges(); + + const pending = new Subject(); + api.getConfig.mockReturnValue(pending); + + const deleted = component.deleteInstance('radarr', RADARR_INSTANCE); + confirm.accept(); + await deleted; + fixture.detectChanges(); + + expect(component.loader.loading()).toBe(true); + expect(fixture.nativeElement.querySelectorAll('app-arr-section')[0]).toBe(sectionElBefore); + expect(component.sections()[0]).toBe(sectionBefore); + + pending.next(SONARR_CONFIG); + pending.complete(); + fixture.detectChanges(); + + const sectionAfter = component.sections()[0]; + expect(sectionAfter).toBe(sectionBefore); + expect(sectionAfter.failedImportModel()).toEqual({ override: true, maxStrikes: 9 }); + }); + + it('reports pending changes from arr-section components', () => { + const { component } = setup(); + + expect(component.hasPendingChanges()).toBe(false); + + const sections = component.sections(); + expect(sections.length).toBeGreaterThan(0); + vi.spyOn(sections[0], 'hasPendingChanges').mockReturnValue(true); + expect(component.hasPendingChanges()).toBe(true); + }); + + it('shows connection error state and recovers on retry', () => { + const api = createApi(); + api.getConfig.mockReturnValue(throwError(() => new Error('offline'))); + const { fixture, component, toast } = setup(api); + + expect(component.loadError()).toBe(true); + expect(toast.error).toHaveBeenCalledWith('Failed to load media manager settings: offline'); + expect(fixture.nativeElement.textContent).toContain('Could not connect to server'); + + api.getConfig.mockReturnValue(of(EMPTY_CONFIG)); + component.retry(); + fixture.detectChanges(); + + expect(component.loadError()).toBe(false); + }); + + it('keeps modal open and reports error when save fails', () => { + const api = createApi(); + api.createInstance.mockReturnValue(throwError(() => ({ message: 'Name exists' }))); + const { fixture: f, component, toast } = setup(api); + + component.openAddModal('sonarr'); + component.instanceForm.name().value.set('New'); + component.instanceForm.url().value.set('http://localhost:8989'); + component.instanceForm.apiKey().value.set('key'); + f.detectChanges(); + + component.saveInstance(); + f.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Name exists'); + expect(component.modalVisible()).toBe(true); + }); + + it('does not call the API when saving an invalid form', () => { + const { component, api } = setup(); + + component.openAddModal('sonarr'); + + component.saveInstance(); + + expect(api.createInstance).not.toHaveBeenCalled(); + expect(api.updateInstance).not.toHaveBeenCalled(); + }); + + it('toasts the error when deleting an instance fails', async () => { + const api = createApi(); + api.deleteInstance.mockReturnValue(throwError(() => ({ message: 'Delete failed' }))); + const { component, toast, confirm } = setup(api); + + const deleted = component.deleteInstance('sonarr', SONARR_INSTANCE); + confirm.accept(); + await deleted; + + expect(toast.error).toHaveBeenCalledWith('Delete failed'); + }); + + it('shows error when test connection fails', () => { + const api = createApi(); + api.testInstance.mockReturnValue(throwError(() => ({ message: 'Connection refused' }))); + const { fixture: f, component, toast } = setup(api); + + component.openAddModal('sonarr'); + f.detectChanges(); + + component.testConnection(); + f.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Connection refused'); + expect(component.testing()).toBe(false); + }); + + it('emits from arr-section when add is clicked', () => { + const { component } = setup(); + + const sections = component.sections(); + expect(sections.length).toBeGreaterThan(0); + const openAddSpy = vi.spyOn(component, 'openAddModal'); + sections[0].onAddClick(); + expect(openAddSpy).toHaveBeenCalled(); + }); + + it('emits from arr-section when edit is clicked', () => { + const { component } = setup(); + + const sections = component.sections(); + expect(sections.length).toBeGreaterThan(0); + const openEditSpy = vi.spyOn(component, 'openEditModal'); + sections[0].onEditClick(SONARR_INSTANCE); + expect(openEditSpy).toHaveBeenCalled(); + }); + + it('emits from arr-section when delete is clicked', () => { + const { component } = setup(); + + const sections = component.sections(); + expect(sections.length).toBeGreaterThan(0); + const deleteSpy = vi.spyOn(component, 'deleteInstance'); + sections[0].onDeleteClick(SONARR_INSTANCE); + expect(deleteSpy).toHaveBeenCalled(); + }); + + it('disables save button when form has errors', () => { + const { fixture, component } = setup(); + + component.openAddModal('sonarr'); + fixture.detectChanges(); + + const saveButton = Array.from(fixture.nativeElement.querySelectorAll('[modal-footer] button')) + .find((el) => (el as HTMLElement).textContent!.trim() === 'Save') as HTMLButtonElement | undefined; + expect(saveButton).toBeDefined(); + expect(saveButton!.disabled).toBe(true); + }); + + it('validates required fields', () => { + const { fixture, component } = setup(); + + component.openAddModal('sonarr'); + fixture.detectChanges(); + + expect(component.instanceForm.name().errors()[0]?.message).toBe('Name is required'); + expect(component.instanceForm.url().errors()[0]?.message).toBe('URL is required'); + expect(component.instanceForm.apiKey().errors()[0]?.message).toBe('API key is required'); + }); +}); diff --git a/code/frontend/src/app/features/settings/arr/arr-apps.component.ts b/code/frontend/src/app/features/settings/arr/arr-apps.component.ts new file mode 100644 index 00000000..a5e0b9b8 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-apps.component.ts @@ -0,0 +1,238 @@ +import { + Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, viewChildren, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { form, required, FormField } from '@angular/forms/signals'; +import { PageHeaderComponent } from '@layout/page-header/page-header.component'; +import { + CardComponent, ButtonComponent, InputComponent, ToggleComponent, + SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent, +} from '@ui'; +import { ArrApi } from '@core/api/arr.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { ToastService } from '@core/services/toast.service'; +import { ThemeService } from '@core/services/theme.service'; +import { ConfirmService } from '@core/services/confirm.service'; +import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model'; +import type { ArrType } from '@shared/models/enums'; +import { HasPendingChanges } from '@core/guards/pending-changes.guard'; +import { createSettingsResource } from '@shared/utils/settings-resource.util'; +import { ARR_TYPES, ARR_APP_META } from './arr-app-meta'; +import { ArrSectionComponent } from './arr-section.component'; + +interface ArrInstanceFormModel { + name: string; + url: string; + externalUrl: string; + apiKey: string; + version: number; + enabled: boolean; +} + +@Component({ + selector: 'app-arr-apps', + standalone: true, + imports: [ + PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, ToggleComponent, + SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent, + FormField, ArrSectionComponent, + ], + templateUrl: './arr-apps.component.html', + styleUrl: './arr-apps.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ArrAppsComponent implements HasPendingChanges { + private readonly api = inject(ArrApi); + private readonly toast = inject(ToastService); + private readonly confirmService = inject(ConfirmService); + private readonly route = inject(ActivatedRoute); + protected readonly themeService = inject(ThemeService); + + protected readonly ARR_APP_META = ARR_APP_META; + + private readonly settings = createSettingsResource({ + params: () => true, + load: () => forkJoin(ARR_TYPES.map(type => + this.api.getConfig(type), + )), + errorMessage: 'Failed to load media manager settings', + }); + readonly configResource = this.settings.resource; + + readonly loader = this.settings.loader; + readonly loadError = this.settings.loadError; + + private readonly allConfigs = computed(() => { + if (!this.configResource.hasValue()) { + return []; + } + const configs = this.configResource.value(); + return ARR_TYPES.map((type, index) => ({ + type, + config: configs[index], + })); + }); + + readonly configured = computed(() => + this.allConfigs().filter(e => (e.config.instances ?? []).length > 0), + ); + + readonly unconfigured = computed(() => + this.allConfigs().filter(e => (e.config.instances ?? []).length === 0).map(e => e.type), + ); + + readonly sections = viewChildren(ArrSectionComponent); + + // Modal state + readonly modalType = signal('sonarr'); + readonly modalVisible = signal(false); + readonly editingInstance = signal(null); + readonly testing = signal(false); + readonly saving = signal(false); + + readonly modalMeta = computed(() => ARR_APP_META[this.modalType()]); + readonly modalDisplayName = computed(() => this.modalMeta().displayName); + readonly modalIsEdit = computed(() => !!this.editingInstance()); + + readonly instanceModel = signal({ + name: '', url: '', externalUrl: '', apiKey: '', version: 3, enabled: true, + }); + readonly instanceForm = form(this.instanceModel, (p) => { + required(p.name, { message: 'Name is required' }); + required(p.url, { message: 'URL is required' }); + required(p.apiKey, { message: 'API key is required' }); + }); + + readonly hasModalErrors = computed(() => this.instanceForm().invalid()); + + private readonly openSnapshot = signal(''); + /** Reloads after a save must not yank the page back to the deep-linked section. */ + private fragmentScrolled = false; + private readonly modalDirty = computed(() => + this.modalVisible() && JSON.stringify(this.instanceModel()) !== this.openSnapshot()); + + constructor() { + effect(() => { + if (!this.fragmentScrolled && !this.configResource.isLoading() && this.configResource.hasValue()) { + this.fragmentScrolled = true; + const fragment = untracked(() => this.route.snapshot.fragment); + if (fragment) { + queueMicrotask(() => { + document.getElementById(fragment)?.scrollIntoView({ block: 'start' }); + }); + } + } + }); + } + + retry(): void { + this.settings.retry(); + } + + openAddModal(type: ArrType): void { + this.modalType.set(type); + this.editingInstance.set(null); + const options = ARR_APP_META[type].versionOptions; + this.instanceModel.set({ + name: '', url: '', externalUrl: '', apiKey: '', + version: options.length > 0 ? (options[0].value as number) : 3, + enabled: true, + }); + this.openSnapshot.set(JSON.stringify(this.instanceModel())); + this.modalVisible.set(true); + } + + openEditModal(type: ArrType, instance: ArrInstance): void { + this.modalType.set(type); + this.editingInstance.set(instance); + this.instanceModel.set({ + name: instance.name, + url: instance.url, + externalUrl: instance.externalUrl ?? '', + apiKey: instance.apiKey, + version: instance.version, + enabled: instance.enabled, + }); + this.openSnapshot.set(JSON.stringify(this.instanceModel())); + this.modalVisible.set(true); + } + + testConnection(): void { + const m = this.instanceModel(); + const request: TestArrInstanceRequest = { + url: m.url, + apiKey: m.apiKey, + version: m.version ?? 3, + instanceId: this.editingInstance()?.id, + }; + this.testing.set(true); + this.api.testInstance(this.modalType(), request).subscribe({ + next: (result) => { + this.toast.success(result.message || 'Connection successful'); + this.testing.set(false); + }, + error: (err: ApiError) => { + this.toast.error(err.message); + this.testing.set(false); + }, + }); + } + + saveInstance(): void { + if (this.instanceForm().invalid()) { + return; + } + const m = this.instanceModel(); + const dto: CreateArrInstanceDto = { + name: m.name, + url: m.url, + externalUrl: m.externalUrl || undefined, + apiKey: m.apiKey, + version: m.version ?? 3, + enabled: m.enabled, + }; + + this.saving.set(true); + const editing = this.editingInstance(); + const obs = editing?.id + ? this.api.updateInstance(this.modalType(), editing.id, dto) + : this.api.createInstance(this.modalType(), dto); + + obs.subscribe({ + next: () => { + this.toast.success(editing ? 'Instance updated' : 'Instance added'); + this.modalVisible.set(false); + this.saving.set(false); + this.configResource.reload(); + }, + error: (err: ApiError) => { + this.toast.error(err.message); + this.saving.set(false); + }, + }); + } + + async deleteInstance(type: ArrType, instance: ArrInstance): Promise { + if (!instance.id) return; + const confirmed = await this.confirmService.confirm({ + title: 'Delete Instance', + message: `Are you sure you want to delete "${instance.name}"? This action cannot be undone.`, + confirmLabel: 'Delete', + destructive: true, + }); + if (!confirmed) return; + + this.api.deleteInstance(type, instance.id).subscribe({ + next: () => { + this.toast.success('Instance deleted'); + this.configResource.reload(); + }, + error: (err: ApiError) => this.toast.error(err.message), + }); + } + + hasPendingChanges(): boolean { + return this.modalDirty() || this.sections().some(s => s.hasPendingChanges()); + } +} diff --git a/code/frontend/src/app/features/settings/arr/arr-section.component.html b/code/frontend/src/app/features/settings/arr/arr-section.component.html new file mode 100644 index 00000000..af6dc347 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-section.component.html @@ -0,0 +1,57 @@ + +
+
+ +

{{ meta().displayName }} instances

+ @if (meta().experimental) { + Experimental + } +
+
+ + + Strikes + + Add +
+
+ + @if (panelVisible()) { +
+
+ + @if (failedImportForm.override().value()) { + + } +
+
+ + {{ failedImportSaved() ? 'Saved!' : 'Save Settings' }} + +
+
+ } + + @for (instance of instances(); track instance.id) { +
+
+ {{ instance.name }} + + {{ instance.enabled ? 'Enabled' : 'Disabled' }} + + {{ instance.url }} +
+
+ Edit + Delete +
+
+ } +
diff --git a/code/frontend/src/app/features/settings/arr/arr-section.component.scss b/code/frontend/src/app/features/settings/arr/arr-section.component.scss new file mode 100644 index 00000000..2e75b632 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-section.component.scss @@ -0,0 +1,58 @@ +@use 'settings-layout' as *; +@use 'list-layout' as *; + +:host { + display: block; +} + +.section-header { + @include list-header; + flex-wrap: wrap; + gap: var(--space-3); + + &__title { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; + } + + &__name { + margin: 0; + font: inherit; + } + + &__icon { + width: 24px; + height: 24px; + flex-shrink: 0; + } + + &__actions { + display: flex; + gap: var(--space-2); + } +} + +.strikes-panel { + padding: var(--space-4) 0; + margin-bottom: var(--space-2); + border-top: 1px solid var(--divider); + border-bottom: 1px solid var(--divider); +} + +.form-stack { @include form-stack; } +.form-actions { @include form-actions; } + +.item-row { + @include item-row; + + &__url { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/code/frontend/src/app/features/settings/arr/arr-section.component.spec.ts b/code/frontend/src/app/features/settings/arr/arr-section.component.spec.ts new file mode 100644 index 00000000..9fb13523 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-section.component.spec.ts @@ -0,0 +1,445 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, input } from '@angular/core'; +import { of, throwError } from 'rxjs'; +import { ArrApi } from '@core/api/arr.api'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { ToastService } from '@core/services/toast.service'; +import { ThemeService } from '@core/services/theme.service'; +import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model'; +import type { ArrType } from '@shared/models/enums'; +import { ArrSectionComponent } from './arr-section.component'; + +const INSTANCE: ArrInstance = { + id: 'instance-1', + enabled: true, + name: 'Main Sonarr', + url: 'http://localhost:8989', + externalUrl: 'https://sonarr.example.com', + apiKey: 'abc123', + version: 4, +}; + +const CONFIG: ArrConfig = { + failedImportMaxStrikes: 3, + instances: [INSTANCE], +}; + +function createApi() { + return { + updateConfig: vi.fn(() => of(undefined)), + }; +} + +function createToast() { + return { success: vi.fn(), error: vi.fn() }; +} + +function createTheme() { + return { + themedIconSrc: vi.fn((src: string) => src), + }; +} + +interface Setup { + fixture: ComponentFixture; + component: ArrSectionComponent; + api: ReturnType; + toast: ReturnType; + theme: ReturnType; +} + +@Component({ + standalone: true, + imports: [ArrSectionComponent], + template: ` + + `, +}) +class HostComponent { + type = input.required(); + config = input.required(); + reloading = input(false); + onAdd = vi.fn(); + onEdit = vi.fn(); + onDelete = vi.fn(); +} + +function text(fixture: ComponentFixture, selector: string): string[] { + return Array.from(fixture.nativeElement.querySelectorAll(selector)).map((el) => + (el as HTMLElement).textContent!.trim(), + ); +} + +describe('ArrSectionComponent', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function setup( + type: ArrType = 'sonarr', config = CONFIG, api = createApi(), toast = createToast(), theme = createTheme(), + ): Setup { + TestBed.configureTestingModule({ + providers: [ + { provide: ArrApi, useValue: api }, + { provide: ToastService, useValue: toast }, + { provide: ThemeService, useValue: theme }, + ], + }); + + const fixture = TestBed.createComponent(HostComponent); + fixture.componentRef.setInput('type', type); + fixture.componentRef.setInput('config', config); + fixture.detectChanges(); + + return { + fixture, + component: fixture.debugElement.query((el) => el.name === 'app-arr-section').componentInstance, + api, + toast, + theme, + }; + } + + it('renders instances from the config input', () => { + const { fixture, component } = setup('sonarr'); + + expect(component.instances()).toEqual([INSTANCE]); + expect(text(fixture, '.item-row__name')).toEqual(['Main Sonarr']); + expect(text(fixture, '.item-row__url')).toEqual(['http://localhost:8989']); + expect(component.hasPendingChanges()).toBe(false); + }); + + it('treats a config with no instances field as having none', () => { + const configWithoutInstances = { failedImportMaxStrikes: -1 } as ArrConfig; + const { component } = setup('sonarr', configWithoutInstances); + + expect(component.instances()).toEqual([]); + }); + + it('shows a disabled badge for a disabled instance', () => { + const disabledInstance: ArrInstance = { ...INSTANCE, enabled: false }; + const { fixture } = setup('sonarr', { failedImportMaxStrikes: 3, instances: [disabledInstance] }); + + expect(text(fixture, '.item-row__info app-badge')).toEqual(['Disabled']); + }); + + it('emits add output when Add button is clicked', () => { + const { fixture, component } = setup('sonarr'); + const hostComponent = fixture.componentInstance; + + component.onAddClick(); + + expect(hostComponent.onAdd).toHaveBeenCalled(); + }); + + it('emits edit output when Edit button is clicked', () => { + const { fixture, component } = setup('sonarr'); + + component.onEditClick(INSTANCE); + + expect(fixture.componentInstance.onEdit).toHaveBeenCalledWith(INSTANCE); + }); + + it('emits delete output when Delete button is clicked', () => { + const { fixture, component } = setup('sonarr'); + const hostComponent = fixture.componentInstance; + + component.onDeleteClick(INSTANCE); + + expect(hostComponent.onDelete).toHaveBeenCalledWith(INSTANCE); + }); + + it('shows the experimental badge only for an experimental arr type', () => { + const { fixture } = setup('lazylibrarian', { ...CONFIG, instances: [] }); + + expect(fixture.nativeElement.querySelector('.section-header__title app-badge')).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Experimental'); + }); + + it('hides the experimental badge for a non-experimental arr type', () => { + const { fixture } = setup('sonarr'); + + expect(fixture.nativeElement.querySelector('.section-header__title app-badge')).toBeNull(); + }); + + it('themes the header icon through the theme service', () => { + const { theme } = setup('sonarr'); + + expect(theme.themedIconSrc).toHaveBeenCalledWith('icons/ext/sonarr-light.svg'); + }); + + it('shows the max strikes input only while the override is on', () => { + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + component.strikesOpen.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-number-input')).toBeNull(); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-number-input')).not.toBeNull(); + + component.failedImportForm.override().value.set(false); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-number-input')).toBeNull(); + }); + + it('sets host id to the arr type', () => { + const { fixture } = setup('radarr'); + + const hostEl = fixture.nativeElement.querySelector('app-arr-section'); + expect(hostEl.id).toBe('radarr'); + }); + + it('shows the strikes panel only when strikesOpen, dirty, or hasErrors is true', () => { + const { fixture, component } = setup('sonarr'); + + expect(fixture.nativeElement.querySelector('.strikes-panel')).toBeNull(); + expect(component.panelVisible()).toBe(false); + + component.strikesOpen.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.strikes-panel')).not.toBeNull(); + expect(component.panelVisible()).toBe(true); + }); + + it('reflects panelVisible on the strikes toggle button aria-expanded attribute', () => { + const { fixture } = setup('sonarr'); + + const strikesButton = Array.from(fixture.nativeElement.querySelectorAll('.section-header__actions button')) + .find((el) => (el as HTMLElement).textContent!.includes('Strikes')) as HTMLButtonElement; + expect(strikesButton.getAttribute('aria-expanded')).toBe('false'); + + strikesButton.click(); + fixture.detectChanges(); + + expect(strikesButton.getAttribute('aria-expanded')).toBe('true'); + }); + + it('seeds the override toggle off with the config failedImportMaxStrikes of -1', () => { + const { component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 }); + expect(component.hasPendingChanges()).toBe(false); + }); + + it('seeds the override toggle on with the config failedImportMaxStrikes value', () => { + const { component } = setup('sonarr', { failedImportMaxStrikes: 5, instances: [] }); + + expect(component.failedImportModel()).toEqual({ override: true, maxStrikes: 5 }); + expect(component.hasPendingChanges()).toBe(false); + }); + + it('enables the override, sets a value and saves it', () => { + const { fixture, component, api, toast } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + component.failedImportForm.maxStrikes().value.set(5); + fixture.detectChanges(); + + expect(component.hasPendingChanges()).toBe(true); + + component.saveFailedImport(); + fixture.detectChanges(); + + expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: 5 }); + expect(toast.success).toHaveBeenCalledWith('Failed import settings saved'); + expect(component.hasPendingChanges()).toBe(false); + }); + + it('saves via the Save Settings button click and shows the Saved! label', () => { + const { fixture, component, api } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.strikesOpen.set(true); + component.failedImportForm.override().value.set(true); + component.failedImportForm.maxStrikes().value.set(5); + fixture.detectChanges(); + + const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement; + saveButton.click(); + fixture.detectChanges(); + + expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: 5 }); + expect(saveButton.textContent!.trim()).toBe('Saved!'); + }); + + it('disables Save Settings while the parent reloads', () => { + const { fixture, component } = setup(); + + component.strikesOpen.set(true); + component.failedImportForm.maxStrikes().value.set(9); + fixture.componentRef.setInput('reloading', true); + fixture.detectChanges(); + + const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement; + expect(component.hasPendingChanges()).toBe(true); + expect(saveButton.disabled).toBe(true); + + fixture.componentRef.setInput('reloading', false); + fixture.detectChanges(); + + expect(saveButton.disabled).toBe(false); + }); + + it('disabling the override sends -1', () => { + const { fixture, component, api } = setup('sonarr', { failedImportMaxStrikes: 5, instances: [] }); + + component.failedImportForm.override().value.set(false); + fixture.detectChanges(); + + component.saveFailedImport(); + fixture.detectChanges(); + + expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: -1 }); + }); + + it('rejects 1 and 2 strikes, accepts 0, and disables save while invalid', () => { + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + + component.failedImportForm.maxStrikes().value.set(1); + fixture.detectChanges(); + expect(component.failedImportHasErrors()).toBe(true); + expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Minimum value is 3'); + + component.failedImportForm.maxStrikes().value.set(2); + fixture.detectChanges(); + expect(component.failedImportHasErrors()).toBe(true); + + component.failedImportForm.maxStrikes().value.set(0); + fixture.detectChanges(); + expect(component.failedImportHasErrors()).toBe(false); + + component.failedImportForm.maxStrikes().value.set(1); + fixture.detectChanges(); + const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement; + expect(saveButton.disabled).toBe(true); + }); + + it('rejects fractional failed import strikes', () => { + const { fixture, component, api } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + + component.failedImportForm.maxStrikes().value.set(3.5); + fixture.detectChanges(); + + expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Value must be a whole number'); + expect(component.failedImportHasErrors()).toBe(true); + + component.saveFailedImport(); + fixture.detectChanges(); + + expect(api.updateConfig).not.toHaveBeenCalled(); + }); + + it.each([ + [null, 'This field is required'], + [-5, 'Value cannot be negative'], + [5001, 'Value cannot exceed 5000'], + ])('rejects a failed import maxStrikes of %s with "%s"', (maxStrikes, message) => { + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + + component.failedImportForm.maxStrikes().value.set(maxStrikes); + fixture.detectChanges(); + + expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe(message); + }); + + it('accepts a failed import maxStrikes of 5000', () => { + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + + component.failedImportForm.maxStrikes().value.set(5000); + fixture.detectChanges(); + + expect(component.failedImportForm.maxStrikes().errors().length).toBe(0); + }); + + it('ignores an invalid failed import maxStrikes while the override is off', () => { + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.maxStrikes().value.set(1); + fixture.detectChanges(); + + expect(component.failedImportForm.maxStrikes().errors().length).toBe(0); + expect(component.failedImportHasErrors()).toBe(false); + }); + + it('stays clean when the override is toggled back off after editing the hidden value', () => { + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + component.failedImportForm.maxStrikes().value.set(9999); + fixture.detectChanges(); + component.failedImportForm.override().value.set(false); + fixture.detectChanges(); + + expect(component.hasPendingChanges()).toBe(false); + expect(component.failedImportHasErrors()).toBe(false); + }); + + it('toasts the error when saving the failed import override fails', () => { + const api = createApi(); + api.updateConfig.mockReturnValue(throwError(() => new ApiError('Save failed'))); + const toast = createToast(); + const { fixture, component } = setup('sonarr', { failedImportMaxStrikes: -1, instances: [] }, api, toast); + + component.failedImportForm.override().value.set(true); + fixture.detectChanges(); + + component.saveFailedImport(); + fixture.detectChanges(); + + expect(toast.error).toHaveBeenCalledWith('Save failed'); + expect(component.failedImportSaving()).toBe(false); + }); + + it('keeps unsaved failed import edits when the config input changes with same type', () => { + const api = createApi(); + const { fixture, component } = setup('sonarr', CONFIG, api); + + component.failedImportForm.maxStrikes().value.set(9); + fixture.detectChanges(); + + fixture.componentRef.setInput('config', { ...CONFIG, failedImportMaxStrikes: 7 }); + fixture.detectChanges(); + + expect(component.failedImportModel().maxStrikes).toBe(9); + expect(component.hasPendingChanges()).toBe(true); + }); + + it('reseeds the failed import settings when the type input changes despite unsaved edits', () => { + const { fixture, component } = setup('sonarr'); + + component.failedImportForm.maxStrikes().value.set(9); + fixture.detectChanges(); + + fixture.componentRef.setInput('type', 'radarr' as ArrType); + fixture.componentRef.setInput('config', { ...CONFIG, failedImportMaxStrikes: -1 }); + fixture.detectChanges(); + + expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 }); + expect(component.hasPendingChanges()).toBe(false); + }); +}); diff --git a/code/frontend/src/app/features/settings/arr/arr-section.component.ts b/code/frontend/src/app/features/settings/arr/arr-section.component.ts new file mode 100644 index 00000000..196f49e3 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/arr-section.component.ts @@ -0,0 +1,153 @@ +import { + Component, ChangeDetectionStrategy, inject, signal, input, output, computed, effect, untracked, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { form, validate, FormField } from '@angular/forms/signals'; +import { + CardComponent, ButtonComponent, NumberInputComponent, ToggleComponent, BadgeComponent, +} from '@ui'; +import { ArrApi } from '@core/api/arr.api'; +import { ToastService } from '@core/services/toast.service'; +import { ThemeService } from '@core/services/theme.service'; +import { ArrInstance, ArrConfig } from '@shared/models/arr-config.model'; +import type { ArrType } from '@shared/models/enums'; +import { createDirtyTracker } from '@shared/utils/dirty-tracker.util'; +import { saveSettings } from '@shared/utils/settings-resource.util'; +import { ARR_APP_META } from './arr-app-meta'; + +interface FailedImportFormModel { + override: boolean; + maxStrikes: number | null; +} + +@Component({ + selector: 'app-arr-section', + standalone: true, + imports: [ + CardComponent, ButtonComponent, NumberInputComponent, ToggleComponent, BadgeComponent, + NgIcon, FormField, + ], + host: { + '[attr.id]': 'type()', + }, + templateUrl: './arr-section.component.html', + styleUrl: './arr-section.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ArrSectionComponent { + private readonly api = inject(ArrApi); + private readonly toast = inject(ToastService); + private readonly themeService = inject(ThemeService); + + readonly type = input.required(); + readonly config = input.required(); + readonly reloading = input(false); + + readonly add = output(); + readonly edit = output(); + readonly delete = output(); + + readonly meta = computed(() => ARR_APP_META[this.type()]); + readonly instances = computed(() => this.config().instances ?? []); + + readonly strikesOpen = signal(false); + + readonly failedImportSaving = signal(false); + readonly failedImportSaved = signal(false); + readonly failedImportModel = signal({ override: false, maxStrikes: 3 }); + readonly failedImportForm = form(this.failedImportModel, (p) => { + validate(p.maxStrikes, ({ value, valueOf }) => { + if (!valueOf(p.override)) { + return undefined; + } + if (value() === null) { + return { kind: 'required', message: 'This field is required' }; + } + if (!Number.isInteger(value())) { + return { kind: 'integer', message: 'Value must be a whole number' }; + } + if (value()! < 0) { + return { kind: 'min', message: 'Value cannot be negative' }; + } + if (value()! > 0 && value()! < 3) { + return { kind: 'min', message: 'Minimum value is 3' }; + } + if (value()! > 5000) { + return { kind: 'max', message: 'Value cannot exceed 5000' }; + } + return undefined; + }); + }); + readonly failedImportHasErrors = computed(() => this.failedImportForm().invalid()); + + private readonly failedImportDirtyTracker = createDirtyTracker( + this.failedImportModel, + (m) => (m.override ? m.maxStrikes : -1), + ); + readonly failedImportDirty = this.failedImportDirtyTracker.dirty; + private seededType: ArrType | null = null; + + readonly panelVisible = computed(() => + this.strikesOpen() || this.failedImportDirty() || this.failedImportHasErrors(), + ); + + constructor() { + effect(() => { + const type = this.type(); + const config = this.config(); + untracked(() => { + if (this.seededType === type && this.failedImportDirty()) { + return; + } + this.seededType = type; + this.failedImportModel.set( + config.failedImportMaxStrikes >= 0 + ? { override: true, maxStrikes: config.failedImportMaxStrikes } + : { override: false, maxStrikes: 3 }, + ); + this.failedImportDirtyTracker.markSaved(); + }); + }); + } + + themedIconSrc(src: string): string { + return this.themeService.themedIconSrc(src); + } + + toggleStrikesPanel(): void { + this.strikesOpen.update((v) => !v); + } + + saveFailedImport(): void { + if (this.failedImportForm().invalid()) { + return; + } + const m = this.failedImportModel(); + const maxStrikes = m.override ? (m.maxStrikes ?? 3) : -1; + + saveSettings({ + request: this.api.updateConfig(this.type(), { failedImportMaxStrikes: maxStrikes }), + toast: this.toast, + saving: this.failedImportSaving, + saved: this.failedImportSaved, + successMessage: 'Failed import settings saved', + onSaved: () => this.failedImportDirtyTracker.markSaved(m), + }); + } + + onAddClick(): void { + this.add.emit(); + } + + onEditClick(instance: ArrInstance): void { + this.edit.emit(instance); + } + + onDeleteClick(instance: ArrInstance): void { + this.delete.emit(instance); + } + + hasPendingChanges(): boolean { + return this.failedImportDirty(); + } +} diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.html b/code/frontend/src/app/features/settings/arr/arr-settings.component.html deleted file mode 100644 index a133e560..00000000 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.html +++ /dev/null @@ -1,110 +0,0 @@ - - -@if (loadError()) { - - - Retry - - -} @else if (loader.showSpinner()) { - -} @else if (!loader.loading()) { -
- -
- - @if (failedImportForm.override().value()) { - - } -
-
- - {{ failedImportSaved() ? 'Saved!' : 'Save Settings' }} - -
-
- - -
-

Instances

- - Add Instance - -
- - @for (instance of instances(); track instance.id) { -
-
- {{ instance.name }} - - {{ instance.enabled ? 'Enabled' : 'Disabled' }} - - {{ instance.url }} -
-
- Edit - Delete -
-
- } @empty { - - } -
-
-} - - - - -
- - Test - - - Save - -
-
diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.scss b/code/frontend/src/app/features/settings/arr/arr-settings.component.scss deleted file mode 100644 index d4e89389..00000000 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.scss +++ /dev/null @@ -1,26 +0,0 @@ -@use 'settings-layout' as *; -@use 'list-layout' as *; - -:host { @include settings-page; } - -.settings-form { @include settings-form; } -.modal-form { @include modal-form; } -.form-stack { @include form-stack; } -.form-actions { @include form-actions; } - -.instances-header { - @include list-header; -} - -.instance-row { - @include item-row; - - &__url { - font-size: var(--font-size-xs); - color: var(--text-tertiary); - font-family: var(--font-mono); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts b/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts deleted file mode 100644 index 04633cd8..00000000 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.spec.ts +++ /dev/null @@ -1,675 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; -import { ArrApi } from '@core/api/arr.api'; -import { ApiError } from '@core/interceptors/error.interceptor'; -import { ConfirmService } from '@core/services/confirm.service'; -import { ToastService } from '@core/services/toast.service'; -import { ArrConfig, ArrInstance } from '@shared/models/arr-config.model'; -import { ArrSettingsComponent } from './arr-settings.component'; - -const INSTANCE: ArrInstance = { - id: 'instance-1', - enabled: true, - name: 'Main Sonarr', - url: 'http://localhost:8989', - externalUrl: 'https://sonarr.example.com', - apiKey: 'abc123', - version: 4, -}; - -const DISABLED_INSTANCE: ArrInstance = { - id: 'instance-2', - enabled: false, - name: 'Backup Sonarr', - url: 'http://localhost:8990', - apiKey: 'def456', - version: 4, -}; - -const CONFIG: ArrConfig = { - failedImportMaxStrikes: 3, - instances: [INSTANCE, DISABLED_INSTANCE], -}; - -function createApi(config: ArrConfig = CONFIG) { - return { - getConfig: vi.fn(() => of(config)), - updateConfig: vi.fn(() => of(undefined)), - createInstance: vi.fn(() => of(INSTANCE)), - updateInstance: vi.fn(() => of(INSTANCE)), - deleteInstance: vi.fn(() => of(undefined)), - testInstance: vi.fn(() => of({ message: 'Connected to Sonarr v4' })), - }; -} - -function createToast() { - return { success: vi.fn(), error: vi.fn() }; -} - -interface Setup { - fixture: ComponentFixture; - component: ArrSettingsComponent; - api: ReturnType; - toast: ReturnType; - confirm: ConfirmService; -} - -function text(fixture: ComponentFixture, selector: string): string[] { - return Array.from(fixture.nativeElement.querySelectorAll(selector)).map((el) => - (el as HTMLElement).textContent!.trim(), - ); -} - -describe('ArrSettingsComponent', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - function setup(type = 'sonarr', api = createApi(), toast = createToast()): Setup { - TestBed.configureTestingModule({ - providers: [ - { provide: ArrApi, useValue: api }, - { provide: ToastService, useValue: toast }, - ], - }); - - const fixture = TestBed.createComponent(ArrSettingsComponent); - fixture.componentRef.setInput('type', type); - fixture.detectChanges(); - - return { - fixture, - component: fixture.componentInstance, - api, - toast, - confirm: TestBed.inject(ConfirmService), - }; - } - - it('loads the instances for the routed arr type and renders them', () => { - const { fixture, component, api } = setup('sonarr'); - - expect(api.getConfig).toHaveBeenCalledWith('sonarr'); - expect(component.displayName()).toBe('Sonarr'); - expect(component.instances()).toEqual([INSTANCE, DISABLED_INSTANCE]); - expect(text(fixture, '.instance-row__name')).toEqual(['Main Sonarr', 'Backup Sonarr']); - expect(text(fixture, '.instance-row__url')).toEqual([ - 'http://localhost:8989', - 'http://localhost:8990', - ]); - expect(fixture.nativeElement.textContent).toContain('Sonarr Settings'); - expect(component.hasPendingChanges()).toBe(false); - }); - - it('tags the LazyLibrarian page as experimental', () => { - const { fixture } = setup('lazylibrarian'); - - const heading = fixture.nativeElement.querySelector('.page-header__heading') as HTMLElement; - - expect(heading.textContent).toContain('LazyLibrarian Settings'); - expect(heading.querySelector('.badge')!.textContent!.trim()).toBe('Experimental'); - }); - - it('leaves the other arr pages untagged', () => { - const { fixture } = setup('sonarr'); - - expect(fixture.nativeElement.querySelector('.page-header__heading .badge')).toBeNull(); - }); - - it('shows an empty state when the arr type has no instances', () => { - const { fixture, component } = setup('radarr', createApi({ failedImportMaxStrikes: 0, instances: [] })); - - expect(component.instances()).toEqual([]); - expect(fixture.nativeElement.textContent).toContain('No instances configured'); - expect(fixture.nativeElement.textContent).toContain('Add a Radarr instance to get started.'); - }); - - it('offers only the api versions supported by the arr type and defaults to the first one', () => { - const { fixture, component } = setup('whisparr'); - - expect(component.versionOptions()).toEqual([ - { label: 'v2', value: 2 }, - { label: 'v3', value: 3 }, - ]); - expect(component.instanceModel().version).toBe(2); - - component.openAddModal(); - fixture.detectChanges(); - - const trigger = fixture.nativeElement.querySelector('.select-trigger') as HTMLButtonElement; - expect(trigger.textContent!.trim()).toBe('v2'); - - trigger.click(); - fixture.detectChanges(); - - expect(text(fixture, '.select-option')).toEqual(['v2', 'v3']); - }); - - it('exposes the single supported version for a single-version arr type', () => { - const { component } = setup('sonarr'); - - expect(component.versionOptions()).toEqual([{ label: 'v4', value: 4 }]); - expect(component.instanceModel().version).toBe(4); - }); - - it('falls back to no version options and version 3 for an unknown arr type', () => { - const { fixture, component } = setup('bogusarr'); - - expect(component.versionOptions()).toEqual([]); - expect(component.instanceModel().version).toBe(3); - - component.openAddModal(); - fixture.detectChanges(); - - expect(component.instanceModel().version).toBe(3); - expect(fixture.nativeElement.querySelectorAll('.select-option').length).toBe(0); - }); - - it('shows sonarr\'s own default port and example url as placeholders', () => { - const { component } = setup('sonarr'); - expect(component.urlPlaceholder()).toBe('http://localhost:8989'); - expect(component.externalUrlPlaceholder()).toBe('https://sonarr.example.com'); - }); - - it('shows sportarr\'s own default port and example url as placeholders, not sonarr\'s', () => { - const { component } = setup('sportarr'); - expect(component.urlPlaceholder()).toBe('http://localhost:1867'); - expect(component.externalUrlPlaceholder()).toBe('https://sportarr.example.com'); - }); - - it('falls back to the sonarr default port for an unknown arr type url placeholder', () => { - const { component } = setup('bogusarr'); - expect(component.urlPlaceholder()).toBe('http://localhost:8989'); - }); - - it('opens a blank add modal and blocks saving until name, url and api key are filled in', () => { - const { fixture, component, api } = setup('sonarr'); - - component.openAddModal(); - fixture.detectChanges(); - - expect(component.editingInstance()).toBeNull(); - expect(component.modalVisible()).toBe(true); - expect(component.instanceModel()).toEqual({ - name: '', - url: '', - externalUrl: '', - apiKey: '', - version: 4, - enabled: true, - }); - expect(fixture.nativeElement.textContent).toContain('Add Instance'); - expect(component.hasModalErrors()).toBe(true); - expect(component.instanceForm.name().errors()[0].message).toBe('Name is required'); - expect(component.instanceForm.url().errors()[0].message).toBe('URL is required'); - expect(component.instanceForm.apiKey().errors()[0].message).toBe('API key is required'); - - component.saveInstance(); - expect(api.createInstance).not.toHaveBeenCalled(); - expect(component.saving()).toBe(false); - - component.instanceForm.name().value.set('New'); - component.instanceForm.url().value.set('http://localhost:7878'); - component.instanceForm.apiKey().value.set('key'); - fixture.detectChanges(); - - expect(component.hasModalErrors()).toBe(false); - }); - - it('creates an instance, omits a blank external url and reloads the list', () => { - const { fixture, component, api, toast } = setup('sonarr'); - - component.openAddModal(); - component.instanceForm.name().value.set('New Sonarr'); - component.instanceForm.url().value.set('http://localhost:8989'); - component.instanceForm.apiKey().value.set('key-1'); - fixture.detectChanges(); - - component.saveInstance(); - fixture.detectChanges(); - - expect(api.createInstance).toHaveBeenCalledWith('sonarr', { - name: 'New Sonarr', - url: 'http://localhost:8989', - externalUrl: undefined, - apiKey: 'key-1', - version: 4, - enabled: true, - }); - expect(toast.success).toHaveBeenCalledWith('Instance added'); - expect(component.modalVisible()).toBe(false); - expect(component.saving()).toBe(false); - expect(api.getConfig).toHaveBeenCalledTimes(2); - }); - - it('loads an existing instance into the modal and updates it by id', () => { - const { fixture, component, api, toast } = setup('sonarr'); - - component.openEditModal(INSTANCE); - fixture.detectChanges(); - - expect(component.editingInstance()).toBe(INSTANCE); - expect(component.instanceModel()).toEqual({ - name: 'Main Sonarr', - url: 'http://localhost:8989', - externalUrl: 'https://sonarr.example.com', - apiKey: 'abc123', - version: 4, - enabled: true, - }); - expect(fixture.nativeElement.textContent).toContain('Edit Instance'); - - component.instanceForm.name().value.set('Renamed'); - fixture.detectChanges(); - component.saveInstance(); - fixture.detectChanges(); - - expect(api.updateInstance).toHaveBeenCalledWith('sonarr', 'instance-1', { - name: 'Renamed', - url: 'http://localhost:8989', - externalUrl: 'https://sonarr.example.com', - apiKey: 'abc123', - version: 4, - enabled: true, - }); - expect(api.createInstance).not.toHaveBeenCalled(); - expect(toast.success).toHaveBeenCalledWith('Instance updated'); - }); - - it('keeps the modal open and reports the failure when saving fails', () => { - const api = createApi(); - api.createInstance.mockReturnValue(throwError(() => ({ message: 'Name already exists' }))); - const { fixture, component, toast } = setup('sonarr', api); - - component.openAddModal(); - component.instanceForm.name().value.set('New'); - component.instanceForm.url().value.set('http://localhost:8989'); - component.instanceForm.apiKey().value.set('key'); - fixture.detectChanges(); - - component.saveInstance(); - fixture.detectChanges(); - - expect(toast.error).toHaveBeenCalledWith('Name already exists'); - expect(component.modalVisible()).toBe(true); - expect(component.saving()).toBe(false); - }); - - it('tests the connection for the edited instance and surfaces the server message', () => { - const { fixture, component, api, toast } = setup('sonarr'); - - component.openEditModal(INSTANCE); - fixture.detectChanges(); - - component.testConnection(); - fixture.detectChanges(); - - expect(api.testInstance).toHaveBeenCalledWith('sonarr', { - url: 'http://localhost:8989', - apiKey: 'abc123', - version: 4, - instanceId: 'instance-1', - }); - expect(toast.success).toHaveBeenCalledWith('Connected to Sonarr v4'); - expect(component.testing()).toBe(false); - }); - - it('reports a failed connection test and stops the testing spinner', () => { - const api = createApi(); - api.testInstance.mockReturnValue(throwError(() => ({ message: 'Connection refused' }))); - const { fixture, component, toast } = setup('sonarr', api); - - component.openAddModal(); - fixture.detectChanges(); - - component.testConnection(); - fixture.detectChanges(); - - expect(api.testInstance).toHaveBeenCalledWith('sonarr', { - url: '', - apiKey: '', - version: 4, - instanceId: undefined, - }); - expect(toast.error).toHaveBeenCalledWith('Connection refused'); - expect(component.testing()).toBe(false); - }); - - it('deletes an instance only after the destructive confirmation is accepted', async () => { - const { fixture, component, api, toast, confirm } = setup('sonarr'); - - const cancelled = component.deleteInstance(INSTANCE); - expect(confirm.state()).toMatchObject({ - title: 'Delete Instance', - confirmLabel: 'Delete', - destructive: true, - }); - expect(confirm.state()!.message).toContain('Main Sonarr'); - confirm.cancel(); - await cancelled; - - expect(api.deleteInstance).not.toHaveBeenCalled(); - - const accepted = component.deleteInstance(INSTANCE); - confirm.accept(); - await accepted; - fixture.detectChanges(); - - expect(api.deleteInstance).toHaveBeenCalledWith('sonarr', 'instance-1'); - expect(toast.success).toHaveBeenCalledWith('Instance deleted'); - expect(api.getConfig).toHaveBeenCalledTimes(2); - }); - - it('reports pending changes only while the modal is open and edited', () => { - const { fixture, component } = setup('sonarr'); - - expect(component.hasPendingChanges()).toBe(false); - - component.openAddModal(); - fixture.detectChanges(); - expect(component.hasPendingChanges()).toBe(false); - - component.instanceForm.name().value.set('New'); - fixture.detectChanges(); - expect(component.hasPendingChanges()).toBe(true); - - component.instanceForm.url().value.set('http://localhost:8989'); - component.instanceForm.apiKey().value.set('key'); - component.saveInstance(); - fixture.detectChanges(); - - expect(component.modalVisible()).toBe(false); - expect(component.hasPendingChanges()).toBe(false); - }); - - it('shows the connection error state when loading fails and recovers on retry', () => { - const api = createApi(); - api.getConfig.mockReturnValue(throwError(() => new Error('offline'))); - const { fixture, component, toast } = setup('sonarr', api); - - expect(component.loadError()).toBe(true); - expect(component.instances()).toEqual([]); - expect(toast.error).toHaveBeenCalledWith('Failed to load Sonarr settings: offline'); - expect(fixture.nativeElement.textContent).toContain('Could not connect to server'); - - api.getConfig.mockReturnValue(of(CONFIG)); - component.retry(); - fixture.detectChanges(); - - expect(component.loadError()).toBe(false); - expect(text(fixture, '.instance-row__name')).toEqual(['Main Sonarr', 'Backup Sonarr']); - }); - - it('reports the ApiError message when testing connection fails', () => { - const api = createApi(); - api.testInstance.mockReturnValue(throwError(() => new ApiError('Network timeout'))); - const { fixture, component, toast } = setup('sonarr', api); - - component.openAddModal(); - fixture.detectChanges(); - - component.testConnection(); - fixture.detectChanges(); - - expect(toast.error).toHaveBeenCalledWith('Network timeout'); - expect(component.testing()).toBe(false); - }); - - it('reports the ApiError message when saving an instance fails', () => { - const api = createApi(); - api.updateInstance.mockReturnValue(throwError(() => new ApiError('Save error'))); - const { fixture, component, toast } = setup('sonarr', api); - - component.openEditModal(INSTANCE); - fixture.detectChanges(); - - component.saveInstance(); - fixture.detectChanges(); - - expect(toast.error).toHaveBeenCalledWith('Save error'); - expect(component.saving()).toBe(false); - }); - - it('reports the ApiError message when deleting an instance fails', async () => { - const api = createApi(); - api.deleteInstance.mockReturnValue(throwError(() => new ApiError('Delete error'))); - const { fixture, component, toast, confirm } = setup('sonarr', api); - - const accepted = component.deleteInstance(INSTANCE); - confirm.accept(); - await accepted; - fixture.detectChanges(); - - expect(toast.error).toHaveBeenCalledWith('Delete error'); - }); - - it('seeds the override toggle off and hides the number input when the config value is -1', () => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 }); - expect(fixture.nativeElement.querySelector('app-number-input')).toBeNull(); - expect(component.hasPendingChanges()).toBe(false); - }); - - it('seeds the override toggle on with the configured value', () => { - const { component } = setup('sonarr', createApi({ failedImportMaxStrikes: 3, instances: [] })); - - expect(component.failedImportModel()).toEqual({ override: true, maxStrikes: 3 }); - expect(component.hasPendingChanges()).toBe(false); - }); - - it('enables the override, sets a value and saves it', () => { - const { fixture, component, api, toast } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - component.failedImportForm.maxStrikes().value.set(5); - fixture.detectChanges(); - - expect(component.hasPendingChanges()).toBe(true); - - component.saveFailedImport(); - fixture.detectChanges(); - - expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: 5 }); - expect(toast.success).toHaveBeenCalledWith('Failed import settings saved'); - expect(component.hasPendingChanges()).toBe(false); - }); - - it('disabling the override sends -1', () => { - const { fixture, component, api } = setup('sonarr', createApi({ failedImportMaxStrikes: 5, instances: [] })); - - component.failedImportForm.override().value.set(false); - fixture.detectChanges(); - - component.saveFailedImport(); - fixture.detectChanges(); - - expect(api.updateConfig).toHaveBeenCalledWith('sonarr', { failedImportMaxStrikes: -1 }); - }); - - it('rejects 1 and 2 strikes, accepts 0, and disables save while invalid', () => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - - component.failedImportForm.maxStrikes().value.set(1); - fixture.detectChanges(); - expect(component.failedImportHasErrors()).toBe(true); - expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Minimum value is 3'); - - component.failedImportForm.maxStrikes().value.set(2); - fixture.detectChanges(); - expect(component.failedImportHasErrors()).toBe(true); - - component.failedImportForm.maxStrikes().value.set(0); - fixture.detectChanges(); - expect(component.failedImportHasErrors()).toBe(false); - - component.failedImportForm.maxStrikes().value.set(1); - fixture.detectChanges(); - const saveButton = fixture.nativeElement.querySelector('.form-actions button') as HTMLButtonElement; - expect(saveButton.disabled).toBe(true); - }); - - it('rejects fractional failed import strikes', () => { - const { fixture, component, api } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - - component.failedImportForm.maxStrikes().value.set(3.5); - fixture.detectChanges(); - - expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe('Value must be a whole number'); - expect(component.failedImportHasErrors()).toBe(true); - - component.saveFailedImport(); - fixture.detectChanges(); - - expect(api.updateConfig).not.toHaveBeenCalled(); - }); - - it.each([ - [null, 'This field is required'], - [-5, 'Value cannot be negative'], - [5001, 'Value cannot exceed 5000'], - ])('rejects a failed import maxStrikes of %s with "%s"', (maxStrikes, message) => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - - component.failedImportForm.maxStrikes().value.set(maxStrikes); - fixture.detectChanges(); - - expect(component.failedImportForm.maxStrikes().errors()[0].message).toBe(message); - }); - - it('accepts a failed import maxStrikes of 5000', () => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - - component.failedImportForm.maxStrikes().value.set(5000); - fixture.detectChanges(); - - expect(component.failedImportForm.maxStrikes().errors().length).toBe(0); - }); - - it('ignores an invalid failed import maxStrikes while the override is off', () => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.maxStrikes().value.set(1); - fixture.detectChanges(); - - expect(component.failedImportForm.maxStrikes().errors().length).toBe(0); - expect(component.failedImportHasErrors()).toBe(false); - }); - - it('renders the failed import maxStrikes error message in the number input', () => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - component.failedImportForm.maxStrikes().value.set(1); - fixture.detectChanges(); - - expect(text(fixture, '.number-error')).toEqual(['Minimum value is 3']); - }); - - it('stays clean when the override is toggled back off after editing the hidden value', () => { - const { fixture, component } = setup('sonarr', createApi({ failedImportMaxStrikes: -1, instances: [] })); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - component.failedImportForm.maxStrikes().value.set(9999); - fixture.detectChanges(); - component.failedImportForm.override().value.set(false); - fixture.detectChanges(); - - expect(component.hasPendingChanges()).toBe(false); - expect(component.failedImportHasErrors()).toBe(false); - }); - - it('toasts the error when saving the failed import override fails', () => { - const api = createApi({ failedImportMaxStrikes: -1, instances: [] }); - api.updateConfig.mockReturnValue(throwError(() => new ApiError('Save failed'))); - const { fixture, component, toast } = setup('sonarr', api); - - component.failedImportForm.override().value.set(true); - fixture.detectChanges(); - - component.saveFailedImport(); - fixture.detectChanges(); - - expect(toast.error).toHaveBeenCalledWith('Save failed'); - expect(component.failedImportSaving()).toBe(false); - }); - - it('keeps unsaved failed import edits when an instance is saved', () => { - const { fixture, component, api } = setup('sonarr'); - - api.getConfig.mockReturnValue(of({ ...CONFIG })); - component.failedImportForm.maxStrikes().value.set(9); - fixture.detectChanges(); - - component.openEditModal(INSTANCE); - fixture.detectChanges(); - component.saveInstance(); - fixture.detectChanges(); - - expect(api.getConfig).toHaveBeenCalledTimes(2); - expect(component.failedImportModel().maxStrikes).toBe(9); - expect(component.hasPendingChanges()).toBe(true); - }); - - it('keeps unsaved failed import edits when an instance is deleted', async () => { - const { fixture, component, api, confirm } = setup('sonarr'); - - api.getConfig.mockReturnValue(of({ ...CONFIG })); - component.failedImportForm.maxStrikes().value.set(9); - fixture.detectChanges(); - - const accepted = component.deleteInstance(INSTANCE); - confirm.accept(); - await accepted; - fixture.detectChanges(); - - expect(api.getConfig).toHaveBeenCalledTimes(2); - expect(component.failedImportModel().maxStrikes).toBe(9); - expect(component.hasPendingChanges()).toBe(true); - }); - - it('reseeds the failed import settings on reload when there are no unsaved edits', () => { - const api = createApi(); - const { fixture, component } = setup('sonarr', api); - - api.getConfig.mockReturnValueOnce(of({ ...CONFIG, failedImportMaxStrikes: 7 })); - - component.openEditModal(INSTANCE); - fixture.detectChanges(); - component.saveInstance(); - fixture.detectChanges(); - - expect(component.failedImportModel().maxStrikes).toBe(7); - expect(component.hasPendingChanges()).toBe(false); - }); - - it('reseeds the failed import settings when the arr type changes despite unsaved edits', () => { - const { fixture, component, api } = setup('sonarr'); - - component.failedImportForm.maxStrikes().value.set(9); - fixture.detectChanges(); - - api.getConfig.mockReturnValue(of({ ...CONFIG, failedImportMaxStrikes: -1 })); - fixture.componentRef.setInput('type', 'radarr'); - fixture.detectChanges(); - - expect(api.getConfig).toHaveBeenLastCalledWith('radarr'); - expect(component.failedImportModel()).toEqual({ override: false, maxStrikes: 3 }); - expect(component.hasPendingChanges()).toBe(false); - }); -}); diff --git a/code/frontend/src/app/features/settings/arr/arr-settings.component.ts b/code/frontend/src/app/features/settings/arr/arr-settings.component.ts deleted file mode 100644 index fba91544..00000000 --- a/code/frontend/src/app/features/settings/arr/arr-settings.component.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { Component, ChangeDetectionStrategy, inject, signal, input, computed, effect, untracked } from '@angular/core'; -import { form, required, validate, FormField } from '@angular/forms/signals'; -import { PageHeaderComponent } from '@layout/page-header/page-header.component'; -import { - CardComponent, ButtonComponent, InputComponent, ToggleComponent, NumberInputComponent, - SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent, LoadingStateComponent, - type SelectOption, -} from '@ui'; -import { ArrApi } from '@core/api/arr.api'; -import { ApiError } from '@core/interceptors/error.interceptor'; -import { ToastService } from '@core/services/toast.service'; -import { ConfirmService } from '@core/services/confirm.service'; -import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model'; -import { ArrType } from '@shared/models/enums'; -import { HasPendingChanges } from '@core/guards/pending-changes.guard'; -import { createSettingsResource, saveSettings } from '@shared/utils/settings-resource.util'; -import { createDirtyTracker } from '@shared/utils/dirty-tracker.util'; - -const ARR_VERSION_OPTIONS: Record = { - sonarr: [{ label: 'v4', value: 4 }], - radarr: [{ label: 'v6', value: 6 }], - lidarr: [{ label: 'v3', value: 3 }], - readarr: [{ label: 'v0.4', value: 0.4 }], - whisparr: [{ label: 'v2', value: 2 }, { label: 'v3', value: 3 }], - sportarr: [{ label: 'v3', value: 3 }], - lazylibrarian: [{ label: 'latest', value: 1 }], -}; - -const EXPERIMENTAL_TYPES = new Set(['lazylibrarian']); - -const ARR_DEFAULT_PORT: Record = { - sonarr: 8989, - radarr: 7878, - lidarr: 8686, - readarr: 8787, - whisparr: 6969, - sportarr: 1867, - lazylibrarian: 5299, -}; - -interface ArrInstanceFormModel { - name: string; - url: string; - externalUrl: string; - apiKey: string; - version: number; - enabled: boolean; -} - -interface FailedImportFormModel { - override: boolean; - maxStrikes: number | null; -} - -@Component({ - selector: 'app-arr-settings', - standalone: true, - imports: [ - PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, - ToggleComponent, NumberInputComponent, SelectComponent, ModalComponent, EmptyStateComponent, - BadgeComponent, LoadingStateComponent, FormField, - ], - templateUrl: './arr-settings.component.html', - styleUrl: './arr-settings.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ArrSettingsComponent implements HasPendingChanges { - private readonly api = inject(ArrApi); - private readonly toast = inject(ToastService); - private readonly confirmService = inject(ConfirmService); - - readonly type = input.required(); - readonly displayName = computed(() => { - const t = this.type(); - if (t === 'lazylibrarian') { - return 'LazyLibrarian'; - } - return t.charAt(0).toUpperCase() + t.slice(1); - }); - readonly badge = computed(() => (EXPERIMENTAL_TYPES.has(this.type()) ? 'Experimental' : '')); - readonly versionOptions = computed(() => ARR_VERSION_OPTIONS[this.type()] ?? []); - readonly urlPlaceholder = computed(() => `http://localhost:${ARR_DEFAULT_PORT[this.type()] ?? 8989}`); - readonly externalUrlPlaceholder = computed(() => `https://${this.type()}.example.com`); - - private readonly settings = createSettingsResource({ - params: () => this.type(), - load: (type) => this.api.getConfig(type as ArrType), - errorMessage: () => `Failed to load ${this.displayName()} settings`, - }); - private readonly configResource = this.settings.resource; - - readonly loader = this.settings.loader; - readonly loadError = this.settings.loadError; - readonly saving = signal(false); - readonly instances = computed(() => - this.configResource.hasValue() ? (this.configResource.value().instances ?? []) : [], - ); - - // Failed import override - readonly failedImportSaving = signal(false); - readonly failedImportSaved = signal(false); - readonly failedImportModel = signal({ override: false, maxStrikes: 3 }); - readonly failedImportForm = form(this.failedImportModel, (p) => { - validate(p.maxStrikes, ({ value, valueOf }) => { - if (!valueOf(p.override)) { - return undefined; - } - if (value() === null) { - return { kind: 'required', message: 'This field is required' }; - } - if (!Number.isInteger(value())) { - return { kind: 'integer', message: 'Value must be a whole number' }; - } - if (value()! < 0) { - return { kind: 'min', message: 'Value cannot be negative' }; - } - if (value()! > 0 && value()! < 3) { - return { kind: 'min', message: 'Minimum value is 3' }; - } - if (value()! > 5000) { - return { kind: 'max', message: 'Value cannot exceed 5000' }; - } - return undefined; - }); - }); - readonly failedImportHasErrors = computed(() => this.failedImportForm().invalid()); - - private readonly failedImportDirtyTracker = createDirtyTracker( - this.failedImportModel, - (m) => (m.override ? m.maxStrikes : -1), - ); - readonly failedImportDirty = this.failedImportDirtyTracker.dirty; - /** Arr type the failed import model was last seeded from; unsaved edits survive only same-type reloads. */ - private seededType: string | null = null; - - // Modal state - readonly modalVisible = signal(false); - readonly editingInstance = signal(null); - readonly testing = signal(false); - - readonly instanceModel = signal({ - name: '', url: '', externalUrl: '', apiKey: '', version: 3, enabled: true, - }); - readonly instanceForm = form(this.instanceModel, (p) => { - required(p.name, { message: 'Name is required' }); - required(p.url, { message: 'URL is required' }); - required(p.apiKey, { message: 'API key is required' }); - }); - - readonly hasModalErrors = computed(() => this.instanceForm().invalid()); - - /** JSON snapshot of the model as loaded when the modal opened, for dirty tracking. */ - private readonly openSnapshot = signal(''); - private readonly modalDirty = computed(() => - this.modalVisible() && JSON.stringify(this.instanceModel()) !== this.openSnapshot()); - - constructor() { - effect(() => { - const options = this.versionOptions(); - if (options.length > 0) { - untracked(() => this.instanceModel.update(m => ({ ...m, version: options[0].value as number }))); - } - }); - - effect(() => { - if (!this.configResource.hasValue()) { - return; - } - const config = this.configResource.value(); - untracked(() => { - const type = this.type(); - if (this.seededType === type && this.failedImportDirty()) { - return; - } - this.seededType = type; - this.failedImportModel.set( - config.failedImportMaxStrikes >= 0 - ? { override: true, maxStrikes: config.failedImportMaxStrikes } - : { override: false, maxStrikes: 3 }, - ); - this.failedImportDirtyTracker.markSaved(); - }); - }); - } - - retry(): void { - this.settings.retry(); - } - - openAddModal(): void { - this.editingInstance.set(null); - const options = this.versionOptions(); - this.instanceModel.set({ - name: '', url: '', externalUrl: '', apiKey: '', - version: options.length > 0 ? (options[0].value as number) : 3, - enabled: true, - }); - this.openSnapshot.set(JSON.stringify(this.instanceModel())); - this.modalVisible.set(true); - } - - openEditModal(instance: ArrInstance): void { - this.editingInstance.set(instance); - this.instanceModel.set({ - name: instance.name, - url: instance.url, - externalUrl: instance.externalUrl ?? '', - apiKey: instance.apiKey, - version: instance.version, - enabled: instance.enabled, - }); - this.openSnapshot.set(JSON.stringify(this.instanceModel())); - this.modalVisible.set(true); - } - - testConnection(): void { - const m = this.instanceModel(); - const request: TestArrInstanceRequest = { - url: m.url, - apiKey: m.apiKey, - version: m.version ?? 3, - instanceId: this.editingInstance()?.id, - }; - this.testing.set(true); - this.api.testInstance(this.type() as ArrType, request).subscribe({ - next: (result) => { - this.toast.success(result.message || 'Connection successful'); - this.testing.set(false); - }, - error: (err: ApiError) => { - this.toast.error(err.message); - this.testing.set(false); - }, - }); - } - - saveInstance(): void { - if (this.instanceForm().invalid()) { - return; - } - const m = this.instanceModel(); - const dto: CreateArrInstanceDto = { - name: m.name, - url: m.url, - externalUrl: m.externalUrl || undefined, - apiKey: m.apiKey, - version: m.version ?? 3, - enabled: m.enabled, - }; - - this.saving.set(true); - const editing = this.editingInstance(); - const obs = editing?.id - ? this.api.updateInstance(this.type() as ArrType, editing.id, dto) - : this.api.createInstance(this.type() as ArrType, dto); - - obs.subscribe({ - next: () => { - this.toast.success(editing ? 'Instance updated' : 'Instance added'); - this.modalVisible.set(false); - this.saving.set(false); - this.configResource.reload(); - }, - error: (err: ApiError) => { - this.toast.error(err.message); - this.saving.set(false); - }, - }); - } - - async deleteInstance(instance: ArrInstance): Promise { - if (!instance.id) return; - const confirmed = await this.confirmService.confirm({ - title: 'Delete Instance', - message: `Are you sure you want to delete "${instance.name}"? This action cannot be undone.`, - confirmLabel: 'Delete', - destructive: true, - }); - if (!confirmed) return; - - this.api.deleteInstance(this.type() as ArrType, instance.id).subscribe({ - next: () => { - this.toast.success('Instance deleted'); - this.configResource.reload(); - }, - error: (err: ApiError) => this.toast.error(err.message), - }); - } - - saveFailedImport(): void { - if (this.failedImportForm().invalid()) { - return; - } - const m = this.failedImportModel(); - const maxStrikes = m.override ? (m.maxStrikes ?? 3) : -1; - - saveSettings({ - request: this.api.updateConfig(this.type() as ArrType, { failedImportMaxStrikes: maxStrikes }), - toast: this.toast, - saving: this.failedImportSaving, - saved: this.failedImportSaved, - successMessage: 'Failed import settings saved', - onSaved: () => this.failedImportDirtyTracker.markSaved(m), - }); - } - - hasPendingChanges(): boolean { - return this.modalDirty() || this.failedImportDirty(); - } -} diff --git a/code/frontend/src/app/features/settings/arr/legacy-arr-redirect.spec.ts b/code/frontend/src/app/features/settings/arr/legacy-arr-redirect.spec.ts new file mode 100644 index 00000000..b7d0ed8f --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/legacy-arr-redirect.spec.ts @@ -0,0 +1,33 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { legacyArrRedirect } from './legacy-arr-redirect'; + +describe('legacyArrRedirect', () => { + it('redirects /settings/arr/:type to /settings/arr with fragment', () => { + TestBed.runInInjectionContext(() => { + const result = legacyArrRedirect({ params: { type: 'radarr' } } as unknown as Parameters[0]); + + expect(result).toEqual( + TestBed.inject(Router).createUrlTree(['/settings/arr'], { fragment: 'radarr' }), + ); + }); + }); + + it('handles sonarr type', () => { + TestBed.runInInjectionContext(() => { + const result = legacyArrRedirect({ params: { type: 'sonarr' } } as unknown as Parameters[0]); + const expected = TestBed.inject(Router).createUrlTree(['/settings/arr'], { fragment: 'sonarr' }); + + expect(result.toString()).toEqual(expected.toString()); + }); + }); + + it('handles lazylibrarian type', () => { + TestBed.runInInjectionContext(() => { + const result = legacyArrRedirect({ params: { type: 'lazylibrarian' } } as unknown as Parameters[0]); + const expected = TestBed.inject(Router).createUrlTree(['/settings/arr'], { fragment: 'lazylibrarian' }); + + expect(result.toString()).toEqual(expected.toString()); + }); + }); +}); diff --git a/code/frontend/src/app/features/settings/arr/legacy-arr-redirect.ts b/code/frontend/src/app/features/settings/arr/legacy-arr-redirect.ts new file mode 100644 index 00000000..e0ed90d1 --- /dev/null +++ b/code/frontend/src/app/features/settings/arr/legacy-arr-redirect.ts @@ -0,0 +1,6 @@ +import { Router, RedirectFunction } from '@angular/router'; +import { inject } from '@angular/core'; + +export const legacyArrRedirect: RedirectFunction = ({ params }) => { + return inject(Router).createUrlTree(['/settings/arr'], { fragment: params['type'] }); +}; diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.scss b/code/frontend/src/app/features/settings/notifications/notifications.component.scss index 8750c197..1c5655a6 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.scss +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.scss @@ -69,71 +69,6 @@ margin-bottom: var(--space-4); } -.provider-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); - gap: var(--space-3); -} +.provider-grid { @include selection-grid; } -.provider-card { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-2); - padding: var(--space-4) var(--space-3); - border: 1px solid var(--glass-border); - border-radius: var(--radius-lg); - background: var(--glass-bg); - cursor: pointer; - transition: all var(--duration-fast) var(--ease-default); - text-align: center; - - &:hover { - background: var(--glass-bg-hover); - border-color: var(--glass-border-hover); - transform: translateY(-2px); - } - - &__icon-wrapper { - position: relative; - width: 40px; - height: 40px; - } - - &__icon { - width: 40px; - height: 40px; - object-fit: contain; - position: absolute; - top: 0; - left: 0; - transition: opacity var(--duration-fast) var(--ease-default); - - &--light { - opacity: 1; - } - - &--normal { - opacity: 0; - } - } - - &:hover &__icon--light { - opacity: 0; - } - - &:hover &__icon--normal { - opacity: 1; - } - - &__name { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--text-primary); - } - - &__description { - font-size: var(--font-size-xs); - color: var(--text-tertiary); - } -} +.provider-card { @include selection-card; } diff --git a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.html b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.html index ceded070..d7ed84e9 100644 --- a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.html +++ b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.html @@ -49,35 +49,6 @@ } - - @if (!collapsed()) { - - } - - @if (mediaAppsExpanded() || collapsed()) { - @for (item of mediaAppItems; track item.route) { - - @if (item.iconSrc) { - - } @else if (item.icon) { - - } - {{ item.label }} - - } - } - @for (item of otherSettingsItems; track item.route) { { expect(fixture.nativeElement.querySelector('.sidebar__version')).toBeNull(); expect(fixture.nativeElement.querySelector('.sidebar__update')).toBeNull(); expect(link(fixture, '/settings/general')).not.toBeNull(); - expect(link(fixture, '/settings/arr/sonarr')).not.toBeNull(); + expect(link(fixture, '/settings/arr')).not.toBeNull(); }); - it('hides the grouped items when a section is folded away', () => { + it('hides the settings items when the settings section is folded away', () => { const { fixture } = setup(); const component = fixture.componentInstance; - component.toggleMediaApps(); - fixture.detectChanges(); - - expect(component.mediaAppsExpanded()).toBe(false); - expect(link(fixture, '/settings/arr/sonarr')).toBeNull(); - expect(link(fixture, '/settings/general')).not.toBeNull(); - component.toggleSettings(); fixture.detectChanges(); expect(component.settingsExpanded()).toBe(false); expect(link(fixture, '/settings/general')).toBeNull(); + expect(link(fixture, '/settings/arr')).toBeNull(); expect(link(fixture, ROUTES.dashboard)).not.toBeNull(); }); diff --git a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts index 33470c2b..4963551f 100644 --- a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts +++ b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts @@ -10,7 +10,6 @@ import { ROUTES } from '@shared/routes'; interface NavItem { label: string; icon?: string; - iconSrc?: string; route: string; } @@ -35,7 +34,6 @@ export class NavSidebarComponent { navClicked = output(); settingsExpanded = signal(true); - mediaAppsExpanded = signal(true); readonly currentVersion = computed(() => this.hub.appStatus()?.currentVersion ?? null); readonly latestVersion = computed(() => this.hub.appStatus()?.latestVersion ?? null); @@ -60,16 +58,7 @@ export class NavSidebarComponent { { label: 'Download Cleaner', icon: 'tablerTrash', route: '/settings/download-cleaner' }, { label: 'Blacklist Sync', icon: 'tablerBan', route: '/settings/blacklist-sync' }, { label: 'Seeker', icon: 'tablerSearch', route: '/settings/seeker' }, - ]; - - mediaAppItems: NavItem[] = [ - { label: 'Sonarr', iconSrc: 'icons/ext/sonarr-light.svg', route: '/settings/arr/sonarr' }, - { label: 'Radarr', iconSrc: 'icons/ext/radarr-light.svg', route: '/settings/arr/radarr' }, - { label: 'Lidarr', iconSrc: 'icons/ext/lidarr-light.svg', route: '/settings/arr/lidarr' }, - { label: 'Readarr', iconSrc: 'icons/ext/readarr-light.svg', route: '/settings/arr/readarr' }, - { label: 'Whisparr', iconSrc: 'icons/ext/whisparr-light.svg', route: '/settings/arr/whisparr' }, - { label: 'Sportarr', iconSrc: 'icons/ext/sportarr-light.svg', route: '/settings/arr/sportarr' }, - { label: 'LazyLibrarian', iconSrc: 'icons/ext/lazylibrarian-light.svg', route: '/settings/arr/lazylibrarian' }, + { label: 'Media Managers', icon: 'tablerApps', route: '/settings/arr' }, { label: 'Download Clients', icon: 'tablerDownload', route: '/settings/download-clients' }, ]; @@ -89,10 +78,6 @@ export class NavSidebarComponent { this.settingsExpanded.set(!this.settingsExpanded()); } - toggleMediaApps(): void { - this.mediaAppsExpanded.set(!this.mediaAppsExpanded()); - } - logout(): void { this.auth.logout(); } diff --git a/code/frontend/src/app/ui/button/button.component.html b/code/frontend/src/app/ui/button/button.component.html index 38461351..962ada4b 100644 --- a/code/frontend/src/app/ui/button/button.component.html +++ b/code/frontend/src/app/ui/button/button.component.html @@ -3,6 +3,7 @@ [class]="'btn btn--' + variant() + ' btn--' + size()" [class.btn--loading]="loading()" [class.btn--glowing]="glowing()" + [attr.aria-expanded]="ariaExpanded()" [disabled]="disabled() || loading()" (click)="onClick($event)" > diff --git a/code/frontend/src/app/ui/button/button.component.ts b/code/frontend/src/app/ui/button/button.component.ts index a8934ab4..efc43b76 100644 --- a/code/frontend/src/app/ui/button/button.component.ts +++ b/code/frontend/src/app/ui/button/button.component.ts @@ -17,6 +17,7 @@ export class ButtonComponent { loading = input(false); type = input<'button' | 'submit' | 'reset'>('button'); glowing = input(false); + ariaExpanded = input(null); clicked = output(); diff --git a/code/frontend/src/styles/_list-layout.scss b/code/frontend/src/styles/_list-layout.scss index 1e1e0008..3e223286 100644 --- a/code/frontend/src/styles/_list-layout.scss +++ b/code/frontend/src/styles/_list-layout.scss @@ -61,3 +61,72 @@ flex-shrink: 0; } } + +@mixin selection-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); + gap: var(--space-3); +} + +@mixin selection-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-3); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + background: var(--glass-bg); + cursor: pointer; + transition: all var(--duration-fast) var(--ease-default); + text-align: center; + + &:hover { + background: var(--glass-bg-hover); + border-color: var(--glass-border-hover); + transform: translateY(-2px); + } + + &__icon-wrapper { + position: relative; + width: 40px; + height: 40px; + } + + &__icon { + width: 40px; + height: 40px; + object-fit: contain; + position: absolute; + top: 0; + left: 0; + transition: opacity var(--duration-fast) var(--ease-default); + + &--light { + opacity: 1; + } + + &--normal { + opacity: 0; + } + } + + &:hover &__icon--light { + opacity: 0; + } + + &:hover &__icon--normal { + opacity: 1; + } + + &__name { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-primary); + } + + &__description { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } +}