From 791288b8efdc1aa631af6bbac01f7a0642f80971 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 27 Aug 2025 08:53:43 +0000 Subject: [PATCH 1/3] form validation WIP --- .../db-table-row-edit.component.html | 8 ++-- .../db-table-row-edit.component.ts | 21 +++++++++++ .../number/number.component.html | 1 + .../number/number.component.ts | 37 +++++++++++++++++-- .../text/text.component.html | 11 ++++++ .../record-edit-fields/text/text.component.ts | 10 +++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html index 21debaee0..de162aa85 100644 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html +++ b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html @@ -173,7 +173,7 @@

*ngIf="(keyAttributesListFromStructure.length || hasKeyAttributesFromURL) && permissions.edit" class="actions__continue" data-testid="record-save-and-continue-editing-button" - [disabled]="submitting || editRowForm.form.invalid" + [disabled]="submitting || !isFormValid" (click)="handleRowSubmitting(true)"> Save and continue editing @@ -181,20 +181,20 @@

diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts index f7c9986c4..519d979c8 100644 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts +++ b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts @@ -16,6 +16,7 @@ import { BreadcrumbsComponent } from '../ui-components/breadcrumbs/breadcrumbs.c import { CommonModule } from '@angular/common'; import { CompanyService } from 'src/app/services/company.service'; import { ConnectionsService } from 'src/app/services/connections.service'; +import { FormValidationService } from 'src/app/services/form-validation.service'; import { DBtype } from 'src/app/models/connection'; import { DbActionLinkDialogComponent } from '../dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component'; import { DbTableRowViewComponent } from '../dashboard/db-table-view/db-table-row-view/db-table-row-view.component'; @@ -130,6 +131,7 @@ export class DbTableRowEditComponent implements OnInit { private _notifications: NotificationsService, private _tableState: TableStateService, private _company: CompanyService, + private _formValidation: FormValidationService, private route: ActivatedRoute, private ngZone: NgZone, public router: Router, @@ -146,6 +148,9 @@ export class DbTableRowEditComponent implements OnInit { } ngOnInit(): void { + // Clear any previous validation states when component initializes + this._formValidation.clearAll(); + this.loading = true; this.connectionID = this._connections.currentConnectionID; this.tableFiltersUrlString = JsonURL.stringify(this._tableState.getBackUrlFilters()); @@ -443,6 +448,10 @@ export class DbTableRowEditComponent implements OnInit { return recordEditTypes[this.connectionType] } + get isFormValid(): boolean { + return this._formValidation.isFormValid(); + } + get currentConnection() { return this._connections.currentConnection; } @@ -609,6 +618,18 @@ export class DbTableRowEditComponent implements OnInit { } handleRowSubmitting(continueEditing: boolean) { + // Double-check validation before submitting + if (!this._formValidation.isFormValid()) { + const invalidFields = this._formValidation.getInvalidFields(); + console.warn('Form has validation errors in fields:', invalidFields); + this._notifications.showAlert( + AlertType.Error, + 'Please fix validation errors before submitting', + [] + ); + return; + } + if (this.hasKeyAttributesFromURL && this.pageAction !== 'dub') { this.updateRow(continueEditing); } else { diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html index 9511a12cc..4c6aa163d 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html @@ -1,6 +1,7 @@ {{normalizedLabel}} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts index adafd3c39..f3c3212a7 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts @@ -1,9 +1,10 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, ViewChild, AfterViewInit } from '@angular/core'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, NgModel } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { FormValidationService } from 'src/app/services/form-validation.service'; @Component({ selector: 'app-edit-number', @@ -11,8 +12,38 @@ import { MatInputModule } from '@angular/material/input'; styleUrls: ['./number.component.css'], imports: [MatFormFieldModule, MatInputModule, FormsModule] }) -export class NumberEditComponent extends BaseEditFieldComponent { +export class NumberEditComponent extends BaseEditFieldComponent implements AfterViewInit { @Input() value: number; + @ViewChild('numberField', { read: NgModel }) numberField: NgModel; static type = 'number'; + + constructor(private formValidationService: FormValidationService) { + super(); + } + + ngAfterViewInit(): void { + // Subscribe to value and status changes + if (this.numberField) { + // Initial validation state + this.updateFieldValidation(); + + // Listen for changes + this.numberField.valueChanges?.subscribe(() => { + this.updateFieldValidation(); + }); + + this.numberField.statusChanges?.subscribe(() => { + this.updateFieldValidation(); + }); + } + } + + private updateFieldValidation(): void { + if (this.numberField) { + const fieldIdentifier = `${this.label}-${this.key}`; + const isValid = this.numberField.valid || (!this.required && (this.value === null || this.value === undefined)); + this.formValidationService.updateFieldValidity(fieldIdentifier, isValid, this.numberField.errors); + } + } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html index 6ca6f5f34..66461a737 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html @@ -2,6 +2,17 @@ {{normalizedLabel}} >>>>>> Stashed changes attr.data-testid="record-{{label}}-text" [(ngModel)]="value" (ngModelChange)="onFieldChange.emit($event)"> \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts index b4edca35a..eefcde370 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts @@ -4,6 +4,12 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +<<<<<<< Updated upstream +======= +import { CommonModule } from '@angular/common'; +import { TextValidatorDirective } from 'src/app/directives/text-validator.directive'; +import { FieldValidationDirective } from 'src/app/directives/field-validation.directive'; +>>>>>>> Stashed changes @Injectable() @@ -11,7 +17,11 @@ import { MatInputModule } from '@angular/material/input'; selector: 'app-edit-text', templateUrl: './text.component.html', styleUrls: ['./text.component.css'], +<<<<<<< Updated upstream imports: [MatFormFieldModule, MatInputModule, FormsModule] +======= + imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule, TextValidatorDirective, FieldValidationDirective] +>>>>>>> Stashed changes }) export class TextEditComponent extends BaseEditFieldComponent { @Input() value: string; From 4b35f71abe63d200b74b233b1eef3fabd3fbd6cd Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 27 Aug 2025 09:30:39 +0000 Subject: [PATCH 2/3] form validation WIP --- .../record-edit-fields/text/text.component.html | 8 +------- .../record-edit-fields/text/text.component.ts | 11 +---------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html index 66461a737..c8e99feae 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html @@ -2,17 +2,11 @@ {{normalizedLabel}} >>>>>> Stashed changes attr.data-testid="record-{{label}}-text" [(ngModel)]="value" (ngModelChange)="onFieldChange.emit($event)"> - \ No newline at end of file + diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts index eefcde370..1233757aa 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts @@ -4,12 +4,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -<<<<<<< Updated upstream -======= -import { CommonModule } from '@angular/common'; -import { TextValidatorDirective } from 'src/app/directives/text-validator.directive'; import { FieldValidationDirective } from 'src/app/directives/field-validation.directive'; ->>>>>>> Stashed changes @Injectable() @@ -17,11 +12,7 @@ import { FieldValidationDirective } from 'src/app/directives/field-validation.di selector: 'app-edit-text', templateUrl: './text.component.html', styleUrls: ['./text.component.css'], -<<<<<<< Updated upstream - imports: [MatFormFieldModule, MatInputModule, FormsModule] -======= - imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule, TextValidatorDirective, FieldValidationDirective] ->>>>>>> Stashed changes + imports: [MatFormFieldModule, MatInputModule, FormsModule, FieldValidationDirective] }) export class TextEditComponent extends BaseEditFieldComponent { @Input() value: string; From 030e5d02390ac79834b127a29ac079b03e95e249 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 23 Oct 2025 12:32:43 +0000 Subject: [PATCH 3/3] missing files --- .../directives/field-validation.directive.ts | 87 +++++++++++++++ .../app/services/form-validation.service.ts | 101 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 frontend/src/app/directives/field-validation.directive.ts create mode 100644 frontend/src/app/services/form-validation.service.ts diff --git a/frontend/src/app/directives/field-validation.directive.ts b/frontend/src/app/directives/field-validation.directive.ts new file mode 100644 index 000000000..3e087ccb2 --- /dev/null +++ b/frontend/src/app/directives/field-validation.directive.ts @@ -0,0 +1,87 @@ +import { Directive, Input, OnDestroy, Optional } from '@angular/core'; +import { NgModel } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { FormValidationService } from '../services/form-validation.service'; + +@Directive({ + selector: '[fieldValidation][ngModel]' +}) +export class FieldValidationDirective implements OnDestroy { + @Input() fieldKey: string; + @Input() fieldLabel: string; + @Input() fieldRequired: boolean = false; + + private destroy$ = new Subject(); + private fieldIdentifier: string; + + constructor( + @Optional() private ngModel: NgModel, + private formValidationService: FormValidationService + ) {} + + ngAfterViewInit(): void { + if (!this.ngModel) { + console.warn('FieldValidationDirective requires ngModel'); + return; + } + + // Create unique field identifier + this.fieldIdentifier = `${this.fieldLabel}-${this.fieldKey}`; + + // Initial validation state + this.updateValidation(); + + // Subscribe to value changes + if (this.ngModel.valueChanges) { + this.ngModel.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateValidation(); + }); + } + + // Subscribe to status changes + if (this.ngModel.statusChanges) { + this.ngModel.statusChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateValidation(); + }); + } + } + + private updateValidation(): void { + if (!this.ngModel || !this.fieldIdentifier) { + return; + } + + const value = this.ngModel.value; + const errors = this.ngModel.errors; + + // Determine if field is valid + // A field is valid if: + // 1. It has no errors + // 2. OR it's not required and is empty/null/undefined + const isValid = + !errors || + (!this.fieldRequired && (value === null || value === undefined || value === '')); + + this.formValidationService.updateFieldValidity( + this.fieldIdentifier, + isValid, + errors + ); + } + + ngOnDestroy(): void { + // Clean up subscription + this.destroy$.next(); + this.destroy$.complete(); + + // Remove field from validation service + if (this.fieldIdentifier) { + this.formValidationService.removeField(this.fieldIdentifier); + } + } +} diff --git a/frontend/src/app/services/form-validation.service.ts b/frontend/src/app/services/form-validation.service.ts new file mode 100644 index 000000000..221bb6f55 --- /dev/null +++ b/frontend/src/app/services/form-validation.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +interface FieldValidationState { + isValid: boolean; + errors: any; + fieldIdentifier: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class FormValidationService { + private fieldStates = new Map(); + private formValidSubject = new BehaviorSubject(true); + + public formValid$: Observable = this.formValidSubject.asObservable(); + + constructor() { } + + /** + * Update the validity state of a field + */ + updateFieldValidity(fieldIdentifier: string, isValid: boolean, errors: any = null): void { + this.fieldStates.set(fieldIdentifier, { + fieldIdentifier, + isValid, + errors + }); + this.updateFormValidity(); + } + + /** + * Check if the entire form is valid + */ + isFormValid(): boolean { + if (this.fieldStates.size === 0) { + return true; + } + + for (const [_, state] of this.fieldStates) { + if (!state.isValid) { + return false; + } + } + + return true; + } + + /** + * Get list of invalid field identifiers + */ + getInvalidFields(): string[] { + const invalidFields: string[] = []; + + for (const [fieldIdentifier, state] of this.fieldStates) { + if (!state.isValid) { + invalidFields.push(fieldIdentifier); + } + } + + return invalidFields; + } + + /** + * Get validation errors for a specific field + */ + getFieldErrors(fieldIdentifier: string): any { + return this.fieldStates.get(fieldIdentifier)?.errors || null; + } + + /** + * Clear all validation states + */ + clearAll(): void { + this.fieldStates.clear(); + this.updateFormValidity(); + } + + /** + * Remove a specific field from validation tracking + */ + removeField(fieldIdentifier: string): void { + this.fieldStates.delete(fieldIdentifier); + this.updateFormValidity(); + } + + /** + * Update the form validity observable + */ + private updateFormValidity(): void { + this.formValidSubject.next(this.isFormValid()); + } + + /** + * Get all field states (useful for debugging) + */ + getAllFieldStates(): Map { + return new Map(this.fieldStates); + } +}