fixed download client UI

This commit is contained in:
Flaminel
2025-06-16 18:26:16 +03:00
parent f39e8eca46
commit b4548573ee
2 changed files with 433 additions and 437 deletions

View File

@@ -3,147 +3,237 @@
<h1>Download Clients</h1>
</div>
<p-card styleClass="settings-card">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Download Client Configuration</h2>
<span class="card-subtitle">Configure download client integration settings</span>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-xl"></i>
</div>
</div>
</ng-template>
<div class="card-content">
<!-- Loading/Error State Component -->
<app-loading-error-state
*ngIf="downloadClientLoading() || downloadClientError()"
[loading]="downloadClientLoading()"
[error]="downloadClientError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Form Content - only shown when not loading and no error -->
<form *ngIf="!downloadClientLoading() && !downloadClientError()" [formGroup]="downloadClientForm" class="p-fluid">
<!-- Clients Section -->
<div class="section-header mt-4">
<h3>Download Clients</h3>
<small>Configure multiple download client instances</small>
</div>
<!-- Clients Container -->
<div class="instances-container">
<!-- Empty state message when no clients -->
<div *ngIf="clients.controls.length === 0" class="empty-instances-message p-3 text-center">
<p>No download clients defined. Add a client to start.</p>
<!-- Loading/Error State Component -->
<div class="mb-4">
<app-loading-error-state
*ngIf="downloadClientLoading() || downloadClientError()"
[loading]="downloadClientLoading()"
[error]="downloadClientError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
</div>
<!-- Content - only shown when not loading and no error -->
<div *ngIf="!downloadClientLoading() && !downloadClientError()">
<!-- Client Management Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Download Clients</h2>
<span class="card-subtitle">Manage download client instances</span>
</div>
<!-- Client cards -->
<div class="instance-list">
<div *ngFor="let client of clients.controls; let i = index" class="instance-item" [formGroup]="getClientAsFormGroup(i)">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-download instance-icon"></i>
<input type="text" pInputText formControlName="name" placeholder="Client name" class="instance-name-input" />
</div>
<button pButton type="button" icon="pi pi-trash" class="p-button-danger p-button-sm"
(click)="removeClient(i)" [disabled]="downloadClientForm.disabled"></button>
</div>
<small *ngIf="hasClientFieldError(i, 'name', 'required')" class="p-error block">Name is required</small>
<div class="d-flex align-items-center ml-4 mb-2">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="ml-2">Enabled</small>
</div>
<div class="instance-content">
<div class="instance-field">
<label>Client Type</label>
<div class="field-input">
<p-select
formControlName="type"
[options]="clientTypeOptions"
optionLabel="label"
optionValue="value"
placeholder="Select client type"
appendTo="body"
></p-select>
<small *ngIf="hasClientFieldError(i, 'type', 'required')" class="p-error block">Client type is required</small>
</div>
</div>
<!-- Connection fields - only shown when client type is not Usenet -->
<ng-container *ngIf="!isUsenetClient(client.get('type')?.value)">
<div class="instance-field">
<label>Host</label>
<div class="field-input">
<input type="text" pInputText formControlName="host" placeholder="http://localhost:8080" required />
<small *ngIf="hasClientFieldError(i, 'host', 'required')" class="p-error block">Host is required</small>
<small *ngIf="hasClientFieldError(i, 'host', 'invalidUri')" class="p-error block">Host must be a valid URL</small>
<small *ngIf="hasClientFieldError(i, 'host', 'invalidProtocol')" class="p-error block">Host must use http or https protocol</small>
</div>
</div>
<div class="instance-field">
<label>URL Base</label>
<div class="field-input">
<input type="text" pInputText formControlName="urlBase" placeholder="(Optional) Path prefix" />
</div>
</div>
<div class="instance-field">
<label>Username</label>
<div class="field-input">
<input type="text" pInputText formControlName="username" placeholder="Username" />
</div>
</div>
<div class="instance-field">
<label>Password</label>
<div class="field-input">
<input type="password" pInputText formControlName="password" placeholder="Password" />
</div>
</div>
</ng-container>
<!-- Message shown when Usenet is selected -->
<div *ngIf="isUsenetClient(client.get('type')?.value)" class="p-3 mt-2 surface-overlay border-1 border-round-sm">
<small class="text-secondary">Usenet client type is for categorization only. No connection details needed.</small>
</div>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-download text-xl"></i>
</div>
</div>
</ng-template>
<!-- Empty state when no clients -->
<div *ngIf="clients.length === 0" class="empty-instances-message p-3 text-center">
<i class="pi pi-inbox empty-icon"></i>
<p>No download clients defined. Add a client to start.</p>
</div>
<!-- Clients List -->
<div *ngIf="clients.length > 0" class="instances-list">
<div *ngFor="let client of clients" class="instance-item">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-download instance-icon"></i>
<span class="instance-name">{{ client.name }}</span>
</div>
<div class="instance-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-text p-button-sm"
[disabled]="downloadClientSaving()"
(click)="openEditClientModal(client)"
pTooltip="Edit client"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
[disabled]="downloadClientSaving()"
(click)="deleteClient(client)"
pTooltip="Delete client"
></button>
</div>
</div>
<div class="flex justify-content-end mt-3">
<button pButton type="button" icon="pi pi-plus" label="Add Client"
(click)="addClient()" class="p-button-outlined"></button>
<div class="instance-content">
<div class="instance-field">
<label>Type: {{ getClientTypeLabel(client) }}</label>
</div>
<div class="instance-field" *ngIf="client.host">
<label>Host: {{ client.host }}</label>
</div>
<div class="instance-field" *ngIf="client.urlBase">
<label>URL Base: {{ client.urlBase }}</label>
</div>
<div class="instance-field">
<label>Status:
<span [class]="client.enabled ? 'text-green-500' : 'text-red-500'">
{{ client.enabled ? 'Enabled' : 'Disabled' }}
</span>
</label>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!downloadClientForm.dirty || !hasActualChanges) || downloadClientForm.invalid || downloadClientSaving()"
[loading]="downloadClientSaving()"
(click)="saveDownloadClientConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetDownloadClientConfig()"
></button>
</div>
</form>
</div>
</p-card>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
icon="pi pi-plus"
label="Add Client"
class="p-button-outlined"
[disabled]="downloadClientSaving()"
(click)="openAddClientModal()"
></button>
</div>
</p-card>
</div>
</div>
<!-- Client Modal -->
<p-dialog
[(visible)]="showClientModal"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="closeClientModal()"
>
<form [formGroup]="clientForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">Enabled</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">Enable this download client</small>
</div>
</div>
<div class="field">
<label for="client-name">Name *</label>
<input
id="client-name"
type="text"
pInputText
formControlName="name"
placeholder="My Download Client"
class="w-full"
/>
<small *ngIf="hasError(clientForm, 'name', 'required')" class="p-error">Name is required</small>
</div>
<div class="field">
<label for="client-type">Client Type *</label>
<p-select
id="client-type"
formControlName="type"
[options]="clientTypeOptions"
optionLabel="label"
optionValue="value"
placeholder="Select client type"
appendTo="body"
class="w-full"
></p-select>
<small *ngIf="hasError(clientForm, 'type', 'required')" class="p-error">Client type is required</small>
</div>
<!-- Connection fields - only shown when client type is not Usenet -->
<ng-container *ngIf="!isUsenetClient(clientForm.get('type')?.value)">
<div class="field">
<label for="client-host">Host *</label>
<input
id="client-host"
type="text"
pInputText
formControlName="host"
placeholder="http://localhost:8080"
class="w-full"
/>
<small *ngIf="hasError(clientForm, 'host', 'required')" class="p-error">Host is required</small>
<small *ngIf="hasError(clientForm, 'host', 'invalidUri')" class="p-error">Host must be a valid URL</small>
<small *ngIf="hasError(clientForm, 'host', 'invalidProtocol')" class="p-error">Host must use http or https protocol</small>
</div>
<div class="field">
<label for="client-urlbase">URL Base</label>
<input
id="client-urlbase"
type="text"
pInputText
formControlName="urlBase"
placeholder="(Optional) Path prefix"
class="w-full"
/>
</div>
<div class="field">
<label for="client-username">Username</label>
<input
id="client-username"
type="text"
pInputText
formControlName="username"
placeholder="Username"
class="w-full"
/>
</div>
<div class="field">
<label for="client-password">Password</label>
<input
id="client-password"
type="password"
pInputText
formControlName="password"
placeholder="Password"
class="w-full"
/>
</div>
</ng-container>
<!-- Message shown when Usenet is selected -->
<div *ngIf="isUsenetClient(clientForm.get('type')?.value)" class="p-3 mt-2 surface-overlay border-1 border-round-sm">
<small class="text-secondary">Usenet client type is for categorization only. No connection details needed.</small>
</div>
</form>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeClientModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary ml-2"
[disabled]="clientForm.invalid || downloadClientSaving()"
[loading]="downloadClientSaving()"
(click)="saveClient()"
></button>
</div>
</ng-template>
</p-dialog>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -1,6 +1,6 @@
import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormControl } from "@angular/forms";
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { DownloadClientConfigStore } from "./download-client-config.store";
import { CanComponentDeactivate } from "../../core/guards";
@@ -15,6 +15,9 @@ import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { SelectModule } from 'primeng/select';
import { ToastModule } from "primeng/toast";
import { DialogModule } from "primeng/dialog";
import { ConfirmDialogModule } from "primeng/confirmdialog";
import { ConfirmationService } from "primeng/api";
import { NotificationService } from "../../core/services/notification.service";
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
@@ -31,9 +34,11 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
InputNumberModule,
SelectModule,
ToastModule,
DialogModule,
ConfirmDialogModule,
LoadingErrorStateComponent
],
providers: [DownloadClientConfigStore],
providers: [DownloadClientConfigStore, ConfirmationService],
templateUrl: "./download-client-settings.component.html",
styleUrls: ["./download-client-settings.component.scss"],
})
@@ -41,14 +46,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
@Output() saved = new EventEmitter<void>();
@Output() error = new EventEmitter<string>();
// Download Client Configuration Form
downloadClientForm: FormGroup;
// Forms
clientForm: FormGroup;
// Original form values for tracking changes
private originalFormValues: any;
// Track whether the form has actual changes compared to original values
hasActualChanges = false;
// Modal state
showClientModal = false;
modalMode: 'add' | 'edit' = 'add';
editingClient: ClientConfig | null = null;
// Download client type options
clientTypeOptions = [
@@ -61,9 +65,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
// Clean up subscriptions
private destroy$ = new Subject<void>();
// Inject the necessary services
// Services
private formBuilder = inject(FormBuilder);
private notificationService = inject(NotificationService);
private confirmationService = inject(ConfirmationService);
private downloadClientStore = inject(DownloadClientConfigStore);
// Signals from store
@@ -71,45 +76,34 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
downloadClientLoading = this.downloadClientStore.loading;
downloadClientError = this.downloadClientStore.error;
downloadClientSaving = this.downloadClientStore.saving;
pendingOperations = this.downloadClientStore.pendingOperations;
/**
* Get the clients form array
*/
public get clients(): FormArray {
return this.downloadClientForm.get('clients') as FormArray;
}
/**
* Check if component can be deactivated (navigation guard)
*/
canDeactivate(): boolean {
return !this.downloadClientForm?.dirty || !this.hasActualChanges;
return true; // No unsaved changes in modal-based approach
}
constructor() {
// Initialize the main form
this.downloadClientForm = this.formBuilder.group({});
// Add clients FormArray to main form
this.downloadClientForm.addControl('clients', this.formBuilder.array([]));
// Initialize client form for modal
this.clientForm = this.formBuilder.group({
name: ['', Validators.required],
type: [null, Validators.required],
host: ['', [Validators.required, this.uriValidator.bind(this)]],
username: [''],
password: [''],
urlBase: [''],
enabled: [true]
});
// Load Download Client config data
this.downloadClientStore.loadConfig();
// Setup effect to update form when config changes
effect(() => {
const config = this.downloadClientConfig();
if (config) {
this.updateFormFromConfig(config);
}
});
// Track form changes for dirty state
this.downloadClientForm.valueChanges
// Setup client type change handler
this.clientForm.get('type')?.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasActualChanges = this.formValuesChanged();
this.onClientTypeChange();
});
}
@@ -122,222 +116,213 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
}
/**
* Update form with values from the configuration
* Custom validator to check if the input is a valid URI
*/
private updateFormFromConfig(config: DownloadClientConfig): void {
// Clear existing clients
const clientsArray = this.downloadClientForm.get('clients') as FormArray;
clientsArray.clear();
// Add each client to the form array
if (config.clients && config.clients.length > 0) {
config.clients.forEach(client => {
this.addClient(client);
});
}
// Don't automatically add an empty client - let the template handle the empty state
// Store the original values for change detection
this.storeOriginalValues();
// Mark the form as pristine after loading data
this.downloadClientForm.markAsPristine();
this.hasActualChanges = false;
}
/**
* Store original form values for dirty checking
*/
private storeOriginalValues(): void {
this.originalFormValues = JSON.parse(JSON.stringify(this.downloadClientForm.value));
}
/**
* Check if the current form values are different from the original values
*/
private formValuesChanged(): boolean {
return !this.isEqual(this.downloadClientForm.value, this.originalFormValues);
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (obj1 === null || obj2 === null) return false;
if (obj1 === undefined || obj2 === undefined) return false;
if (typeof obj1 !== 'object' && typeof obj2 !== 'object') {
return obj1 === obj2;
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
if (Array.isArray(obj1) && Array.isArray(obj2)) {
if (obj1.length !== obj2.length) return false;
for (let i = 0; i < obj1.length; i++) {
if (!this.isEqual(obj1[i], obj2[i])) return false;
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return true;
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!this.isEqual(obj1[key], obj2[key])) return false;
}
return true;
}
/**
* Save the Download Client configuration
* Mark all controls in a form group as touched
*/
saveDownloadClientConfig(): void {
// Mark all form controls as touched to trigger validation
this.markFormGroupTouched(this.downloadClientForm);
private markFormGroupTouched(formGroup: FormGroup): void {
Object.values(formGroup.controls).forEach((control) => {
control.markAsTouched();
if (this.downloadClientForm.invalid) {
if ((control as any).controls) {
this.markFormGroupTouched(control as FormGroup);
}
});
}
/**
* Check if a form control has an error
*/
hasError(form: FormGroup, controlName: string, errorName: string): boolean {
const control = form.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Get clients from current config
*/
get clients(): ClientConfig[] {
return this.downloadClientConfig()?.clients || [];
}
/**
* Open modal to add new client
*/
openAddClientModal(): void {
this.modalMode = 'add';
this.editingClient = null;
this.clientForm.reset();
this.clientForm.patchValue({ enabled: true }); // Default enabled to true
this.showClientModal = true;
}
/**
* Open modal to edit existing client
*/
openEditClientModal(client: ClientConfig): void {
this.modalMode = 'edit';
this.editingClient = client;
// Map backend type to frontend type
const frontendType = client.typeName
? this.mapClientTypeFromBackend(client.typeName)
: client.type;
this.clientForm.patchValue({
name: client.name,
type: frontendType,
host: client.host,
username: client.username,
password: client.password,
urlBase: client.urlBase,
enabled: client.enabled
});
this.showClientModal = true;
}
/**
* Close client modal
*/
closeClientModal(): void {
this.showClientModal = false;
this.editingClient = null;
this.clientForm.reset();
}
/**
* Save client (add or edit)
*/
saveClient(): void {
this.markFormGroupTouched(this.clientForm);
if (this.clientForm.invalid) {
this.notificationService.showError('Please fix the validation errors before saving');
return;
}
if (!this.hasActualChanges) {
this.notificationService.showSuccess('No changes detected');
return;
}
const formValue = this.clientForm.value;
const mappedType = this.mapClientTypeForBackend(formValue.type);
const clientData: CreateDownloadClientDto = {
name: formValue.name,
typeName: mappedType.typeName,
type: mappedType.type,
host: formValue.host,
username: formValue.username,
password: formValue.password,
urlBase: formValue.urlBase,
enabled: formValue.enabled
};
// Get the clients from the form
const formClients = this.clients.getRawValue();
if (formClients.length === 0) {
this.notificationService.showSuccess('No clients to save');
return;
}
// Separate creates and updates
const creates: CreateDownloadClientDto[] = [];
const updates: Array<{ id: string, client: ClientConfig }> = [];
formClients.forEach((client: any) => {
// Map the client type for backend compatibility
const mappedType = this.mapClientTypeForBackend(client.type);
const backendClient = {
...client,
if (this.modalMode === 'add') {
this.downloadClientStore.createClient(clientData);
} else if (this.editingClient) {
// For updates, create a proper ClientConfig object
const clientConfig: ClientConfig = {
id: this.editingClient.id!,
name: formValue.name,
type: formValue.type, // Keep the frontend enum type
typeName: mappedType.typeName,
type: mappedType.type
host: formValue.host,
username: formValue.username,
password: formValue.password,
urlBase: formValue.urlBase,
enabled: formValue.enabled
};
if (client.id) {
// This is an existing client, prepare for update
updates.push({ id: client.id, client: backendClient });
} else {
// This is a new client, prepare for creation (don't send ID)
const { id, ...clientWithoutId } = backendClient;
creates.push(clientWithoutId as CreateDownloadClientDto);
}
});
// Use batch operations to handle everything at once
this.downloadClientStore.processBatchOperations({ creates, updates });
// Monitor the saving state to show completion feedback
this.monitorSavingCompletion();
this.downloadClientStore.updateClient({
id: this.editingClient.id!,
client: clientConfig
});
}
this.monitorClientSaving();
}
/**
* Monitor saving completion and show appropriate feedback
* Monitor client saving completion
*/
private monitorSavingCompletion(): void {
// Use a timeout to check the saving state periodically
private monitorClientSaving(): void {
const checkSavingStatus = () => {
const saving = this.downloadClientSaving();
const error = this.downloadClientError();
const pendingOps = this.pendingOperations();
if (!saving && pendingOps === 0) {
// Operations are complete
if (!saving) {
if (error) {
this.notificationService.showError(`Save completed with issues: ${error}`);
this.error.emit(error);
// Don't mark as pristine if there were errors
this.notificationService.showError(`Operation failed: ${error}`);
} else {
// Complete success
this.notificationService.showSuccess('Download Client configuration saved successfully');
this.saved.emit();
// Reload config from backend to ensure UI is in sync
this.downloadClientStore.loadConfig();
// Reset form state after successful save
setTimeout(() => {
this.downloadClientForm.markAsPristine();
this.hasActualChanges = false;
this.storeOriginalValues();
}, 100);
const action = this.modalMode === 'add' ? 'created' : 'updated';
this.notificationService.showSuccess(`Client ${action} successfully`);
this.closeClientModal();
}
} else {
// Still saving, check again in a short while
setTimeout(checkSavingStatus, 100);
}
};
// Start monitoring
setTimeout(checkSavingStatus, 100);
}
/**
* Reset the Download Client configuration form to default values
* Delete client with confirmation
*/
resetDownloadClientConfig(): void {
// Clear all clients
const clientsArray = this.downloadClientForm.get('clients') as FormArray;
clientsArray.clear();
// Check if this reset actually changes anything compared to the original state
const hasChangesAfterReset = this.formValuesChanged();
if (hasChangesAfterReset) {
// Only mark as dirty if the reset actually changes something
this.downloadClientForm.markAsDirty();
this.hasActualChanges = true;
} else {
// If reset brings us back to original state, mark as pristine
this.downloadClientForm.markAsPristine();
this.hasActualChanges = false;
}
deleteClient(client: ClientConfig): void {
this.confirmationService.confirm({
message: `Are you sure you want to delete the client "${client.name}"?`,
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
acceptButtonStyleClass: 'p-button-danger',
accept: () => {
this.downloadClientStore.deleteClient(client.id!);
// Monitor deletion
const checkDeletionStatus = () => {
const saving = this.downloadClientSaving();
const error = this.downloadClientError();
if (!saving) {
if (error) {
this.notificationService.showError(`Deletion failed: ${error}`);
} else {
this.notificationService.showSuccess('Client deleted successfully');
}
} else {
setTimeout(checkDeletionStatus, 100);
}
};
setTimeout(checkDeletionStatus, 100);
}
});
}
/**
* Add a new client to the clients form array
* @param client Optional client configuration to initialize the form with
* Get modal title based on mode
*/
addClient(client: ClientConfig | null = null): void {
// If client has typeName from backend, map it to frontend type
const frontendType = client?.typeName
? this.mapClientTypeFromBackend(client.typeName)
: client?.type || null;
const clientForm = this.formBuilder.group({
id: [client?.id || ''],
name: [client?.name || '', Validators.required],
type: [frontendType, Validators.required],
host: [client?.host || '', Validators.required],
username: [client?.username || ''],
password: [client?.password || ''],
urlBase: [client?.urlBase || ''],
enabled: [client?.enabled ?? true]
});
// Set up client type change handler
clientForm.get('type')?.valueChanges.subscribe(() => {
this.onClientTypeChange(clientForm);
});
this.clients.push(clientForm);
get modalTitle(): string {
return this.modalMode === 'add' ? 'Add Download Client' : 'Edit Download Client';
}
/**
* Map frontend client type to backend TypeName and Type
*/
@@ -373,95 +358,6 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
return DownloadClientType.QBittorrent;
}
}
/**
* Remove a client at the specified index
*/
removeClient(index: number): void {
const clientForm = this.getClientAsFormGroup(index);
const clientId = clientForm.get('id')?.value;
// If this is an existing client (has ID), delete it from the backend immediately
if (clientId) {
this.downloadClientStore.deleteClient(clientId);
}
// Remove from the form array
this.clients.removeAt(index);
// Mark form as dirty to enable save button
this.downloadClientForm.markAsDirty();
this.hasActualChanges = this.formValuesChanged();
// Don't automatically add an empty client - let users have a truly empty state
}
/**
* Mark all controls in a form group as touched to trigger validation
*/
private markFormGroupTouched(formGroup: FormGroup): void {
Object.values(formGroup.controls).forEach((control) => {
control.markAsTouched();
if ((control as any).controls) {
this.markFormGroupTouched(control as FormGroup);
}
});
}
/**
* Get a client at the specified index as a FormGroup
*/
public getClientAsFormGroup(index: number): FormGroup {
return this.clients.at(index) as FormGroup;
}
/**
* 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.downloadClientForm.get(controlName);
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Check if a client field has an error
* @param clientIndex The index of the client 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
*/
hasClientFieldError(clientIndex: number, fieldName: string, errorName: string): boolean {
if (!this.clients || !this.clients.controls[clientIndex]) return false;
const control = this.getClientAsFormGroup(clientIndex).get(fieldName);
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Custom validator to check if the input is a valid URI
*/
private uriValidator(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // Let required validator handle empty values
}
try {
const url = new URL(control.value);
// Check that we have a valid protocol (http or https)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { invalidProtocol: true };
}
return null; // Valid URI
} catch (e) {
return { invalidUri: true }; // Invalid URI
}
}
/**
* Checks if a client type is Usenet
@@ -472,11 +368,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
/**
* Handle client type changes to update validation
* @param clientFormGroup The form group containing the client type and host controls
*/
onClientTypeChange(clientFormGroup: FormGroup): void {
const clientType = clientFormGroup.get('type')?.value;
const hostControl = clientFormGroup.get('host');
onClientTypeChange(): void {
const clientType = this.clientForm.get('type')?.value;
const hostControl = this.clientForm.get('host');
if (!hostControl) return;
@@ -495,4 +390,15 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD
hostControl.updateValueAndValidity();
}
/**
* Get client type label for display
*/
getClientTypeLabel(client: ClientConfig): string {
const frontendType = client.typeName
? this.mapClientTypeFromBackend(client.typeName)
: client.type;
const option = this.clientTypeOptions.find(opt => opt.value === frontendType);
return option?.label || 'Unknown';
}
}