src/app/features/file/edit-file/edit-file.component.ts

Description

This component should be used as a editComponent when a property should store files. It allows to show, upload and remove files.

Extends

CustomFormControlDirective<string>

Implements

OnInit EditComponent

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Inputs

entity
Type : Entity
formFieldConfig
Type : FormFieldConfig
aria-describedby
Type : string
disabled
Type : boolean
ngControl
Type : any
Default value : inject(NgControl, { optional: true, self: true })
placeholder
Type : string
required
Type : boolean
value
Type : T

Outputs

valueChange
Type : EventEmitter

Methods

delete
delete()
Returns : void
Protected deleteExistingFile
deleteExistingFile()
Returns : void
formClicked
formClicked()
Returns : void
Async onFileSelected
onFileSelected(file: File)
Parameters :
Name Type Optional
file File No
Returns : any
Protected resetFile
resetFile()
Returns : void
Protected saveNewFile
saveNewFile(file: File)
Parameters :
Name Type Optional
file File No
Returns : void
Protected setInitialValue
setInitialValue()

Template method to allow easy override of mapping the initialValue from the formControl.

Returns : void
showFile
showFile()
Returns : void
blur
blur()
Returns : void
focus
focus()
Returns : void
onContainerClick
onContainerClick(event: MouseEvent)
Parameters :
Name Type Optional
event MouseEvent No
Returns : void
registerOnChange
registerOnChange(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
registerOnTouched
registerOnTouched(fn: any)
Parameters :
Name Type Optional
fn any No
Returns : void
setDescribedByIds
setDescribedByIds(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
setDisabledState
setDisabledState(isDisabled: boolean)
Parameters :
Name Type Optional
isDisabled boolean No
Returns : void
writeValue
writeValue(value: T, notifyFormControl: unknown)

Implementation for Angular ControlValueAccessor interface that links the form control value to the component value

Parameters :
Name Type Optional Default value Description
value T No

The new value to set

notifyFormControl unknown No false

Whether to notify the FormControl of this change (for internal updates)

Returns : void

Properties

acceptedFileTypes
Type : string
Default value : "*"

The accepted file types for file selection dialog. If not defined, allows any file.

Protected fileService
Type : unknown
Default value : inject(FileService)
fileUploadInput
Type : ElementRef<HTMLInputElement>
Decorators :
@ViewChild('fileUpload')
Readonly hasControlValue
Type : unknown
Default value : signal<boolean>(false)
initialValue
Type : string
Readonly isControlEnabled
Type : unknown
Default value : signal<boolean>(false)
Protected navigator
Type : unknown
Default value : inject<Navigator>(NAVIGATOR_TOKEN)
controlType
Type : string
Default value : "custom-control"
elementRef
Type : unknown
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
Readonly enabled
Type : Signal<boolean>
Default value : computed(() => !this._disabled())

Whether the control is currently enabled, as a signal (tracks disabled).

errorStateMatcher
Type : unknown
Default value : inject(ErrorStateMatcher)
id
Type : unknown
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
Static nextId
Type : number
Default value : 0
onChange
Type : unknown
Default value : () => {...}
onTouched
Type : unknown
Default value : () => {...}
parentForm
Type : unknown
Default value : inject(NgForm, { optional: true })
parentFormGroup
Type : unknown
Default value : inject(FormGroupDirective, { optional: true })
stateChanges
Type : unknown
Default value : new Subject<void>()
Readonly valueSignal
Type : Signal<T>
Default value : computed(() => this._value())

The current value of the control as a signal. Authoritative in both modes: it reflects the bound FormControl (synced in ngDoCheck) as well as [(value)] / writeValue updates.

import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  ElementRef,
  inject,
  input,
  OnInit,
  signal,
  ViewChild,
} from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatTooltipModule } from "@angular/material/tooltip";
import { distinctUntilChanged, startWith } from "rxjs/operators";
import { AlertService } from "../../../core/alerts/alert.service";
import { CustomFormControlDirective } from "../../../core/common-components/basic-autocomplete/custom-form-control.directive";
import { FaDynamicIconComponent } from "../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator";
import { EditComponent } from "../../../core/entity/entity-field-edit/dynamic-edit/edit-component.interface";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../core/entity/model/entity";
import { Logging } from "../../../core/logging/logging.service";
import { NotAvailableOfflineError } from "../../../core/session/not-available-offline.error";
import { NAVIGATOR_TOKEN } from "../../../utils/di-tokens";
import { FileService } from "../file.service";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";

/**
 * This component should be used as a `editComponent` when a property should store files.
 * It allows to show, upload and remove files.
 */
@DynamicComponent("EditFile")
@Component({
  selector: "app-edit-file",
  templateUrl: "./edit-file.component.html",
  styleUrls: [
    "./edit-file.component.scss",
    "../../../core/entity/entity-field-edit/dynamic-edit/dynamic-edit.component.scss",
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    MatInputModule,
    ReactiveFormsModule,
    MatTooltipModule,
    MatButtonModule,
    FaDynamicIconComponent,
  ],
  providers: [{ provide: MatFormFieldControl, useExisting: EditFileComponent }],
})
export class EditFileComponent
  extends CustomFormControlDirective<string>
  implements OnInit, EditComponent
{
  protected fileService = inject(FileService);
  private alertService = inject(AlertService);
  private entityMapper = inject(EntityMapperService);
  protected navigator = inject<Navigator>(NAVIGATOR_TOKEN);
  private destroyRef = inject(DestroyRef);

  entity = input<Entity>();
  formFieldConfig = input<FormFieldConfig>();

  @ViewChild("fileUpload") fileUploadInput: ElementRef<HTMLInputElement>;
  private selectedFile: File;
  private removeClicked = false;
  initialValue: string;
  readonly isControlEnabled = signal<boolean>(false);
  readonly hasControlValue = signal<boolean>(false);

  /**
   * The accepted file types for file selection dialog.
   * If not defined, allows any file.
   */
  acceptedFileTypes: string = "*";

  ngOnInit() {
    this.initialValue = this.formControl.value;
    this.isControlEnabled.set(this.formControl.enabled);
    this.hasControlValue.set(!!this.formControl.value);

    this.acceptedFileTypes =
      this.formFieldConfig()?.additional?.acceptedFileTypes ??
      this.acceptedFileTypes;

    this.formControl.statusChanges
      .pipe(
        startWith(this.formControl.status),
        distinctUntilChanged(),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((status) => {
        this.isControlEnabled.set(this.formControl.enabled);
        if (status !== "DISABLED") {
          return;
        }

        if (
          this.selectedFile &&
          this.selectedFile.name === this.formControl.value
        ) {
          this.saveNewFile(this.selectedFile);
        } else if (
          this.removeClicked &&
          !this.formControl.value &&
          !!this.initialValue
        ) {
          this.deleteExistingFile();
        } else {
          this.resetFile();
        }
      });

    this.formControl.valueChanges
      .pipe(
        startWith(this.formControl.value),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((value) => {
        this.hasControlValue.set(!!value);
      });
  }

  /**
   * Template method to allow easy override of mapping the initialValue from the formControl.
   * @protected
   */
  protected setInitialValue() {}

  async onFileSelected(file: File) {
    // directly reset input so subsequent selections with the same name also trigger the change event
    this.fileUploadInput.nativeElement.value = "";
    this.selectedFile = file;
    this.formControl.markAsDirty();
    this.formControl.setValue(file.name);
  }

  protected saveNewFile(file: File) {
    const entity = this.entity();
    const formFieldConfig = this.formFieldConfig();
    if (!entity || !formFieldConfig) {
      return;
    }
    // The maximum file size is set to 5 MB
    this.fileService.uploadFile(file, entity, formFieldConfig.id).subscribe({
      error: (err) => this.handleError(err),
      complete: () => {
        this.initialValue = this.formControl.value;
        this.selectedFile = undefined;
      },
    });
  }

  private handleError(err) {
    let errorMessage: string;
    if (err?.status === 413) {
      errorMessage = $localize`:File Upload Error Message:File too large. Usually files up to 5 MB are supported.`;
    } else if (err instanceof NotAvailableOfflineError) {
      errorMessage = $localize`:File Upload Error Message:Changes to file attachments are not available offline.`;
    } else {
      Logging.error("Failed to update file", {
        status: err?.status,
        message: err?.message,
      });
      errorMessage = $localize`:File Upload Error Message:Failed to update file attachment. Please try again.`;
    }
    this.alertService.addDanger(errorMessage);

    return this.revertEntityChanges();
  }

  private async revertEntityChanges() {
    const entity = this.entity();
    const formFieldConfig = this.formFieldConfig();
    if (!entity || !formFieldConfig) {
      this.resetFile();
      return;
    }
    // ensure we have latest _rev of entity
    const loadedEntity = await this.entityMapper.load(
      entity.getConstructor(),
      entity.getId(),
    );

    // Reset entity to how it was before
    loadedEntity[formFieldConfig.id] = this.initialValue;
    this.formControl.setValue(this.initialValue);

    await this.entityMapper.save(loadedEntity);

    this.resetFile();
  }

  formClicked() {
    if (this.initialValue && this.formControl.value === this.initialValue) {
      this.showFile();
    } else {
      if (this.formControl.enabled) {
        this.fileUploadInput.nativeElement.click();
      }
    }
  }

  showFile() {
    const entity = this.entity();
    const formFieldConfig = this.formFieldConfig();
    if (this.initialValue && this.formControl.value === this.initialValue) {
      if (!entity || !formFieldConfig) {
        return;
      }
      this.fileService.showFile(entity, formFieldConfig.id);
    }
  }

  delete() {
    this.formControl.markAsDirty();
    this.formControl.setValue(undefined);
    this.selectedFile = undefined;
    // remove is only necessary if an initial value was set
    this.removeClicked = true;
  }

  protected deleteExistingFile() {
    const entity = this.entity();
    const formFieldConfig = this.formFieldConfig();
    if (!entity || !formFieldConfig) {
      return;
    }
    this.fileService.removeFile(entity, formFieldConfig.id).subscribe({
      error: (err) => this.handleError(err),
      complete: () => {
        this.alertService.addInfo(
          $localize`:Message for user:File "${this.initialValue}" deleted`,
        );
        this.initialValue = undefined;
        this.removeClicked = false;
      },
    });
  }

  protected resetFile() {
    this.selectedFile = undefined;
  }
}
<input
  type="file"
  [accept]="acceptedFileTypes"
  style="display: none"
  (change)="onFileSelected($event.target['files'][0])"
  #fileUpload
/>

<div
  class="flex-row gap-regular"
  [class.clickable]="hasControlValue()"
  (click)="navigator.onLine ? formClicked() : null"
>
  <input
    matInput
    readonly
    class="filename"
    [formControl]="formControl"
    i18n-placeholder="placeholder for file-input"
    placeholder="No file selected"
    i18n-matTooltip="Tooltip show file"
    matTooltip="Show file"
    [matTooltipDisabled]="!(initialValue && valueSignal() === initialValue)"
  />

  @if (hasControlValue() && isControlEnabled()) {
    <button
      type="button"
      mat-icon-button
      (click)="delete(); $event.stopPropagation()"
      i18n-matTooltip
      matTooltip="Remove file"
      [disabled]="!navigator.onLine"
      class="input-action-button"
    >
      <app-fa-dynamic-icon icon="xmark"></app-fa-dynamic-icon>
    </button>
  }
  @if (isControlEnabled()) {
    <button
      type="button"
      mat-icon-button
      (click)="fileUpload.click(); $event.stopPropagation()"
      i18n-matTooltip="Tooltip upload file button"
      matTooltip="Upload file"
      [disabled]="!navigator.onLine"
      class="input-action-button"
    >
      <app-fa-dynamic-icon icon="upload"></app-fa-dynamic-icon>
    </button>
  }
</div>

@if (!navigator.onLine && isControlEnabled()) {
  <div class="hint-text" i18n>Changes to files are not possible offline.</div>
}

./edit-file.component.scss

/* let click events on disabled input be handled by parent element (because browsers completely eat them up otherwise)
 https://stackoverflow.com/a/32925830/1473411  */
input[disabled] {
  pointer-events: none;
}

.clickable,
.clickable * {
  cursor: pointer;
}

.filename {
  font-style: italic;
}

../../../core/entity/entity-field-edit/dynamic-edit/dynamic-edit.component.scss

.input-action-button {
  margin: -12px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""