Search code examples
angularprimeng

Angular primeng FileSelector in a reactive form


I am trying to rebuild a form with several file selectors with primeng but primeng does only offer a FileUpload component which is not the same as a file selector. Same seems to apply for ngx-dropzone.

Example with one file selector:

<div>
  <form [formGroup]="model" (ngSubmit)="onSubmit()">
    <label for="fileUpload">Csv-File:</label>
    <input
      id="fileUpload"
      type="file"
      formControlName="csvFile"
    />
    <p>Complete the form to enable button.</p>
    <button type="submit" [disabled]="!model.valid">Submit</button>
  </form>
</div>

with

import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-input',
  templateUrl: './input.component.html',
  styleUrls: ['./input.component.scss'],
})
export class InputComponent {
  model = new FormGroup({
    csvFile: new FormControl('', [Validators.required]),
  });

  onSubmit() {
    console.log(JSON.stringify(this.model.value));
  }
}

Example which swagger would create from a spring boot app.

swagger-generated-two-file-selectors

What I have done so far is the following. However, that seems not the the best way to do that.

<div>
    <form (ngSubmit)="onSubmit()" [formGroup]="form">
        <div class="row" id="csvFileUpload">
            <div class="col">
                <p-button icon="pi pi-file" label="csv-Datei auswählen">
                    <input
            (input)="onCsvFileSelected($event)"
                        formControlName="csvFile"
                        style="opacity: 0; position: absolute; width: 100%; height: 100%"
                        type="file"
                    />
                </p-button>
            </div>
            <div class="col left-spacer">{{ csvFileShadowed.name }}</div>
        </div>

        <p>Complete the form to enable button.</p>
        <p-button [disabled]="!form.valid" type="submit">Submit</p-button>
    </form>
</div>
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
    selector: 'app-koko',
    templateUrl: './koko.component.html',
    styleUrls: ['./koko.component.scss']
})
export class KokoComponent {
    csvFileShadowed = {} as File;

    form = new FormGroup({
        csvFile: new FormControl('', [Validators.required])
    });

    onSubmit() {
        console.log(this.csvFileShadowed);
    }

    onCsvFileSelected($event: Event) {
        if ($event.target && $event.target instanceof HTMLInputElement) {
            let inputEvent = $event.target as HTMLInputElement;
            if (inputEvent.files?.length == 1) {
                this.csvFileShadowed = inputEvent.files[0];
            }
        }
    }
}
.row {
    display: flex; /* equal height of the children */
    width: fit-content;
}

.col {
    flex: 1; /* additionally, equal width */
    margin: 0;
    padding: 0;
    display: flex;
    align-items: center;
}

.left-spacer {
    margin-left: 0.5em;
  max-width: 50%;
}

p-button {
    white-space: nowrap;
}
  • What would be the reason that primeng does not offer a file selector component?
  • What would be the best way to use several file selectors in a (reactive) form?

Solution

  • The following solution seems to be working. Here another link for some better understanding: What is ngDefaultControl in Angular?.

    If anybody has some improvements please let me know!

    parent.component.html

    <div>
        <form (ngSubmit)="onSubmit()" [formGroup]="form">
            <p-dropdown
                [options]="banks"
                formControlName="bank"
                optionLabel="name"
                placeholder="Select..."
                [autoDisplayFirst]="false"
            ></p-dropdown>
            <app-customer-file-upload formControlName="csvFile" [customChooseLabel]="'csv Datei'" />
            <app-customer-file-upload formControlName="dmnFile" [customChooseLabel]="'dmn Datei'" />
            <p-button [disabled]="!form.valid" type="submit">Submit</p-button>
        </form>
    </div>
    

    parent.component.ts

    @Component({
    ...
    })
    export class KokoComponent implements OnInit {
        banks: Bank[] | undefined;
        form = {} as FormGroup;
    
        constructor() {}
    
        ngOnInit(): void {
            this.banks = [
                { name: 'Comdirect', code: 'COMDIRECT' },
                { name: 'Targobank', code: 'TARGOBANK' },
                { name: 'Ing', code: 'ING' }
            ];
    
            this.form = new FormGroup({
                bank: new FormControl(null, [Validators.required]),
                csvFile: new FormControl(null, [Validators.required]),
                dmnFile: new FormControl(null, [Validators.required])
            });
        }
    
        onSubmit() {
            let bank = this.form.get('bank')?.value;
            let csvFile = this.form.get('csvFile')?.value;
            let dmnFile = this.form.get('dmnFile')?.value;
            ... call the api service to the backend with the parameters ...
        }
    }
    

    custom-file-upload.component.html

    <p-fileUpload
      mode="advanced"
      [customUpload]="true"
      [chooseLabel]="customChooseLabel"
      (onSelect)="onSelect($event)"
      (onRemove)="onRemove($event)"
      [showUploadButton]="false"
      [showCancelButton]="false"
      [multiple]="false"
    ></p-fileUpload>
    

    custom-file-upload.component.ts

    export const CUSTOM_FILE_UPLOAD_VALUE_ACCESSOR: Provider = {
        provide: NG_VALUE_ACCESSOR,
        useExisting: forwardRef(() => CustomFileUploadComponent),
        multi: true
    };
    
    @Component({
        selector: 'app-customer-file-upload',
        templateUrl: './custom-file-upload.component.html',
        styleUrls: ['./custom-file-upload.component.scss'],
        providers: [CUSTOM_FILE_UPLOAD_VALUE_ACCESSOR]
    })
    export class CustomFileUploadComponent implements ControlValueAccessor {
        @Input()
        customChooseLabel: string | undefined;
    
        constructor() {}
    
        private onChange: Function | undefined;
    
        writeValue(obj: any): void {
            if (this.onChange) {
                this.onChange(obj);
            }
        }
    
        registerOnChange(fn: any): void {
            this.onChange = fn;
        }
    
        registerOnTouched(fn: any): void {}
    
        onSelect($event: FileSelectEvent) {
            const file = $event && $event.files[0];
            this.writeValue(file);
        }
    
        onRemove($event: FileRemoveEvent) {
            this.writeValue(null);
        }
    }
    

    ###### Update: ######

    After some more reading I came up with a hopefully better solution for the custom file upload component. Hope that helps others.

    <p-fileUpload
      #fileUploadComponentSelector
      mode="advanced"
      [customUpload]="true"
      [chooseLabel]="customChooseLabel"
      [accept]="acceptMimeType"
      (onSelect)="onSelect($event)"
      (onRemove)="onRemove()"
      [showUploadButton]="false"
      [showCancelButton]="false"
      [multiple]="false"
      [disabled]="disabled"
    ></p-fileUpload>
    
    import { AfterViewInit, Component, forwardRef, Input, Provider, Renderer2, ViewChild } from '@angular/core';
    import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
    import { FileSelectEvent, FileUpload } from 'primeng/fileupload';
    
    export const CUSTOM_FILE_UPLOAD_VALUE_ACCESSOR: Provider = {
        provide: NG_VALUE_ACCESSOR,
        useExisting: forwardRef(() => FileUploadComponent),
        multi: true
    };
    
    @Component({
        selector: 'app-file-upload',
        templateUrl: './file-upload.component.html',
        styleUrls: ['./file-upload.component.scss'],
        providers: [CUSTOM_FILE_UPLOAD_VALUE_ACCESSOR]
    })
    export class FileUploadComponent implements ControlValueAccessor, AfterViewInit {
        @ViewChild('fileUploadComponentSelector')
        fileUploadElement: FileUpload;
        @Input()
        customChooseLabel: string | undefined;
        @Input()
        acceptMimeType: string | undefined;
    
        private _onChange: Function = () => {};
        private _onTouched: Function = () => {};
        private _value: File | null = null;
        private viewInit = false;
        private _disabled = false;
    
        constructor(private _renderer: Renderer2) {}
    
        get value(): File | null {
            return this._value;
        }
    
        set value(v: File | null) {
            this.setValue(v, true);
        }
    
        get disabled(): boolean {
            return this._disabled;
        }
    
        ngAfterViewInit() {
            this.viewInit = true;
            this.setValue(this._value, false);
        }
    
        writeValue(value: any): void {
            this.setValue(value, false);
        }
    
        registerOnChange(fn: any): void {
            this._onChange = fn;
        }
    
        registerOnTouched(fn: any): void {
            this._onTouched = fn;
        }
    
        setDisabledState(isDisabled: boolean) {
            this._disabled = isDisabled;
        }
    
        onSelect($event: FileSelectEvent) {
            let fileFromEvent = null;
            if ($event && $event.files && $event.files.length > 0) {
                fileFromEvent = $event.files[0];
            }
            this.setValue(fileFromEvent, true);
        }
    
        onRemove() {
            this.setValue(null, true);
        }
    
        setValue(value: any, emitEvent: boolean) {
            this._value = value;
            if (this.viewInit) {
                const newValue: File[] = value == null ? [] : Array.of(value);
                this._renderer.setProperty(this.fileUploadElement, 'files', newValue);
            }
            if (emitEvent && typeof this._onChange === 'function') {
                this._onChange(value);
                this._onTouched(value);
            }
        }
    }
    

    Additionally, for testing I added ngx-cva-test-suite and added the following test.

    import { FileUploadComponent } from './file-upload.component';
    import { runValueAccessorTests } from 'ngx-cva-test-suite';
    import { KokoPrimengModule } from '../common-primeng/koko-primeng.module';
    
    runValueAccessorTests({
        component: FileUploadComponent,
        testModuleMetadata: {
            imports: [KokoPrimengModule],
            declarations: [FileUploadComponent]
        },
        supportsOnBlur: false,
        internalValueChangeSetter: (fixture, value) => {
            fixture.componentInstance.setValue(value, true);
        },
        getComponentValue: fixture => fixture.componentInstance.value,
        getValues: () => [new File([], 'test1.txt'), new File([], 'test2.txt'), new File([], 'test3.txt')]
    });
    

    For testing with ngx-cva-test-suite it seems some elements need to be public like the method setValue or value itself. Without that test they could be private. Hope I didn't miss anything there.

    And again: (improvements) => {pleaseLeaveAComment(improvements)}

    Further links: