src/app/features/attendance/edit-attendance/edit-attendance.component.ts
Edit component for the attendance datatype.
Manages an array of AttendanceItem objects, each with a participant entity reference, attendance status, and remarks.
Participants can be of any entity type configured via the field's additional.participant.additional.
CustomFormControlDirective<AttendanceItem[]>
| changeDetection | ChangeDetectionStrategy.OnPush |
| providers |
EditAttendanceComponent
|
| selector | app-edit-attendance |
| standalone | true |
| imports |
FontAwesomeModule
MatButtonModule
MatInputModule
MatCardModule
|
| styleUrls | ./edit-attendance.component.scss |
| templateUrl | ./edit-attendance.component.html |
No results matching.
NgTemplateOutlet
ReactiveFormsModule
EditEntityComponent
FontAwesomeModule
EntityBlockComponent
MatButtonModule
EditConfigurableEnumComponent
MatInputModule
MatCardModule
CustomFormControlDirective<AttendanceItem[]>
OnInit
EditComponent
Properties |
Methods |
Inputs |
Outputs |
constructor()
|
| 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
|
|
| addParticipant | ||||||
addParticipant(participantId: string)
|
||||||
|
Parameters :
Returns :
void
|
| getAttendanceItem | ||||||
getAttendanceItem(participantId: string)
|
||||||
|
Parameters :
Returns :
AttendanceItem | undefined
|
| getStatusControl | ||||||
getStatusControl(participantId: string)
|
||||||
|
Returns (and lazily creates) a FormControl for the given participant's status
Parameters :
Returns :
FormControl<ConfigurableEnumValue>
|
| removeParticipant | ||||||
removeParticipant(participantId: string)
|
||||||
|
Parameters :
Returns :
void
|
| updateAttendanceValue | ||||||||||||
updateAttendanceValue(participantId: string, property: "status" | "remarks", newValue: any)
|
||||||||||||
|
Parameters :
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
|
| addParticipantControl |
Type : unknown
|
Default value : new FormControl<string>(null)
|
|
Internal form control for the entity autocomplete to add new participants |
| attendanceItems |
Type : unknown
|
Default value : signal<AttendanceItem[]>([])
|
|
Signal reflecting the current attendance items from the form control |
| compact |
Type : unknown
|
Default value : signal(false)
|
|
Whether the component is rendered in compact (mobile-like) layout based on its own width |
| isDisabled |
Type : unknown
|
Default value : signal(false)
|
|
Signal reflecting whether the form control is currently disabled |
| participantFilter |
Type : WritableSignal<boolean>
|
Default value : signal(
() => true,
)
|
|
Filter to exclude already-added participants from the autocomplete |
| 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 {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
input,
OnInit,
signal,
WritableSignal,
} from "@angular/core";
import { NgTemplateOutlet } from "@angular/common";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { startWith } from "rxjs";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatCardModule } from "@angular/material/card";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { EditEntityComponent } from "#src/app/core/basic-datatypes/entity/edit-entity/edit-entity.component";
import { EntityBlockComponent } from "#src/app/core/basic-datatypes/entity/entity-block/entity-block.component";
import { CustomFormControlDirective } from "#src/app/core/common-components/basic-autocomplete/custom-form-control.directive";
import { FormFieldConfig } from "#src/app/core/common-components/entity-form/FormConfig";
import { DynamicComponent } from "#src/app/core/config/dynamic-components/dynamic-component.decorator";
import { Entity } from "#src/app/core/entity/model/entity";
import { EditComponent } from "#src/app/core/entity/entity-field-edit/dynamic-edit/edit-component.interface";
import { EditConfigurableEnumComponent } from "#src/app/core/basic-datatypes/configurable-enum/edit-configurable-enum/edit-configurable-enum.component";
import { AttendanceItem } from "../model/attendance-item";
import {
ATTENDANCE_STATUS_CONFIG_ID,
AttendanceStatusType,
NullAttendanceStatusType,
} from "../model/attendance-status";
import { ConfigurableEnumValue } from "#src/app/core/basic-datatypes/configurable-enum/configurable-enum.types";
/**
* Edit component for the `attendance` datatype.
*
* Manages an array of {@link AttendanceItem} objects, each with a participant entity reference,
* attendance status, and remarks.
*
* Participants can be of any entity type configured via the field's `additional.participant.additional`.
*/
@DynamicComponent("EditAttendance")
@Component({
selector: "app-edit-attendance",
imports: [
NgTemplateOutlet,
ReactiveFormsModule,
EditEntityComponent,
FontAwesomeModule,
EntityBlockComponent,
MatButtonModule,
EditConfigurableEnumComponent,
MatInputModule,
MatCardModule,
],
templateUrl: "./edit-attendance.component.html",
styleUrls: ["./edit-attendance.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: MatFormFieldControl, useExisting: EditAttendanceComponent },
],
})
export class EditAttendanceComponent
extends CustomFormControlDirective<AttendanceItem[]>
implements OnInit, EditComponent
{
formFieldConfig = input<FormFieldConfig>();
private readonly destroyRef = inject(DestroyRef);
private static readonly COMPACT_BREAKPOINT = 500;
private resizeObserver: ResizeObserver;
/** Whether the component is rendered in compact (mobile-like) layout based on its own width */
compact = signal(false);
/** Signal reflecting the current attendance items from the form control */
attendanceItems = signal<AttendanceItem[]>([]);
/** Signal reflecting whether the form control is currently disabled */
isDisabled = signal(false);
/** Internal form control for the entity autocomplete to add new participants */
addParticipantControl = new FormControl<string>(null);
/** The configurable-enum ID for attendance status options */
statusEnumId = computed(
() =>
this.formFieldConfig()?.additional?.status?.additional ??
ATTENDANCE_STATUS_CONFIG_ID,
);
/** FormFieldConfig passed to EditConfigurableEnumComponent for status selection */
statusFieldConfig = computed<FormFieldConfig>(() => ({
id: "status",
dataType: "configurable-enum",
label:
this.formFieldConfig()?.additional?.status?.label ??
AttendanceItem.schema.get("status")?.label,
additional: this.statusEnumId(),
}));
/** Label for the remarks field, overridable via the field config */
remarksLabel = computed(
() =>
this.formFieldConfig()?.additional?.remarks?.label ??
AttendanceItem.schema.get("remarks")?.label,
);
/** FormFieldConfig for the internal entity autocomplete */
participantFieldConfig = computed<FormFieldConfig>(() => ({
id: "participant",
label: $localize`:Placeholder for adding a participant:Select additional participant`,
dataType: "entity",
additional: this.formFieldConfig()?.additional?.participant?.additional,
}));
/** Per-participant FormControls used by EditConfigurableEnumComponent */
private readonly statusControls = new Map<
string,
FormControl<ConfigurableEnumValue>
>();
/** Returns (and lazily creates) a FormControl for the given participant's status */
getStatusControl(participantId: string): FormControl<ConfigurableEnumValue> {
let ctrl = this.statusControls.get(participantId);
if (!ctrl) {
const item = this.getAttendanceItem(participantId);
ctrl = new FormControl<ConfigurableEnumValue>(
item?.status ?? NullAttendanceStatusType,
{ nonNullable: true },
);
if (this.formControl.disabled) {
ctrl.disable({ emitEvent: false });
}
ctrl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((status) =>
this.updateAttendanceValue(
participantId,
"status",
status as AttendanceStatusType,
),
);
this.statusControls.set(participantId, ctrl);
}
return ctrl;
}
/** Filter to exclude already-added participants from the autocomplete */
participantFilter: WritableSignal<(e: Entity) => boolean> = signal(
() => true,
);
constructor() {
super();
// Whenever a new participant is selected in the autocomplete, add them.
// addParticipantControl is a class field so it's available at construction time.
this.addParticipantControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((id) => {
if (id) {
this.addParticipant(id);
this.addParticipantControl.setValue(null, { emitEvent: false });
}
});
}
ngOnInit() {
this.resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect?.width ?? 0;
const shouldBeCompact =
width > 0 && width < EditAttendanceComponent.COMPACT_BREAKPOINT;
if (shouldBeCompact !== this.compact()) {
this.compact.set(shouldBeCompact);
}
});
this.resizeObserver.observe(this.elementRef.nativeElement);
// Re-render when the form control value changes externally (e.g. loading entity data).
// startWith emits the initial value so the filter and status controls are set up immediately.
this.formControl.valueChanges
.pipe(
startWith(this.formControl.value ?? []),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((items) => {
const currentIds = new Set(
(items ?? []).map((item) => item.participant),
);
this.attendanceItems.set(items ?? []);
this.participantFilter.set((e: Entity) => !currentIds.has(e.getId()));
this.syncStatusControlValues(items ?? []);
});
// Sync enabled/disabled state of per-participant status controls.
this.formControl.statusChanges
.pipe(startWith(null), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
const disabled = this.formControl.disabled;
this.isDisabled.set(disabled);
this.statusControls.forEach((ctrl) =>
disabled
? ctrl.disable({ emitEvent: false })
: ctrl.enable({ emitEvent: false }),
);
});
}
private syncStatusControlValues(items: AttendanceItem[]) {
for (const item of items) {
const ctrl = this.statusControls.get(item.participant);
if (ctrl && ctrl.value !== item.status) {
ctrl.setValue(item.status, { emitEvent: false });
}
}
}
addParticipant(participantId: string) {
const current = this.formControl.value ?? [];
// Prevent duplicates
if (current.some((item) => item.participant === participantId)) {
return;
}
const newItem = new AttendanceItem(undefined, "", participantId);
this.formControl.setValue([...current, newItem]);
this.formControl.markAsDirty();
}
removeParticipant(participantId: string) {
const current = this.formControl.value ?? [];
this.formControl.setValue(
current.filter((item) => item.participant !== participantId),
);
this.formControl.markAsDirty();
}
updateAttendanceValue(
participantId: string,
property: "status" | "remarks",
newValue: any,
) {
const current = this.formControl.value ?? [];
const updatedArray = current.map((item) => {
if (item.participant !== participantId) {
return item;
}
const updatedItem = item.copy();
updatedItem[property] = newValue;
return updatedItem;
});
this.formControl.setValue(updatedArray);
this.formControl.markAsDirty();
}
getAttendanceItem(participantId: string): AttendanceItem | undefined {
return (this.formControl.value ?? []).find(
(item) => item.participant === participantId,
);
}
override ngOnDestroy() {
super.ngOnDestroy();
this.resizeObserver?.disconnect();
}
}
@if (!compact()) {
<div>
<!-- Desktop view: display the information as table -->
<table class="table">
@for (item of attendanceItems(); track item.participant) {
<tr>
<td>
@if (!isDisabled()) {
<button
class="remove-btn"
type="button"
(click)="removeParticipant(item.participant)"
matTooltip="Remove participant"
i18n-matTooltip
>
<fa-icon icon="xmark"></fa-icon>
</button>
}
</td>
<td class="participant-name">
<app-entity-block [entityId]="item.participant"></app-entity-block>
</td>
<td>
<mat-form-field class="margin-small">
<mat-label>{{ statusFieldConfig().label }}</mat-label>
<app-edit-configurable-enum
[formControl]="getStatusControl(item.participant)"
[formFieldConfig]="statusFieldConfig()"
></app-edit-configurable-enum>
</mat-form-field>
</td>
<td class="full-width">
<mat-form-field class="adjust-top margin-small">
<mat-label>{{ remarksLabel() }}</mat-label>
<input
#inputElement
matInput
name="remarks"
type="text"
[value]="item.remarks"
(input)="
updateAttendanceValue(
item.participant,
'remarks',
inputElement.value
)
"
[disabled]="isDisabled()"
/>
</mat-form-field>
</td>
</tr>
}
@if (!isDisabled()) {
<tr>
<td colspan="4">
<ng-container *ngTemplateOutlet="addParticipant"></ng-container>
</td>
</tr>
}
</table>
</div>
} @else {
<div class="attendance-blocks">
<!-- Mobile view / smaller screen: display the information using a flex-layout -->
@for (item of attendanceItems(); track item.participant) {
<mat-card class="attendance-item mat-elevation-z1 margin-bottom-small">
<mat-card-content>
<div class="attendance-item--header margin-bottom-regular">
<app-entity-block [entityId]="item.participant"></app-entity-block>
@if (!isDisabled()) {
<button
type="button"
mat-icon-button
(click)="removeParticipant(item.participant)"
class="mobile-remove-item"
matTooltip="Remove participant"
i18n-matTooltip
>
<fa-icon icon="xmark"></fa-icon>
</button>
}
</div>
<div class="attendance-item--content">
<mat-form-field class="margin-small">
<mat-label>{{ statusFieldConfig().label }}</mat-label>
<app-edit-configurable-enum
[formControl]="getStatusControl(item.participant)"
[formFieldConfig]="statusFieldConfig()"
></app-edit-configurable-enum>
</mat-form-field>
<mat-form-field>
<mat-label>{{ remarksLabel() }}</mat-label>
<input
#inputElement
matInput
name="remarks"
type="text"
[value]="item.remarks"
(input)="
updateAttendanceValue(
item.participant,
'remarks',
inputElement.value
)
"
[disabled]="isDisabled()"
/>
</mat-form-field>
</div>
</mat-card-content>
</mat-card>
}
@if (!isDisabled()) {
<ng-container *ngTemplateOutlet="addParticipant"></ng-container>
}
</div>
}
<ng-template #addParticipant>
<mat-form-field>
<mat-label i18n>Add participant</mat-label>
<app-edit-entity
[formControl]="addParticipantControl"
[formFieldConfig]="participantFieldConfig()"
[showEntities]="false"
[additionalFilter]="participantFilter()"
></app-edit-entity>
</mat-form-field>
</ng-template>
./edit-attendance.component.scss
@use "variables/sizes";
@use "variables/colors";
:host {
display: block;
overflow: hidden;
container-type: inline-size;
}
/* make layout of attendee items more dense and align its fields by hiding unused subscript space */
:host ::ng-deep .table .mat-mdc-form-field-subscript-wrapper {
display: none;
}
.table {
width: 100%;
td {
white-space: nowrap;
}
.participant-name {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
@container (min-width: 700px) {
max-width: 350px;
}
}
.remove-btn {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 12px;
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54));
border-radius: 50%;
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
.full-width {
width: 100%;
max-width: 0;
white-space: normal;
mat-form-field {
width: 100%;
min-width: 100px;
}
}
}
.mobile-remove-item {
position: absolute;
top: 0;
right: 0;
}