javascriptangulartypescriptrecursionangular-reactive-forms

How to validate that 2 folders on the same level cannot have the same name in a recursive Angular form array


I'm not sure if my question title is clear enough but I will try to give more details. I'm trying to create a folder hierarchy form using angular forms. The form can have unlimited nesting. My problem is that now I can add 2 folders with the same name on a certain level but this should not be possible and should warn the user. This is logical because in a normal file system 2 folders cannot have same name

I present a simplified version here for clarity. still a bit long to read but here is reproducible demo in stackblitz with same code

form component

@Component({
  selector: 'my-form',
  templateUrl: './form.component.html',
  styleUrls: ['./form.component.css'],
})
export class FormComponent implements OnInit {
  myForm!: FormGroup;
  isHierarchyVisible: boolean = false;

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit() {
    this.myForm = this.formBuilder.group({
      folderHierarchy: this.formBuilder.array([]),
    });
    if (this.folderHierarchy.length === 0) this.isHierarchyVisible = false;
  }

  removeFolder(index: number): void {
    this.folderHierarchy.removeAt(index);
    if (this.folderHierarchy.length === 0) this.isHierarchyVisible = false;
  }

  addFolder(): void {
    this.folderHierarchy.push(
      this.formBuilder.group({
        name: [null, [Validators.required]],
        subFolders: this.formBuilder.array([]),
        level: 0,
      })
    );
    this.isHierarchyVisible = true;
  }

  getForm(control: AbstractControl): FormGroup {
    return control as FormGroup;
  }

  get folderHierarchy(): FormArray {
    return this.myForm.get('folderHierarchy') as FormArray;
  }
}
<p>folder form. type in form name and press enter</p>
<form [formGroup]="myForm">
  <div formArrayName="folderHierarchy">
    <label for="folderHierarchy">create folder</label>
    <div>
      <button type="button" class="btn btn-custom rounded-corners btn-circle mb-2" (click)="addFolder()" [disabled]="!folderHierarchy.valid">
        Add
      </button>
      <span class="pl-1">new folder</span>
    </div>
    <div>
      <div *ngIf="!folderHierarchy.valid" class="folder-hierarchy-error">invalid folder hierarchy</div>
      <div class="folderContainer">
        <div>
          <div *ngFor="let folder of folderHierarchy.controls; let i = index" [formGroupName]="i">
            <folder-hierarchy (remove)="removeFolder(i)" [folder]="getForm(folder)" [index]="i"></folder-hierarchy>
          </div>
        </div>
      </div>
    </div>
  </div>
</form>

folder-hierarchy component

@Component({
  selector: 'folder-hierarchy',
  templateUrl: './folder-hierarchy.component.html',
  styleUrls: ['./folder-hierarchy.component.css'],
})
export class FolderHierarchyComponent implements OnInit {
  constructor(private formBuilder: FormBuilder) {}
  @Output() remove = new EventEmitter();
  @Input() folder!: FormGroup;
  @Input() index!: number;
  tempName: string = '';

  ngOnInit() {}

  addSubFolder(folder: FormGroup): void {
    (folder.get('subFolders') as FormArray).push(
      this.formBuilder.group({
        name: [null, [Validators.required]],
        subFolders: this.formBuilder.array([]),
        level: folder.value.level + 1,
      })
    );
  }

  getControls(folder: FormGroup): FormGroup[] {
    return (folder.get('subFolders') as FormArray).controls as FormGroup[];
  }

  removeSubFolder(folder: FormGroup, index: number): void {
    (folder.get('subFolders') as FormArray).removeAt(index);
  }

  removeFolder(folder: { value: { subFolders: string | any[] } }): void {
    this.remove.emit(folder);
  }

  disableAdd(folder: { invalid: any }): void {
    return this.folder.invalid || folder.invalid;
  }
  onKeyup(event: KeyboardEvent): void {
    this.tempName = (event.target as HTMLInputElement).value;
  }
  updateName(folder: FormGroup, name: string): void {
    folder.get('name')?.setValue(name);
    if (this.isInvalid(folder)) {
      folder.get('name')?.updateValueAndValidity();
      return;
    }
  }

  isInvalid(folder: FormGroup): boolean {
    return !folder.get('name')?.valid;
  }
}
<div *ngIf="folder" #folderRow class="folder-row">
  <div class="folder-header">
    <div class="folder-name-container">
      <label for="folderName" class="folder-name-label">Name:</label>
      <input #folderName id="folderName" [ngClass]="isInvalid(folder) ? 'invalid-input' : ''" class="folder-name-input" placeholder="Folder Name" type="text" (keyup)="onKeyup($event)" maxlength="50" (keyup.enter)="updateName(folder, $any($event.target).value)" [value]="folder.value.name" autocomplete="off" />
    </div>
    <button type="button" class="btn-remove-folder" (click)="removeFolder(folder)">Remove</button>
    <button type="button" class="btn-add-subfolder" [disabled]="disableAdd(folder)" (click)="addSubFolder(folder)">Add Subfolder</button>
  </div>
  <div *ngIf="folder && folder.value.subFolders.length > 0" class="subfolder-container">
    <div *ngFor="let subFolder of getControls(folder); let i = index" class="subfolder-item">
      <folder-hierarchy (remove)="removeSubFolder(folder, i)" [folder]="subFolder"></folder-hierarchy>
    </div>
  </div>
</div>

Solution

  • My answer will be fully working with the Reactive forms.

    To summarize your requirements and the work on:

    1. Not allow duplicate names in the same directory.

      1.1. Implement the custom validator function duplicateFolderName based on the provided parentDirectory. We have 2 scenarios:

      1.1.1. The first case is the folder with the first level which itself is the root. We access the folderHierarchy form array (control.parent.parent) to get the same-level object(s).

      1.1.2. The second case is the folder that has the parent. We provide the parentDirectory which contains the subfolders form array and through it to get the same-level object(s).

      1.2. For the FolderHierarchyComponent, you need to implement the parentDirectory @Input decorator to pass the parent form to the component. Note that for first level folder shouldn't provide the value to [parentDirectory].

    2. Perform validation(s) only when pressing Enter key.

      2.1. Angular supports the event that triggers the validation only such as blur, submit, and change (default). For this scenario, add the validateOn: 'blur' to the name control. Next, set the (keyup.enter)="folderName.blur()" to the name control to blur (lose focus) the control when pressing Enter key.

    form.component.html

    <p>folder form. type in form name and press enter</p>
    <form [formGroup]="myForm">
      <div formArrayName="folderHierarchy">
        <label for="folderHierarchy">create folder </label>
        <div>
          <div>
            <button
              type="button"
              class="btn btn-custom rounded-corners btn-circle mb-2"
              (click)="addFolder()"
              [disabled]="!folderHierarchy.valid"
            >
              Add
            </button>
            <span class="pl-1"> new folder</span>
          </div>
          <div>
            <div *ngIf="!folderHierarchy.valid" class="folder-hierarchy-error">
              invalid folder hierarchy
            </div>
            <div class="folderContainer">
              <div>
                <div
                  *ngFor="let folder of folderHierarchy.controls; let i = index"
                  [formGroupName]="i"
                >
                  <folder-hierarchy
                    (remove)="removeFolder(i)"
                    [folder]="getForm(folder)"
                    [index]="i"
                  >
                  </folder-hierarchy>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </form>
    

    form.component.ts

    import { duplicateFolderName } from '../validators/duplicate-folder-name.validator';
    
    export class FormComponent implements OnInit {
      myForm!: FormGroup;
      isHierarchyVisible: boolean = false;
    
      constructor(private formBuilder: FormBuilder) {}
    
      ngOnInit() {
        this.myForm = this.formBuilder.group({
          folderHierarchy: this.formBuilder.array([]),
        });
        if (this.folderHierarchy.length === 0) this.isHierarchyVisible = false;
      }
    
      removeFolder(index: number): void {
        this.folderHierarchy.removeAt(index);
        if (this.folderHierarchy.length === 0) this.isHierarchyVisible = false;
      }
    
      addFolder(): void {
        this.folderHierarchy.push(
          this.formBuilder.group({
            name: [
              null,
              {
                validators: [
                  Validators.required,
                  duplicateFolderName(),
                ],
                updateOn: 'blur',
              },
            ],
            subFolders: this.formBuilder.array([]),
            level: 0,
          })
        );
        this.isHierarchyVisible = true;
      }
    
      getForm(control: AbstractControl): FormGroup {
        return control as FormGroup;
      }
    
      get folderHierarchy(): FormArray {
        return this.myForm.get('folderHierarchy') as FormArray;
      }
    }
    

    folder-hierarchy.component.html

    <div *ngIf="folder" #folderRow class="folder-row" [formGroup]="folder">
      <div class="folder-header">
        <div class="folder-name-container">
          <label for="folderName" class="folder-name-label">Name:</label>
          <input
            #folderName
            id="folderName"
            [ngClass]="nameControl.errors ? 'invalid-input' : ''"
            class="folder-name-input"
            placeholder="Folder Name"
            type="text"
            maxlength="50"
            autocomplete="off"
            name="name"
            formControlName="name"
            (keyup.enter)="folderName.blur()"
          />
        </div>
    
        <button
          type="button"
          class="btn-remove-folder"
          (click)="removeFolder(folder)"
        >
          Remove
        </button>
    
        <button
          type="button"
          class="btn-add-subfolder"
          [disabled]="disableAdd(folder)"
          (click)="addSubFolder(folder)"
        >
          Add Subfolder
        </button>
      </div>
    
      <div
        *ngIf="folder && folder.value.subFolders.length > 0"
        class="subfolder-container"
      >
        <div
          *ngFor="
            let subFolder of getSubFoldersControls(folder);
            let i = index
          "
          class="subfolder-item"
        >
          <folder-hierarchy
            (remove)="removeSubFolder(folder, i)"
            [folder]="subFolder"
            [parentDirectory]="folder"
          >
          </folder-hierarchy>
        </div>
      </div>
    
      <div
        *ngIf="nameControl.errors && nameControl.errors.required"
        class="folder-hierarchy-error"
      >
        Name is required.
      </div>
    
      <div
        *ngIf="nameControl.errors && nameControl.errors.duplicateName"
        class="folder-hierarchy-error"
      >
        Name already exists
      </div>
    </div>
    

    folder-hierarchy.component.ts

    import { duplicateFolderName } from '../validators/duplicate-folder-name.validator';
    
    @Component({
      selector: 'folder-hierarchy',
      templateUrl: './folder-hierarchy.component.html',
      styleUrls: ['./folder-hierarchy.component.css'],
    })
    export class FolderHierarchyComponent implements OnInit {
      constructor(private formBuilder: FormBuilder) {}
      @Output() remove = new EventEmitter();
      @Input() folder!: FormGroup;
      @Input() index!: number;
      @Input() parentDirectory?: FormGroup;
    
      ngOnInit() {}
    
      addSubFolder(folder: FormGroup): void {
        (folder.get('subFolders') as FormArray).push(
          this.formBuilder.group({
            name: [
              null,
              {
                validators: [
                  Validators.required,
                  duplicateFolderName(this.parentDirectory),
                ],
                updateOn: 'blur',
              },
            ],
            subFolders: this.formBuilder.array([]),
            level: folder.value.level + 1,
          })
        );
      }
    
      getControls(folder: FormGroup): FormArray {
        return folder.get('subFolders') as FormArray;
      }
    
      getSubFoldersControls(folder: FormGroup): FormGroup[] {
        return (folder.get('subFolders') as FormArray).controls as FormGroup[];
      }
    
      removeSubFolder(folder: FormGroup, index: number): void {
        (folder.get('subFolders') as FormArray).removeAt(index);
      }
    
      removeFolder(folder: { value: { subFolders: string | any[] } }): void {
        this.remove.emit(folder);
      }
    
      disableAdd(folder: { invalid: any }): void {
        return this.folder.invalid || folder.invalid;
      }
    
      get nameControl() {
        return this.folder.get('name') as FormControl;
      }
    }
    

    validators/duplicate-folder-name.validator.ts

    import {
      AbstractControl,
      FormArray,
      FormGroup,
      ValidatorFn,
    } from '@angular/forms';
    
    export const duplicateFolderName = (
      parentDirectory?: FormGroup
    ): ValidatorFn => {
      return (control: AbstractControl): { [key: string]: any } | null => {
        if (!control.value) return null;
    
        let folderNamesInSameDirectory: string[] = parentDirectory
          ? (parentDirectory.get('subFolders').value as any[]).map((x) => x.name)
          : (control.parent.parent.value as any[]).map((x) => x.name);
    
        console.log(folderNamesInSameDirectory);
    
        if (folderNamesInSameDirectory.indexOf(control.value) > -1)
          return {
            duplicateName: true,
          };
    
        return null;
      };
    };
    

    Demo @ StackBlitz