src/app/core/basic-datatypes/entity/edit-entity/edit-entity.component.ts
A form field to select among the entities of the given type(s). Can be configured as single or multi select.
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
EditEntityComponent
|
| selector | app-edit-entity |
| standalone | true |
| imports |
MatAutocompleteModule
MatChipsModule
FontAwesomeModule
MatTooltipModule
MatInputModule
MatCheckboxModule
MatSlideToggle
|
| styleUrls | ../../../common-components/basic-autocomplete/basic-autocomplete-dropdown.component.scss |
| templateUrl | ./edit-entity.component.html |
ReactiveFormsModule
MatAutocompleteModule
MatChipsModule
EntityBlockComponent
FontAwesomeModule
MatTooltipModule
MatInputModule
MatCheckboxModule
BasicAutocompleteComponent
MatSlideToggle
FormsModule
CustomFormControlDirective<T>
OnInit
DoCheck
EditComponent
Properties |
|
Methods |
Inputs |
Outputs |
| accessor | |
Type : function
|
|
Default value : (e) => {
if (e instanceof Entity) {
return e.toString();
}
if (typeof e === "string") {
return e;
}
return "?";
}
|
|
|
The accessor used for filtering and when selecting a new entity. |
|
| disableCreateNew | |
Type : boolean
|
|
|
Disable the option to type any text into the selection field and use a "Create new ..." link to open the form for a new entity. |
|
| multi | |
Type : boolean
|
|
Default value : false
|
|
|
Whether users can select multiple entities. |
|
| placeholder | |
Type : string
|
|
|
Inherited from
CustomFormControlDirective
|
|
|
The placeholder is what is seen when someone clicks into the input field and adds new entities. |
|
| showEntities | |
Type : boolean
|
|
Default value : true
|
|
|
Whether to show entities in the list. |
|
| additionalFilter | |
Default value : (_) => true
|
|
| entityType | |
Default value : undefined
|
|
|
Explicitly define the entity type(s) to select among.
Overrides the |
|
| 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
|
|
| required | |
Type : boolean
|
|
|
Inherited from
CustomFormControlDirective
|
|
| value | |
Type : T
|
|
|
Inherited from
CustomFormControlDirective
|
|
| valueChange | |
Type : EventEmitter
|
|
|
Inherited from
CustomFormControlDirective
|
|
| Async createNewEntity |
createNewEntity(input: string, type?: string)
|
|
Returns :
Promise<E>
|
| onContainerClick | ||||||
onContainerClick(event: MouseEvent)
|
||||||
|
Inherited from
CustomFormControlDirective
|
||||||
|
Parameters :
Returns :
void
|
| recalculateMatchingInactive | ||||||
recalculateMatchingInactive(newAutocompleteFilter?: (o?: Entity) => void)
|
||||||
|
Parameters :
Returns :
void
|
| toggleIncludeInactive |
toggleIncludeInactive()
|
|
Returns :
void
|
| blur |
blur()
|
|
Inherited from
CustomFormControlDirective
|
|
Returns :
void
|
| focus |
focus()
|
|
Inherited from
CustomFormControlDirective
|
|
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
|
| autocompleteComponent |
Type : BasicAutocompleteComponent<E | T>
|
Decorators :
@ViewChild(BasicAutocompleteComponent)
|
| control |
Type : Signal<FormControl<T>>
|
Default value : computed(() => {
this.controlSourceRefresh();
let control = this.ngControl?.control as FormControl<T>;
if (!control) {
control = this._formControl ?? new FormControl();
}
this._formControl = control;
return this._formControl;
})
|
|
The form control bound to the inner autocomplete.
Recomputed when ngControl appears (it is not a signal and may be set after
first render) so we stop using the fallback control. Named |
| createNewEntityOptions |
Type : Signal<CreateOptionConfig[]>
|
Default value : computed(() => {
if (this.isCreateDisabled()) {
return [];
}
return this.entityType().map((type) => ({
label: this.entityRegistry.get(type)?.label ?? type,
create: (input: string) => this.createNewEntity(input, type),
}));
})
|
|
One create option per configured entity type, shown as separate "Add new [TypeLabel]" entries in the autocomplete dropdown. |
| currentlyMatchingInactive |
Type : Signal<number>
|
Default value : computed(() => {
return this.allEntities
.value()
.filter((e) => e.inactive && this.autocompleteFilter()(e)).length;
})
|
| entityToId |
Type : unknown
|
Default value : () => {...}
|
| includeInactive |
Type : unknown
|
Default value : signal<boolean>(false)
|
| loading |
Type : Signal<boolean>
|
Default value : computed(() => this.allEntities.isLoading())
|
| Readonly loadingPlaceholder |
Type : unknown
|
Default value : $localize`:A placeholder for the input element when select options are not loaded yet:Loading...`
|
| 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 { resourceWithRetention } from "#src/app/utils/resourceWithRetention";
import {
ChangeDetectionStrategy,
Component,
computed,
DoCheck,
inject,
Input,
input,
InputSignal,
OnInit,
Resource,
Signal,
signal,
ViewChild,
} from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MatAutocompleteModule } from "@angular/material/autocomplete";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatChipsModule } from "@angular/material/chips";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatSlideToggle } from "@angular/material/slide-toggle";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { asArray } from "app/utils/asArray";
import { lastValueFrom, map, startWith, switchMap } from "rxjs";
import {
BasicAutocompleteComponent,
CreateOptionConfig,
} from "../../../common-components/basic-autocomplete/basic-autocomplete.component";
import { CustomFormControlDirective } from "../../../common-components/basic-autocomplete/custom-form-control.directive";
import { FormFieldConfig } from "../../../common-components/entity-form/FormConfig";
import { DynamicComponent } from "../../../config/dynamic-components/dynamic-component.decorator";
import { EntityRegistry } from "../../../entity/database-entity.decorator";
import { EditComponent } from "../../../entity/entity-field-edit/dynamic-edit/edit-component.interface";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../entity/model/entity";
import { FormDialogService } from "../../../form-dialog/form-dialog.service";
import { Logging } from "../../../logging/logging.service";
import { EntityAbility } from "../../../permissions/ability/entity-ability";
import { EntityBlockComponent } from "../entity-block/entity-block.component";
/**
* A form field to select among the entities of the given type(s).
* Can be configured as single or multi select.
*/
@DynamicComponent("EditEntity")
@Component({
selector: "app-edit-entity",
templateUrl: "./edit-entity.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: [
"../../../common-components/basic-autocomplete/basic-autocomplete-dropdown.component.scss",
],
imports: [
ReactiveFormsModule,
MatAutocompleteModule,
MatChipsModule,
EntityBlockComponent,
FontAwesomeModule,
MatTooltipModule,
MatInputModule,
MatCheckboxModule,
BasicAutocompleteComponent,
MatSlideToggle,
FormsModule,
],
providers: [
{ provide: MatFormFieldControl, useExisting: EditEntityComponent },
],
})
export class EditEntityComponent<
T extends string[] | string = string[],
E extends Entity = Entity,
>
extends CustomFormControlDirective<T>
implements OnInit, DoCheck, EditComponent
{
formFieldConfig = input<FormFieldConfig>();
@ViewChild(BasicAutocompleteComponent)
autocompleteComponent: BasicAutocompleteComponent<E, T>;
private readonly entityMapperService = inject(EntityMapperService);
private readonly formDialog = inject(FormDialogService);
private readonly entityRegistry = inject(EntityRegistry);
private readonly ability = inject(EntityAbility);
readonly loadingPlaceholder = $localize`:A placeholder for the input element when select options are not loaded yet:Loading...`;
/**
* Whether users can select multiple entities.
*/
@Input() multi: boolean = false;
/**
* Disable the option to type any text into the selection field and use a "Create new ..." link to open the form for a new entity.
*/
@Input() disableCreateNew: boolean;
/**
* The placeholder is what is seen when someone clicks into the input
* field and adds new entities.
*/
@Input() override placeholder: string;
/**
* Whether to show entities in the list.
*/
@Input() showEntities = true;
/**
* The accessor used for filtering and when selecting a new entity.
*/
@Input() accessor: (e: Entity | string) => string = (e) => {
if (e instanceof Entity) {
return e.toString();
}
if (typeof e === "string") {
return e;
}
return "?";
};
entityToId = (option: E) => option.getId();
additionalFilter: InputSignal<(e: E) => boolean> = input((_) => true);
/**
* The form control bound to the inner autocomplete.
* Recomputed when {@link ngControl} appears (it is not a signal and may be set after
* first render) so we stop using the fallback control. Named `control` to avoid clashing
* with the base `formControl` getter.
*/
control: Signal<FormControl<T>> = computed(() => {
this.controlSourceRefresh();
let control = this.ngControl?.control as FormControl<T>;
if (!control) {
control = this._formControl ?? new FormControl();
}
this._formControl = control;
return this._formControl;
});
private _formControl: FormControl<T>;
/**
* Manual recompute trigger for `formControl`.
* Needed because `ngControl` is not a signal and may appear after first render.
*/
private readonly controlSourceRefresh = signal(0);
/**
* Explicitly define the entity type(s) to select among.
* Overrides the `additional` configuration of the FormFieldConfig if given.
*/
entityTypeInput: Signal<string | string[]> = input(undefined, {
// eslint-disable-next-line @angular-eslint/no-input-rename
alias: "entityType",
});
entityType: Signal<string[]> = computed(() => {
let value = this.entityTypeInput();
if (!value || value.length === 0) {
value = this.formFieldConfig()?.additional;
}
return asArray(value ?? []);
});
private readonly allEntities: Resource<E[]> = resourceWithRetention({
defaultValue: [],
params: () => ({
entityTypes: this.entityType(),
additionalFilter: this.additionalFilter(),
loadType: (type: string) => this.entityMapperService.loadType<E>(type), // we cannot directly access `this.` within the loader (see https://github.com/Aam-Digital/ndb-core/pull/3410#issuecomment-3438380605)
}),
loader: async ({ params }) => {
if (params.entityTypes.length === 0) return [];
const entities: E[] = [];
for (const type of params.entityTypes) {
entities.push(...(await params.loadType(type)));
}
return entities
.filter((e) => params.additionalFilter(e))
.sort((a, b) => a.toString().localeCompare(b.toString()));
},
});
currentlyMatchingInactive: Signal<number> = computed(() => {
return this.allEntities
.value()
.filter((e) => e.inactive && this.autocompleteFilter()(e)).length;
});
readonly isCreateDisabled = computed(() => {
if (this.disableCreateNew === true) {
return true;
}
const entityTypes = this.entityType();
if (entityTypes.length === 0) {
return true;
}
const entityType = entityTypes[0];
return !this.ability.can("create", entityType);
});
/**
* One create option per configured entity type, shown as separate
* "Add new [TypeLabel]" entries in the autocomplete dropdown.
*/
createNewEntityOptions: Signal<CreateOptionConfig<E>[]> = computed(() => {
if (this.isCreateDisabled()) {
return [];
}
return this.entityType().map((type) => ({
label: this.entityRegistry.get(type)?.label ?? type,
create: (input: string) => this.createNewEntity(input, type),
}));
});
loading: Signal<boolean> = computed(() => this.allEntities.isLoading());
/**
* The currently selected values (IDs) of the form control.
*/
values: Signal<string[]> = toSignal(
toObservable(this.control)
.pipe(
switchMap((form) => {
// Emit both the initial value and subsequent value changes
return form.valueChanges.pipe(startWith(form.value));
}),
)
.pipe(map((value) => (value === undefined ? [] : asArray(value)))),
{ initialValue: [] },
);
includeInactive = signal<boolean>(false);
readonly availableEntitiesResource: Resource<E[]> = resourceWithRetention({
defaultValue: [],
params: () => ({
allEntities: this.allEntities.value(),
values: this.values(),
includeInactive: this.includeInactive(),
getEntity: (id: string) => this.getEntity(id), // we cannot directly access `this.` within the loader (see https://github.com/Aam-Digital/ndb-core/pull/3410#issuecomment-3438380605)
}),
loader: async ({ params }) => {
const availableEntities = params.allEntities.filter(
(e) =>
params.values.includes(e.getId()) ||
params.includeInactive ||
!e.inactive,
);
for (const id of params.values) {
if (id === null || id === undefined || id === "") {
continue;
}
if (availableEntities.find((e) => id === e.getId())) {
continue;
}
const additionalEntity = await params.getEntity(id);
if (additionalEntity) {
availableEntities.push(additionalEntity);
} else {
availableEntities.push({
getId: () => id,
isHidden: true,
} as unknown as E);
}
}
return availableEntities;
},
});
readonly hasInaccessible: Signal<boolean> = computed(() => {
const entities = this.availableEntitiesResource.value();
const currentValues = this.values();
return currentValues.some((id) => {
if (!id) return false;
const entity = entities.find((e) => e.getId() === id);
return entity && (entity as any).isHidden === true;
});
});
private async getEntity(selectedId: string): Promise<E | undefined> {
const type = Entity.extractTypeFromId(selectedId);
const entity = await this.entityMapperService
.load<E>(type, selectedId)
.catch((err: any) => {
if (err?.status === 404 || err?.status === 401) {
Logging.debug(
"[ENTITY_SELECT] Selected entity not found.",
selectedId,
);
} else {
Logging.warn(
"[ENTITY_SELECT] Error loading selected entity.",
selectedId,
err,
);
}
return undefined;
});
return entity;
}
toggleIncludeInactive() {
this.includeInactive.set(!this.includeInactive());
}
private autocompleteFilter = signal<(o: E) => boolean>(() => true);
recalculateMatchingInactive(newAutocompleteFilter?: (o: Entity) => boolean) {
if (newAutocompleteFilter) {
this.autocompleteFilter.set(newAutocompleteFilter);
}
}
async createNewEntity(input: string, type?: string): Promise<E> {
const entityTypes = this.entityType();
const targetType = type ?? entityTypes[0];
if (!targetType) {
return;
}
const newEntity = new (this.entityRegistry.get(targetType))();
applyTextToCreatedEntity(newEntity, input);
const dialogRef = this.formDialog.openFormPopup(newEntity);
return lastValueFrom<E | undefined>(dialogRef.afterClosed());
}
ngOnInit() {
this.multi = this.formFieldConfig()?.isArray ?? false;
}
override ngDoCheck() {
super.ngDoCheck();
const control = this.ngControl?.control;
if (!control || control === this._formControl) {
return;
}
// Force `formControl` recomputation so we stop using the fallback control
// and bind to the real parent form control.
this.controlSourceRefresh.update((v) => v + 1);
}
override onContainerClick(event: MouseEvent) {
this.autocompleteComponent?.onContainerClick(event);
}
}
/**
* Update the given entity by applying the text entered by a user
* to the most likely appropriate entity field, inferred from the toString representation.
*/
export function applyTextToCreatedEntity(entity: Entity, input: string) {
const toStringFields = entity.getConstructor().toStringAttributes;
if (!toStringFields || toStringFields.length < 1) {
return;
}
const inputParts = input.split(/\s+/);
for (let i = 0; i < inputParts.length; i++) {
const targetProperty =
toStringFields[i < toStringFields.length ? i : toStringFields.length - 1];
const currentValue = entity[targetProperty] ?? "";
entity[targetProperty] =
currentValue === ""
? inputParts[i]
: (currentValue + " " + inputParts[i]).trim();
}
return entity;
}
<div class="flex-row gap-small">
<app-basic-autocomplete
#autocomplete
(click)="onContainerClick($event)"
class="flex-grow"
display="chips"
[formControl]="control()"
[valueMapper]="entityToId"
[optionToString]="accessor"
(autocompleteFilterChange)="recalculateMatchingInactive($event)"
[multi]="multi"
[options]="availableEntitiesResource.value()"
[placeholder]="loading() ? loadingPlaceholder : placeholder"
[createOptions]="createNewEntityOptions()"
>
<ng-template let-item>
@if (!item.isHidden) {
<app-entity-block
style="margin: auto"
[entity]="item"
[linkDisabled]="enabled()"
></app-entity-block>
}
</ng-template>
@if (currentlyMatchingInactive() > 0) {
<ng-container autocompleteFooter>
<mat-slide-toggle
[checked]="includeInactive()"
(toggleChange)="toggleIncludeInactive()"
i18n="Label for checkbox|e.g. include inactive children"
>Also show {{ currentlyMatchingInactive() }} inactive
</mat-slide-toggle>
</ng-container>
}
</app-basic-autocomplete>
@if (enabled()) {
<fa-icon
icon="caret-down"
class="form-field-icon-suffix"
(click)="autocomplete.showAutocomplete()"
></fa-icon>
}
</div>
@if (hasInaccessible()) {
<div class="hint-text">
<fa-icon
icon="warning"
class="standard-icon-with-text warning-icon"
></fa-icon>
<span i18n
>Some records are hidden because you do not have permission to access them
(or they could not be found for other reasons).</span
>
</div>
}
../../../common-components/basic-autocomplete/basic-autocomplete-dropdown.component.scss
.autocomplete-container {
position: relative;
}
.icon-container {
position: absolute;
top: 0;
right: 0;
}