src/app/features/file/edit-file/edit-file.component.ts
This component should be used as a editComponent when a property should store files.
It allows to show, upload and remove files.
CustomFormControlDirective<string>
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
EditFileComponent
|
| selector | app-edit-file |
| standalone | true |
| imports |
MatInputModule
MatTooltipModule
MatButtonModule
|
| styleUrls | ./edit-file.component.scss, |
| templateUrl | ./edit-file.component.html |
MatInputModule
ReactiveFormsModule
MatTooltipModule
MatButtonModule
FaDynamicIconComponent
CustomFormControlDirective<string>
OnInit
EditComponent
Properties |
|
Methods |
|
Inputs |
Outputs |
| entity | |
Type : Entity
|
|
| formFieldConfig | |
Type : FormFieldConfig
|
|
| aria-describedby | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| disabled | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| ngControl | |
Type : any
|
|
Default value : inject(NgControl, { optional: true, self: true })
|
|
|
Inherited from
CustomFormControlDirective
|
|
| placeholder | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
| required | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| value | |
Type : T
|
|
|
Inherited from
CustomFormControlDirective
|
|
| valueChange | |
Type : EventEmitter
|
|
|
Inherited from
CustomFormControlDirective
|
|
| delete |
delete()
|
|
Returns :
void
|
| Protected deleteExistingFile |
deleteExistingFile()
|
|
Returns :
void
|
| formClicked |
formClicked()
|
|
Returns :
void
|
| Async onFileSelected | ||||||
onFileSelected(file: File)
|
||||||
|
Parameters :
Returns :
any
|
| Protected resetFile |
resetFile()
|
|
Returns :
void
|
| Protected saveNewFile | ||||||
saveNewFile(file: File)
|
||||||
|
Parameters :
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()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| focus |
focus()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnChange | ||||||
registerOnChange(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| registerOnTouched | ||||||
registerOnTouched(fn: any)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDescribedByIds | ||||||
setDescribedByIds(ids: string[])
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| setDisabledState | ||||||
setDisabledState(isDisabled: boolean)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| writeValue | |||||||||||||||
writeValue(value: T, notifyFormControl: unknown)
|
|||||||||||||||
|
Inherited from
CustomFormControlDirective
|
|||||||||||||||
|
Implementation for Angular ControlValueAccessor interface that links the form control value to the component value
Parameters :
Returns :
void
|
| 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"
|
|
Inherited from
CustomFormControlDirective
|
| elementRef |
Type : unknown
|
Default value : inject<ElementRef<HTMLElement>>(ElementRef)
|
|
Inherited from
CustomFormControlDirective
|
| Readonly enabled |
Type : Signal<boolean>
|
Default value : computed(() => !this._disabled())
|
|
Inherited from
CustomFormControlDirective
|
|
Whether the control is currently enabled, as a signal (tracks disabled). |
| errorStateMatcher |
Type : unknown
|
Default value : inject(ErrorStateMatcher)
|
|
Inherited from
CustomFormControlDirective
|
| id |
Type : unknown
|
Default value : `custom-form-control-${CustomFormControlDirective.nextId++}`
|
|
Inherited from
CustomFormControlDirective
|
| Static nextId |
Type : number
|
Default value : 0
|
|
Inherited from
CustomFormControlDirective
|
| onChange |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| onTouched |
Type : unknown
|
Default value : () => {...}
|
|
Inherited from
CustomFormControlDirective
|
| parentForm |
Type : unknown
|
Default value : inject(NgForm, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| parentFormGroup |
Type : unknown
|
Default value : inject(FormGroupDirective, { optional: true })
|
|
Inherited from
CustomFormControlDirective
|
| stateChanges |
Type : unknown
|
Default value : new Subject<void>()
|
|
Inherited from
CustomFormControlDirective
|
| Readonly valueSignal |
Type : Signal<T>
|
Default value : computed(() => this._value())
|
|
Inherited from
CustomFormControlDirective
|
|
The current value of the control as a signal.
Authoritative in both modes: it reflects the bound |
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;
}