Search code examples
angularformarrayformgroups

Multiple file-input with Angular Dynamic Form


TLDR: How to use FormArray or FormGroup with multiple file-input fields?

Hello everyone,

I want to build an event manager with Angular. Each event has a title, description, image and a flyer. The image is a png or jpg file-input and the flyer is a pdf one.

I need a toolbar performing the following actions:

toolbar

  • add should append a new event to the list withouth image or pdf
  • delete should remove all selected event
  • save should update to the database the event and load the two file-inputs files.

The list of event look like:

event

But I struggle to implement the form.

How can I use multiple file-input into a dynamic form with angular?

This is what I tried to do (event = promotion):

HTML:

  <form [formGroup]="promotionForm" *ngFor="let promotion of promotions; let i = index;">
    <div class="card" *ngIf="promotion.displayed" (click)="promotion.selected = !promotion.selected;">
      <div>
        <h2>Title</h2>
        <input matInput [(ngModel)]="promotion.title" [ngModelOptions]="{standalone: true}"/>
      </div>
      <div>
        <h2>Subtitle</h2>
        <input matInput [(ngModel)]="promotion.subtitle" [ngModelOptions]="{standalone: true}"/>
      </div>
      <div>
        <h2>Language</h2>
        <mat-select [(value)]="promotion.language"></mat-select>
      </div>
      <div class="description">
        <h2>Description</h2>
        <textarea matInput cdkAutosizeMinRows="5" [(ngModel)]="promotion.description"></textarea>
      </div>
      <div class="img">
        <div class="container">
          <h2>Image</h2>
          <mat-form-field>
            <ngx-mat-file-input formControlName="image" [multiple]="false" accept="image/webp, image/jpeg, image/png">
              <mat-icon ngxMatFileInputIcon>folder</mat-icon>
            </ngx-mat-file-input>
          </mat-form-field>
        </div>
        <img src="{{promotion.image}}">
      </div>
      <div class="pdf">
        <h2>PDF</h2>
        <mat-form-field>
          <ngx-mat-file-input formControlName="pdf" [multiple]="false" accept="application/pdf">
            <mat-icon ngxMatFileInputIcon>folder</mat-icon>
          </ngx-mat-file-input>
        </mat-form-field>
      </div>
      <mat-checkbox class="checkbox" [checked]="promotion.selected"></mat-checkbox>
    </div>
  </form>

TS:

promotions: Promotion[] = [];
  maxSize: number = 2; //Mo
  promotionForm: FormGroup;
  images: any[];
  pdfs: any[];

  constructor(private promotionService: PromotionService, private fb: FormBuilder) { }

  ngOnInit(): void {
    this.promotionForm = this.fb.group({
      image: [MaxSizeValidator(this.maxSize * 1024 * 1024)],
      pdf: [MaxSizeValidator(this.maxSize * 1024 * 1024)]
    })
    this.getAllPromotions();
  }

  getAllPromotions() {
    this.promotionService.getAllPromotions()
      .subscribe(promotions => {
        this.promotions = promotions.reverse();
        this.promotions.map(p => {
          p.selected = false;
          p.displayed = true;
        })
      })
  }

  deletePromotions() {
    let toDelete$ = this.promotions.filter(p => p.selected).map(p => { return this.promotionService.deletePromotion(p._id) });
    forkJoin(toDelete$).subscribe(() => this.getAllPromotions());
  }

  updatePromotions() {
    let toUpdate$ = this.promotions.filter(p => p.selected).map(p => { return this.promotionService.updatePromotion(p) });
    forkJoin(toUpdate$).subscribe(() => this.getAllPromotions());
  }

  selectAll() {
    if (this.promotions.filter(p => p.displayed).every(p => p.selected)) {
      this.promotions.map(p => p.selected = false)
    } else {
      this.promotions.filter(p => p.displayed).map(p => p.selected = true)
    }
  }

The issue with this solution is to get a unique file for each event. I want an unique file-input for each event and if I use FormGroup, I'm unable to have n file-input associated to each event. Should I use FormArrays of FormGroup and use image and pdf into each group?


Solution

  • This is how I achieved it.

    Instead of using formArray. I added a custom control for each objects. Here's my html

    <ng-container [formGroup]="promotion.filesForm">
            <div class="img">
              <div class="container">
                <h2>Image</h2>
                <mat-form-field>
                  <ngx-mat-file-input formControlName="image" [multiple]="false" accept="image/webp, image/jpeg, image/png" (change)="promotion.image=''">
                    <mat-icon ngxMatFileInputIcon>folder</mat-icon>
                  </ngx-mat-file-input>
                </mat-form-field>
              </div>
              <img src="{{promotion.image}}">
            </div>
            <div class="pdf">
              <h2>PDF</h2>
              <mat-form-field>
                <ngx-mat-file-input formControlName="pdf" [multiple]="false" accept="application/pdf">
                  <mat-icon ngxMatFileInputIcon>folder</mat-icon>
                </ngx-mat-file-input>
              </mat-form-field>
              <a href="{{promotion.pdf}}">Flyer</a>
            </div>
          </ng-container>
    

    And my ts

      addPromotionFileForm() {
        const fileForm = new FormGroup({
          image: new FormControl('', [MaxSizeValidator(this.maxSize * 1024 * 1024)]),
          pdf: new FormControl('', [MaxSizeValidator(this.maxSize * 1024 * 1024)])
        });
        return fileForm as FormGroup;
      }
    
      getAllPromotions() {
        this.promotionService.getAllPromotions()
          .subscribe(promotions => {
            this.promotions = promotions.reverse();
            this.promotions.map(p => {
              p.selected = false;
              p.displayed = true;
              p.filesForm = this.addPromotionFileForm();
            })
          })
      }
    

    When I want to upload them, I do a deep copy of my events array and I put the file into the image and pdf attributes.

      updatePromotions() {
        let payload = cloneDeep(this.promotions);
        payload.map(p => {
          p.image = p.filesForm.value.image;
          p.pdf = p.filesForm.value.pdf;
          delete p.filesForm;
        });
        let toUpdate$ = payload.filter(p => p.selected).map(p => { return this.promotionService.updatePromotion(p) });
        forkJoin(toUpdate$).subscribe(() => this.getAllPromotions());
      }
    

    Then I send them with my http service ensuring to pass the file with the FormData way and giving the good http headers ! (you must control that your api accept the header enctype

      updatePromotion(promotion: any) {
        const formData = new FormData();
        Object.keys(promotion).forEach(key => formData.append(key, promotion[key]));
        const httpOptions = {
          headers: new HttpHeaders({
            'enctype': 'multipart/form-data',
            'Authorization': this.auth
          })
        };
        return this.http.put<any>(this.apiUrl, formData, httpOptions);
      }
    

    Finally, my backend api take care of the database storage:

    First the route

    const auth = require('../middleware/auth');
    const multer = require('../middleware/multer');
    const promotionController = require('../controller/promotion-controller');
    
    const promotionUpload = [{
        name: 'image',
        maxCount: 1
      },
      {
        name: 'pdf',
        maxCount: 1
      }
    ];
    router.put('/', auth, multer.fields(promotionUpload), promotionController.updatePromotion);
    

    Then the controller

    exports.updatePromotion = (req, res) => {
      let payload = {
        ...req.body
      };
    
      // If req contains files
      if (req.files) {
        if (req.files['pdf']) {
          payload.pdf = req.protocol + "://" + req.get('host') + "/" + req.files['pdf'][0].path;
        }
        if (req.files['image']) {
          payload.image = req.protocol + "://" + req.get('host') + "/" + req.files['image'][0].path;
        }
      }
    
      // Update database
      Promotion.findByIdAndUpdate(payload._id, payload)
        .then(() => res.status(200).json("Success"))
        .catch((error) => res.status(500).json("Failure: " + error))
    }
    

    This is it! I used several day to solve this puzzle and I'm proud to show you a working solution. Dont hesitate to ask question. I would be happy to help