diff --git a/code/UI/src/app/settings/sonarr/sonarr-settings.component.html b/code/UI/src/app/settings/sonarr/sonarr-settings.component.html index 2e961d90..8d2fd08d 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.html +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.html @@ -69,70 +69,55 @@ -
+

Sonarr Instances

- Configure multiple Sonarr server instances + Configure multiple Sonarr server instances
- -
- - - - Name - URL - API Key - Actions - - - - - {{ instance.get('name')?.value }} - {{ instance.get('url')?.value }} - {{ instance.get('apiKey')?.value | slice:0:8 }}••••••• - -
- - + +
+ +
+

No Sonarr instances defined. Add an instance to start using Sonarr integration.

+
+ + +
+
+
+
+ + +
+ +
+ Name is required + +
+
+ +
+ + URL is required
- - - - - - -
No Sonarr instances configured
- - -
- -
- - -
- +
+ +
+ +
+ + API key is required +
+
+
+
+
+ +
+ +
@@ -159,70 +144,3 @@
- - - -
-
- - - Name is required -
- -
- - - URL is required -
- -
- - - API key is required -
-
- - - - -
diff --git a/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss b/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss index 4631be1f..0fec1115 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss @@ -2,17 +2,115 @@ @import '../styles/settings-shared.scss'; -.instances-table { - margin-bottom: 1rem; -} - .section-header { - margin-top: 1.5rem; - margin-bottom: 0.5rem; + margin-bottom: 1rem; h3 { - margin: 0; - font-size: 1.2rem; - font-weight: 600; + margin-bottom: 0.25rem; + font-weight: 500; + } + + small { + color: var(--text-color-secondary); } } + +.instances-container { + margin-bottom: 1.5rem; +} + +.instance-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.instance-item { + background-color: var(--surface-hover); + border-radius: 6px; + padding: 1rem; + border-left: 4px solid var(--primary-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } +} + +.instance-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.instance-title { + display: flex; + align-items: center; + gap: 0.5rem; + + .instance-icon { + color: var(--primary-color); + font-size: 1rem; + } + + .instance-name-input { + font-weight: 500; + font-size: 1.1rem; + } +} + +.instance-content { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--surface-border); +} + +.instance-field { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + + @media (min-width: 768px) { + flex-direction: row; + align-items: flex-start; + } + + label { + font-weight: 500; + margin-bottom: 0.5rem; + + @media (min-width: 768px) { + flex: 0 0 200px; + margin-bottom: 0; + padding-top: 0.5rem; + } + } + + .field-input { + flex: 1; + max-width: 100%; + + @media (min-width: 768px) { + max-width: 400px; + } + } +} + +.empty-instances-message { + background-color: var(--surface-ground); + border-radius: 6px; + border: 1px dashed var(--surface-border); + color: var(--text-color-secondary); + font-style: italic; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--surface-border); +} diff --git a/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts b/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts index a587cae1..54c6598a 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts @@ -12,10 +12,7 @@ import { InputTextModule } from "primeng/inputtext"; import { CheckboxModule } from "primeng/checkbox"; import { ButtonModule } from "primeng/button"; import { InputNumberModule } from "primeng/inputnumber"; -import { AccordionModule } from "primeng/accordion"; import { SelectButtonModule } from "primeng/selectbutton"; -import { DialogModule } from "primeng/dialog"; -import { TableModule } from "primeng/table"; import { ToastModule } from "primeng/toast"; import { NotificationService } from "../../core/services/notification.service"; import { DropdownModule } from "primeng/dropdown"; @@ -32,10 +29,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro CheckboxModule, ButtonModule, InputNumberModule, - AccordionModule, SelectButtonModule, - DialogModule, - TableModule, ToastModule, DropdownModule, LoadingErrorStateComponent, @@ -50,18 +44,13 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat // Sonarr Configuration Form sonarrForm: FormGroup; - + // Original form values for tracking changes private originalFormValues: any; - + // Track whether the form has actual changes compared to original values hasActualChanges = false; - // Dialog state - showInstanceDialog = false; - editingInstanceIndex: number | null = null; - instanceForm: FormGroup; - // SonarrSearchType options searchTypeOptions = [ { label: "Episode", value: SonarrSearchType.Episode }, @@ -78,11 +67,11 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat private notificationService = inject(NotificationService); private sonarrStore = inject(SonarrConfigStore); - // Signals from the store - readonly sonarrConfig = this.sonarrStore.config; - readonly sonarrLoading = this.sonarrStore.loading; - readonly sonarrSaving = this.sonarrStore.saving; - readonly sonarrError = this.sonarrStore.error; + // Signals from store + sonarrConfig = this.sonarrStore.config; + sonarrLoading = this.sonarrStore.loading; + sonarrError = this.sonarrStore.error; + sonarrSaving = this.sonarrStore.saving; /** * Check if component can be deactivated (navigation guard) @@ -99,27 +88,17 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat searchType: [SonarrSearchType.Episode, Validators.required], }); - // Initialize the instance form - this.instanceForm = this.formBuilder.group({ - id: [''], - name: ['', Validators.required], - url: ['', [Validators.required]], - apiKey: ['', [Validators.required]], - }); - // Add instances FormArray to main form this.sonarrForm.addControl('instances', this.formBuilder.array([])); - // Setup value change listeners - this.setupFormValueChangeListeners(); + // Load Sonarr config data + this.sonarrStore.loadConfig(); - // Create an effect to respond to config changes + // Setup effect to update form when config changes effect(() => { const config = this.sonarrConfig(); if (config) { - this.updateForm(config); - this.storeOriginalValues(); - this.updateFormControlDisabledStates(config); + this.updateFormFromConfig(config); } }); } @@ -133,97 +112,9 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat } /** - * Set up listeners for form control value changes to manage dependent control states + * Update form with values from the configuration */ - private setupFormValueChangeListeners(): void { - // Listen for changes on the enabled control - this.sonarrForm - .get("enabled") - ?.valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe((enabled) => { - this.updateMainControlsState(enabled); - }); - - // Listen for form changes to update the hasActualChanges flag - this.sonarrForm.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.hasActualChanges = this.formValuesChanged(); - }); - } - - /** - * Store original form values for dirty checking - */ - private storeOriginalValues(): void { - this.originalFormValues = JSON.parse(JSON.stringify(this.sonarrForm.value)); - this.sonarrForm.markAsPristine(); - this.hasActualChanges = false; - } - - /** - * Check if the current form values are different from the original values - */ - private formValuesChanged(): boolean { - return !this.isEqual(this.sonarrForm.value, this.originalFormValues); - } - - /** - * Deep compare two objects for equality - */ - private isEqual(obj1: any, obj2: any): boolean { - if (obj1 === obj2) return true; - - if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) { - return false; - } - - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - - if (keys1.length !== keys2.length) return false; - - for (const key of keys1) { - const val1 = obj1[key]; - const val2 = obj2[key]; - const areObjects = typeof val1 === "object" && typeof val2 === "object"; - - if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) { - return false; - } - } - - return true; - } - - /** - * Update form control disabled states based on the configuration - */ - private updateFormControlDisabledStates(config: SonarrConfig): void { - const enabled = config.enabled; - this.updateMainControlsState(enabled); - } - - /** - * Update the state of main controls based on the 'enabled' control value - */ - private updateMainControlsState(enabled: boolean): void { - const failedImportMaxStrikesControl = this.sonarrForm.get('failedImportMaxStrikes'); - const searchTypeControl = this.sonarrForm.get('searchType'); - - if (enabled) { - failedImportMaxStrikesControl?.enable(); - searchTypeControl?.enable(); - } else { - failedImportMaxStrikesControl?.disable(); - searchTypeControl?.disable(); - } - } - - /** - * Update the form with values from the configuration - */ - private updateForm(config: SonarrConfig): void { + private updateFormFromConfig(config: SonarrConfig): void { // Update main form controls this.sonarrForm.patchValue({ enabled: config.enabled, @@ -248,13 +139,77 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat ); }); } + + // Store original form values for dirty checking + this.storeOriginalValues(); } /** - * Get the instances form array + * Store original form values for dirty checking */ - get instances(): FormArray { - return this.sonarrForm.get('instances') as FormArray; + private storeOriginalValues(): void { + this.originalFormValues = JSON.parse(JSON.stringify(this.sonarrForm.value)); + this.sonarrForm.markAsPristine(); + this.hasActualChanges = false; + } + + /** + * Check if the current form values are different from the original values + */ + private formValuesChanged(): boolean { + return !this.isEqual(this.sonarrForm.value, this.originalFormValues); + } + + /** + * Deep compare two objects for equality + */ + private isEqual(obj1: any, obj2: any): boolean { + if (obj1 === obj2) return true; + + if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) { + return false; + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + const val1 = obj1[key]; + const val2 = obj2[key]; + const areObjects = typeof val1 === "object" && typeof val2 === "object"; + + if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) { + return false; + } + } + + return true; + } + + /** + * Update form control disabled states based on the configuration + */ + private updateFormControlDisabledStates(config: SonarrConfig): void { + const enabled = config.enabled; + this.updateMainControlsState(enabled); + } + + /** + * Update the state of main controls based on the 'enabled' control value + */ + private updateMainControlsState(enabled: boolean): void { + const failedImportMaxStrikesControl = this.sonarrForm.get('failedImportMaxStrikes'); + const searchTypeControl = this.sonarrForm.get('searchType'); + + if (enabled) { + failedImportMaxStrikesControl?.enable(); + searchTypeControl?.enable(); + } else { + failedImportMaxStrikesControl?.disable(); + searchTypeControl?.disable(); + } } /** @@ -267,7 +222,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat // Get data from form const formValue = this.sonarrForm.getRawValue(); - + // Create config object const sonarrConfig: SonarrConfig = { enabled: formValue.enabled, @@ -275,7 +230,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat searchType: formValue.searchType, instances: formValue.instances || [] }; - + // Save the configuration this.sonarrStore.saveConfig(sonarrConfig); @@ -285,18 +240,18 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat if (!this.sonarrSaving()) { // Re-enable the form this.sonarrForm.enable(); - + // If still disabled, update control states based on enabled state if (!this.sonarrForm.get('enabled')?.value) { this.updateMainControlsState(false); } - + // Update original values to match current form state this.storeOriginalValues(); - + // Notify listeners that we've completed the save this.saved.emit(); - + // Show success message this.notificationService.showSuccess("Sonarr configuration saved successfully"); } else { @@ -304,16 +259,16 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat setTimeout(checkSaveCompletion, 100); } }; - + // Start checking for save completion checkSaveCompletion(); } else { // Form is invalid, show error message this.notificationService.showValidationError(); - + // Emit error for parent components this.error.emit("Please fix validation errors before saving."); - + // Mark all controls as touched to show validation errors this.markFormGroupTouched(this.sonarrForm); } @@ -335,71 +290,27 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat // Update control states after reset this.updateMainControlsState(false); - + // Mark form as dirty so the save button is enabled after reset this.sonarrForm.markAsDirty(); this.hasActualChanges = true; } /** - * Open the instance dialog for adding a new instance + * Add a new instance to the instances form array */ - openAddInstanceDialog(): void { - this.editingInstanceIndex = null; - this.instanceForm.reset({ - id: '', - name: '', - url: '', - apiKey: '', - }); - this.showInstanceDialog = true; - } - - /** - * Open the instance dialog for editing an existing instance - */ - openEditInstanceDialog(index: number): void { - const instanceToEdit = this.instances.at(index).value; - this.editingInstanceIndex = index; - - this.instanceForm.reset({ - id: instanceToEdit.id || '', - name: instanceToEdit.name, - url: instanceToEdit.url, - apiKey: instanceToEdit.apiKey, - }); - - this.showInstanceDialog = true; - } - - /** - * Save the instance from the dialog - */ - saveInstance(): void { - if (this.instanceForm.invalid) { - this.markFormGroupTouched(this.instanceForm); - return; - } - - const instanceData = this.instanceForm.value; + addInstance(): void { const instancesArray = this.sonarrForm.get('instances') as FormArray; - if (this.editingInstanceIndex !== null) { - // Update existing instance - instancesArray.at(this.editingInstanceIndex).patchValue(instanceData); - } else { - // Add new instance - instancesArray.push( - this.formBuilder.group({ - id: [instanceData.id || ''], - name: [instanceData.name, Validators.required], - url: [instanceData.url, Validators.required], - apiKey: [instanceData.apiKey, Validators.required], - }) - ); - } + instancesArray.push( + this.formBuilder.group({ + id: [''], + name: ['', Validators.required], + url: ['', Validators.required], + apiKey: ['', Validators.required], + }) + ); - this.showInstanceDialog = false; this.sonarrForm.markAsDirty(); this.hasActualChanges = true; } @@ -415,12 +326,21 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat } /** - * Cancel the instance dialog + * Get the instances form array */ - cancelInstanceDialog(): void { - this.showInstanceDialog = false; + get instances(): FormArray { + return this.sonarrForm.get('instances') as FormArray; } + /** + * Get an instance at the specified index as a FormGroup + */ + getInstanceAsFormGroup(index: number): FormGroup { + return this.instances.at(index) as FormGroup; + } + + // hasInstanceFieldError is implemented below + /** * Mark all controls in a form group as touched */ @@ -435,18 +355,28 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat } /** - * Check if a form control has an error after it's been touched + * Check if the form control has an error + * @param controlName The name of the control to check + * @param errorName The name of the error to check for + * @returns True if the control has the specified error */ hasError(controlName: string, errorName: string): boolean { const control = this.sonarrForm.get(controlName); - return control ? control.touched && control.hasError(errorName) : false; + return control !== null && control.hasError(errorName) && control.touched; } /** - * Get nested form control errors + * Check if an instance field has an error + * @param instanceIndex The index of the instance in the array + * @param fieldName The name of the field to check + * @param errorName The name of the error to check for + * @returns True if the field has the specified error */ - hasInstanceError(controlName: string, errorName: string): boolean { - const control = this.instanceForm.get(controlName); - return control ? control.touched && control.hasError(errorName) : false; + hasInstanceFieldError(instanceIndex: number, fieldName: string, errorName: string): boolean { + const instancesArray = this.sonarrForm.get('instances') as FormArray; + if (!instancesArray || !instancesArray.controls[instanceIndex]) return false; + + const control = (instancesArray.controls[instanceIndex] as FormGroup).get(fieldName); + return control !== null && control.hasError(errorName) && control.touched; } }