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

Description

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.

Extends

CustomFormControlDirective<AttendanceItem[]>

Implements

OnInit EditComponent

Example

Metadata

Relationships

Used by

No results matching.

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

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

addParticipant
addParticipant(participantId: string)
Parameters :
Name Type Optional
participantId string No
Returns : void
getAttendanceItem
getAttendanceItem(participantId: string)
Parameters :
Name Type Optional
participantId string No
getStatusControl
getStatusControl(participantId: string)

Returns (and lazily creates) a FormControl for the given participant's status

Parameters :
Name Type Optional
participantId string No
removeParticipant
removeParticipant(participantId: string)
Parameters :
Name Type Optional
participantId string No
Returns : void
updateAttendanceValue
updateAttendanceValue(participantId: string, property: "status" | "remarks", newValue: any)
Parameters :
Name Type Optional
participantId string No
property "status" | "remarks" No
newValue any No
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

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

participantFieldConfig
Type : unknown
Default value : computed<FormFieldConfig>(() => ({ id: "participant", label: $localize`:Placeholder for adding a participant:Select additional participant`, dataType: "entity", additional: this.formFieldConfig()?.additional?.participant?.additional, }))

FormFieldConfig for the internal entity autocomplete

participantFilter
Type : WritableSignal<boolean>
Default value : signal( () => true, )

Filter to exclude already-added participants from the autocomplete

remarksLabel
Type : unknown
Default value : computed( () => this.formFieldConfig()?.additional?.remarks?.label ?? AttendanceItem.schema.get("remarks")?.label, )

Label for the remarks field, overridable via the field config

statusEnumId
Type : unknown
Default value : computed( () => this.formFieldConfig()?.additional?.status?.additional ?? ATTENDANCE_STATUS_CONFIG_ID, )

The configurable-enum ID for attendance status options

statusFieldConfig
Type : unknown
Default value : computed<FormFieldConfig>(() => ({ id: "status", dataType: "configurable-enum", label: this.formFieldConfig()?.additional?.status?.label ?? AttendanceItem.schema.get("status")?.label, additional: this.statusEnumId(), }))

FormFieldConfig passed to EditConfigurableEnumComponent for status selection

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 {
  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;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""