src/app/core/basic-datatypes/date-range/edit-date-range/edit-date-range.component.ts

Description

Form field to edit a DateRangeValue: the start and the end date in one range picker. With the withinParent validator, a warning icon shows in view mode when the range goes beyond a linked parent's range (while editing, the validator's error shows below the field instead).

Extends

CustomFormControlDirective<DateRangeValue | null>

Implements

EditComponent OnInit

Example

Metadata

Relationships

Used by

No results matching.

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

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

openPanel
openPanel(event: Event)

Pick the dates in the calendar or as one of the predefined ranges (the same panel as the date filters of lists and reports).

Parameters :
Name Type Optional
event Event 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

Protected Readonly outsideParent
Type : unknown
Default value : computed(() => findParentOutside(this.valueSignal(), this.parents.value()), )

name of the first linked parent record whose range the entered range goes beyond

Readonly range
Type : unknown
Default value : new FormGroup({ start: new FormControl<Date | null>(null), end: new FormControl<Date | null>(null), })

the two inputs of the range picker, which are kept in sync with the form control of the field

Readonly rangeOptions
Type : unknown
Default value : computed<DateRangeOption[]>(() => { const configured = this.formFieldConfig()?.additional; return [ ...this.closedParentPeriods().map(({ name, range }) => ({ label: $localize`Period of ${name}`, start: range.start, end: range.end, })), ...(Array.isArray(configured) ? configured : periodRangeOptions), ]; })

The predefined ranges offered next to the calendar: the periods of the linked parent records (see withinParent), then the ones in the additional config of the field (an empty list offers none), or the default ones.

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,
  effect,
  inject,
  input,
  OnInit,
  signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  ValidationErrors,
} from "@angular/forms";
import { DateRange, MatDatepickerModule } from "@angular/material/datepicker";
import { MatDialog } from "@angular/material/dialog";
import { MatFormFieldControl } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatTooltipModule } from "@angular/material/tooltip";
import moment from "moment";
import { startWith } from "rxjs";
import { CustomFormControlDirective } from "../../../common-components/basic-autocomplete/custom-form-control.directive";
import { FaDynamicIconComponent } from "../../../common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { FormFieldConfig } from "../../../common-components/entity-form/FormConfig";
import { DynamicComponent } from "../../../config/dynamic-components/dynamic-component.decorator";
import { EditComponent } from "../../../entity/entity-field-edit/dynamic-edit/edit-component.interface";
import { Entity } from "../../../entity/model/entity";
import { EMPTY_FILTER_OPTION_KEY } from "../../../filter/filters/filters";
import { DateRangeFilterPanelComponent } from "../../date/date-range-filter/date-range-filter-panel/date-range-filter-panel.component";
import {
  calculateDateRange,
  DateRangeOption,
  periodRangeOptions,
} from "../../date/date-range-filter/date-range-filter-panel/date-range-utils";
import { DateRangeValue } from "../date-range.datatype";
import {
  findParentOutside,
  ParentLinkInForm,
  parentRangesResource,
} from "../parent-ranges";

/**
 * Form field to edit a {@link DateRangeValue}: the start and the end date in one range picker.
 * With the `withinParent` validator, a warning icon shows in view mode when the range goes beyond a linked parent's range
 * (while editing, the validator's error shows below the field instead).
 */
@DynamicComponent("EditDateRange")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-edit-date-range",
  templateUrl: "./edit-date-range.component.html",
  styleUrls: [
    "../../../entity/entity-field-edit/dynamic-edit/dynamic-edit.component.scss",
    "./edit-date-range.component.scss",
  ],
  imports: [
    MatInputModule,
    ReactiveFormsModule,
    MatDatepickerModule,
    FaDynamicIconComponent,
    MatTooltipModule,
  ],
  providers: [
    { provide: MatFormFieldControl, useExisting: EditDateRangeComponent },
  ],
})
export class EditDateRangeComponent
  extends CustomFormControlDirective<DateRangeValue | null>
  implements EditComponent, OnInit
{
  formFieldConfig = input<FormFieldConfig>();
  entity = input<Entity>();

  private readonly destroyRef = inject(DestroyRef);
  private readonly dialog = inject(MatDialog);

  /**
   * The predefined ranges offered next to the calendar: the periods of the linked parent records (see `withinParent`),
   * then the ones in the `additional` config of the field (an empty list offers none), or the default ones.
   */
  readonly rangeOptions = computed<DateRangeOption[]>(() => {
    const configured = this.formFieldConfig()?.additional;
    return [
      ...this.closedParentPeriods().map(({ name, range }) => ({
        label: $localize`Period of ${name}`,
        start: range.start,
        end: range.end,
      })),
      ...(Array.isArray(configured) ? configured : periodRangeOptions),
    ];
  });

  /**
   * The linked parent periods with a start and an end
   * (a predefined range always has an end, so an ongoing period cannot be one).
   */
  private readonly closedParentPeriods = computed(() =>
    (this.parents.value() ?? []).filter(
      ({ range }) => range?.start && range?.end,
    ),
  );

  /** shaded in the calendar: the period of the (first) parent */
  private readonly highlightedParentRange = computed(() => {
    const range = this.closedParentPeriods()[0]?.range;
    return range && new DateRange(range.start, range.end);
  });

  /**
   * the parent link as entered in the form, if the form has that field (otherwise the record's saved link is used),
   * so that the parent's period offered and shaded while editing follows a changed link before it is saved
   */
  private readonly parentReferenceInForm = signal<ParentLinkInForm | undefined>(
    undefined,
  );

  private readonly parents = parentRangesResource(() => ({
    record: this.entity(),
    fieldId: this.formFieldConfig()?.id,
    config: this.formFieldConfig()?.validators?.withinParent,
    linkInForm: this.parentReferenceInForm(),
  }));

  /** name of the first linked parent record whose range the entered range goes beyond */
  protected readonly outsideParent = computed(() =>
    findParentOutside(this.valueSignal(), this.parents.value()),
  );

  /** the two inputs of the range picker, which are kept in sync with the form control of the field */
  readonly range = new FormGroup({
    start: new FormControl<Date | null>(null),
    end: new FormControl<Date | null>(null),
  });

  constructor() {
    super();
    effect(() => {
      if (this.enabled()) {
        this.range.enable({ emitEvent: false });
      } else {
        this.range.disable({ emitEvent: false });
      }
    });
  }

  /**
   * A text that cannot be read as a date stays as an error of its own input in the picker and is not part of the value,
   * so the field would be valid and the text would be lost when saving.
   */
  private readonly pickerInputsReadable = (): ValidationErrors | null =>
    this.range.controls.start.hasError("matDatepickerParse") ||
    this.range.controls.end.hasError("matDatepickerParse")
      ? {
          dateRangeInput: {
            errorMessage: $localize`Please enter a valid date.`,
          },
        }
      : null;

  ngOnInit() {
    this.formControl.addValidators(this.pickerInputsReadable);
    this.formControl.updateValueAndValidity({ emitEvent: false });
    // it checks the inputs of this component, so it must not stay on the field once the component is gone
    this.destroyRef.onDestroy(() => {
      this.formControl.removeValidators(this.pickerInputsReadable);
      this.formControl.updateValueAndValidity({ emitEvent: false });
    });

    this.formControl.valueChanges
      .pipe(
        startWith(this.formControl.value),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((value) => this.showValue(value));
    this.followParentReferenceInForm();
    this.range.statusChanges
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.formControl.updateValueAndValidity());
    this.range.valueChanges
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(({ start, end }) => {
        this.formControl.setValue(
          start || end
            ? { ...(start && { start }), ...(end && { end }) }
            : null,
        );
        this.formControl.markAsDirty();
      });
  }

  private followParentReferenceInForm() {
    const field =
      this.formFieldConfig()?.validators?.withinParent?.parentReferenceField;
    const control = field && this.formControl.parent?.get(field);
    if (!control) {
      return;
    }
    this.parentReferenceInForm.set({ value: control.value });
    control.valueChanges
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((value) => this.parentReferenceInForm.set({ value }));
  }

  /** Show a value that was set from outside, without resetting what the user is just typing. */
  private showValue(value: DateRangeValue | null | undefined) {
    const { start, end } = this.range.value;
    if (isSameDate(start, value?.start) && isSameDate(end, value?.end)) {
      return;
    }
    this.range.setValue(
      { start: value?.start ?? null, end: value?.end ?? null },
      { emitEvent: false },
    );
  }

  /**
   * Pick the dates in the calendar or as one of the predefined ranges
   * (the same panel as the date filters of lists and reports).
   */
  openPanel(event: Event) {
    event.stopPropagation();
    // the click also reaches the disabled toggle, e.g. next to its button
    if (!this.enabled()) {
      return;
    }

    const { start, end } = this.range.value;
    // the panel returns an index into these, which must not shift if the parent's period loads in the meantime
    const rangeOptions = this.rangeOptions();
    this.dialog
      .open(DateRangeFilterPanelComponent, {
        width: "600px",
        minWidth: "400px",
        data: {
          dateRange: new DateRange(start ?? null, end ?? null),
          rangeOptions,
          hideAllOption: true,
          highlightRange: this.highlightedParentRange(),
        },
      })
      .afterClosed()
      .subscribe((result?: { selectedOptionValues: string[] }) => {
        if (result?.selectedOptionValues?.length) {
          this.range.setValue(
            this.toDates(result.selectedOptionValues, rangeOptions),
          );
        }
      });
  }

  /** The dates of a selection in the panel: "no date", the index of a predefined range, or two picked dates. */
  private toDates(
    [first, second]: string[],
    rangeOptions: DateRangeOption[],
  ): {
    start: Date | null;
    end: Date | null;
  } {
    if (first === EMPTY_FILTER_OPTION_KEY) {
      return { start: null, end: null };
    }
    if (second === undefined) {
      const range = calculateDateRange(rangeOptions[Number(first)]);
      return { start: startOfDay(range.start), end: startOfDay(range.end) };
    }
    return { start: fromDateString(first), end: fromDateString(second) };
  }
}

function startOfDay(date: Date): Date {
  return moment(date).startOf("day").toDate();
}

/** a date as "YYYY-MM-DD" (as the panel returns the picked dates), in local time */
function fromDateString(value: string): Date {
  return moment(value, "YYYY-MM-DD").toDate();
}

function isSameDate(a?: Date | null, b?: Date | null): boolean {
  return (a?.getTime() ?? null) === (b?.getTime() ?? null);
}
<div class="flex-row">
  @if (!enabled() && outsideParent(); as parentName) {
    <!-- before the inputs: the date picker button after them overlaps its neighbours (negative margin) -->
    <app-fa-dynamic-icon
      class="margin-end-small input-tooltip-icon"
      icon="warning"
      matTooltip="Outside the period of {{ parentName }}"
      i18n-matTooltip
    ></app-fa-dynamic-icon>
  }
  <mat-date-range-input [formGroup]="range">
    <input
      matStartDate
      formControlName="start"
      placeholder="Start date"
      i18n-placeholder
    />
    <input
      matEndDate
      formControlName="end"
      placeholder="End date"
      i18n-placeholder
    />
  </mat-date-range-input>

  <mat-datepicker-toggle
    class="input-action-button"
    [disabled]="!enabled()"
    (click)="openPanel($event)"
  ></mat-datepicker-toggle>
</div>

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

@use "variables/colors";

.input-action-button {
  margin: -12px;
  color: colors.$muted;
}

./edit-date-range.component.scss

// Like other empty fields, an empty range shows no "Start date – End date" (only while typing into it).
// The text is made transparent rather than hidden, so that the inputs can still be clicked.
:host(:not(:focus-within))
  ::ng-deep
  .mat-date-range-input-container:has(.mat-start-date:placeholder-shown):has(
    .mat-end-date:placeholder-shown
  ) {
  .mat-date-range-input-inner::placeholder,
  .mat-date-range-input-separator {
    color: transparent;
    // disabled fields (the view mode) get their text color this way (see _mat-input.scss)
    -webkit-text-fill-color: transparent;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""