swimlane/ngx-ui

View on GitHub
projects/swimlane/ngx-ui/src/lib/components/date-time/date-time.component.ts

Summary

Maintainability
F
3 days
Test Coverage
import {
  Component,
  Input,
  Output,
  EventEmitter,
  ViewEncapsulation,
  forwardRef,
  ViewChild,
  TemplateRef,
  OnDestroy,
  ElementRef,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  HostBinding,
  OnChanges,
  SimpleChanges
} from '@angular/core';
import {
  ControlValueAccessor,
  UntypedFormControl,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ValidationErrors,
  Validator
} from '@angular/forms';

import moment from 'moment-timezone';

import { Clipboard } from '@angular/cdk/clipboard';

import { DialogService } from '../dialog/dialog.service';
import { DateTimeType } from './date-time-type.enum';
import { Datelike } from './date-like.type';
import { InputComponent } from '../input/input.component';
import { NotificationService } from '../notification/notification.service';
import { NotificationStyleType } from '../notification/notification-style-type.enum';

import { CoerceBooleanProperty } from '../../utils/coerce/coerce-boolean';
import { CoerceNumberProperty } from '../../utils/coerce/coerce-number';

import { Size } from '../../mixins/size/size.enum';
import { Appearance } from '../../mixins/appearance/appearance.enum';
import { DATE_DISPLAY_FORMATS, DATE_DISPLAY_INPUT_FORMATS, DATE_DISPLAY_TYPES } from '../../enums/date-formats.enum';
import { KeyboardKeys } from '../../enums/keyboard-keys.enum';
import { CalendarComponent } from '../calendar/calendar.component';
import { defaultDisplayFormat, defaultInputFormat } from '../../utils/date-formats/default-formats';
import { TooltipDirective } from '../tooltip/tooltip.directive';

let nextId = 0;

const MIN_WIDTH = 60;

const DATE_TIME_VALUE_ACCESSOR = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => DateTimeComponent),
  multi: true
};

const DATE_TIME_VALIDATORS = {
  provide: NG_VALIDATORS,
  useExisting: forwardRef(() => DateTimeComponent),
  multi: true
};

const guessTimeZone = moment.tz.guess();

@Component({
  exportAs: 'ngxDateTime',
  selector: 'ngx-date-time',
  templateUrl: './date-time.component.html',
  styleUrls: ['./date-time.component.scss'],
  providers: [DATE_TIME_VALUE_ACCESSOR, DATE_TIME_VALIDATORS],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: {
    class: 'ngx-date-time',
    '[class.legacy]': 'appearance === "legacy"',
    '[class.fill]': 'appearance === "fill"',
    '[class.sm]': 'size === "sm"',
    '[class.md]': 'size === "md"',
    '[class.lg]': 'size === "lg"',
    '[class.autosize]': 'autosize',
    '[class.marginless]': '!withMargin',
    '[class.no-label]': '!label'
  }
})
export class DateTimeComponent implements OnDestroy, OnChanges, ControlValueAccessor, Validator {
  @Input() id = `datetime-${++nextId}`;
  @Input() name: string;
  @Input() label: string;
  @Input() hint: string;
  @Input() placeholder = '';
  @Input() size: Size = Size.Small;
  @Input() appearance: Appearance = Appearance.Legacy;
  @Input() withMargin = true;
  @Input() precision: moment.unitOfTime.StartOf;
  @Input() timezone: string;
  @Input() inputFormats: any[] = DATE_DISPLAY_INPUT_FORMATS;

  @Input()
  set value(val: Date | string) {
    if (typeof val === 'string') {
      val = val.trim();
    }

    if (!val && !this._value) {
      val = this._value = null; // Match falsely values
    }

    let isSame = val === this._value;
    if (isSame) return; // if values are the same at this point, do nothing

    let isDate = false;
    const date = this.parseDate(val);
    if (val && date.isValid()) {
      isDate = true;
      if (this._value instanceof Date) {
        // only compare precision if old values is a date
        const sameDiff: moment.unitOfTime.StartOf = this.precision
          ? this.precision
          : this.inputType === DateTimeType.date
          ? 'day'
          : 'second';
        isSame = this._value ? date.isSame(this._value, sameDiff) : false;
      }
    }

    this._value = isDate ? date.toDate() : val;

    if (!isSame) {
      // update the display value and table
      this.update();

      // notify of changes only when the component is cleared
      // or when the set value is valid
      if (!this.dateInvalid) {
        this.change.emit(this._value);
      }

      // called each time for validation
      this.onChangeCallback(this._value);
      this.valueChange.emit(val);
    }
  }
  get value(): Date | string {
    return this._value;
  }

  @Input()
  @CoerceBooleanProperty()
  disabled = false;

  @Input()
  @CoerceNumberProperty()
  minWidth: number = MIN_WIDTH;

  @Input()
  @CoerceNumberProperty()
  tabindex: number;

  @Input()
  @CoerceBooleanProperty()
  autofocus = false;

  // date, time, dateTime
  @Input()
  set inputType(val: string) {
    this._inputType = val;
  }
  get inputType(): string {
    if (this._inputType) return this._inputType;
    if (this.precision === 'hour' || this.precision === 'minute') return DateTimeType.datetime;
    return DateTimeType.date;
  }

  /**
   * Display mode for date/time
   * 'timezone' - display date/time with a timezone
   * 'local' - display date/time without timezone
   *
   * Defaults to LOCAL unless timezone is set
   */
  @Input()
  set displayMode(val: DATE_DISPLAY_TYPES) {
    this._displayMode = val;
  }
  // Defaults to LOCAL unless
  get displayMode(): DATE_DISPLAY_TYPES {
    if (typeof this._displayMode === 'string') {
      return this._displayMode;
    }
    return this.timezone ? DATE_DISPLAY_TYPES.TIMEZONE : DATE_DISPLAY_TYPES.LOCAL;
  }

  /**
   * Display format for date/time
   * Considers if mode is user (has timezone), local (no timezone)
   */
  @Input()
  set format(val: string) {
    this._format = val;
  }
  get format(): string {
    if (this._format) return DATE_DISPLAY_FORMATS[this._format] || this._format;
    return defaultInputFormat(this.displayMode, this.inputType as DateTimeType, this.precision);
  }

  @Input()
  set tooltipFormat(val: string) {
    this._tooltipFormat = val;
  }
  get tooltipFormat(): string {
    if (this._tooltipFormat) return DATE_DISPLAY_FORMATS[this._tooltipFormat] || this._tooltipFormat;
    if (this._format) return DATE_DISPLAY_FORMATS[this._format] || this._format;

    return defaultDisplayFormat(this.displayMode, this.inputType as DateTimeType, this.precision);
  }

  @Input()
  @CoerceBooleanProperty()
  tooltipDisabled = false;

  @HostBinding('class.ngx-date-time--has-popup')
  get hasPopup() {
    if (DATE_DISPLAY_TYPES.LOCAL === this.displayMode) return false;
    if (this.tooltipDisabled) return false;
    if (this._focused) return false;
    return !!this.value && !this.dateInvalid;
  }

  @HostBinding('class.ngx-date-time--date-invalid')
  dateInvalid = false;

  @HostBinding('class.ngx-date-time--date-out-of-range')
  dateOutOfRange = false;

  /**
   * Used to display date in other timezones
   *
   * Only used if displayMode is 'user' or timezone is set
   */
  @Input()
  timezones: Record<string, string> = {
    UTC: 'Etc/UTC',
    Local: ''
  };

  @Input()
  tooltipCssClass = 'date-tip-tooltip';

  @Input()
  set clipFormat(val: string) {
    this._clipFormat = val;
  }
  get clipFormat(): string {
    if (this._clipFormat) return DATE_DISPLAY_FORMATS[this._clipFormat] || this._clipFormat;
    return this.format;
  }

  @Input() requiredIndicator: string | boolean = '*';

  @Input()
  @CoerceBooleanProperty()
  required = false;

  get displayValue(): string {
    return this._displayValue;
  }
  set displayValue(value: string) {
    this._displayValue = value;
    this.cdr.markForCheck();
  }

  @Input()
  @CoerceBooleanProperty()
  autosize = false;

  @Input()
  minDate: Date | string;

  @Input()
  maxDate: Date | string;

  /**
   * this output will emit only when the input value is valid or cleared.
   */
  @Output() change = new EventEmitter<string | Date | undefined | null>();

  /**
   * this output will emit anytime the value changes regardless of validity.
   */
  @Output() valueChange = new EventEmitter<string | Date | undefined | null>();

  /**
   * this output will emit anytime the value changes in the input, regardless of validity.
   */
  @Output() inputChange = new EventEmitter<string | Date | undefined | null>();

  /**
   * this output will emit a date is selected from the calendar
   */
  @Output() dateTimeSelected = new EventEmitter<Date | string>();

  @Output() blur = new EventEmitter<Event>();
  @Output() focus = new EventEmitter<Event>();

  @ViewChild('dialogTpl', { static: true })
  readonly calendarTpl: TemplateRef<ElementRef>;

  @ViewChild('input', { static: true })
  readonly input: InputComponent;

  @ViewChild(CalendarComponent, { static: false })
  readonly calendar: CalendarComponent;

  @ViewChild(TooltipDirective, { static: true })
  readonly tooltip: TooltipDirective;

  dialog: any;
  dialogModel: moment.Moment;
  hour: number;
  minute: string;
  second: string;
  millisecond: string;
  amPmVal: string;
  modes = ['millisecond', 'second', 'minute', 'hour', 'date', 'month', 'year'];
  timeValues = {};

  private _value: Date | string;
  private _displayValue = '';
  private _format: string;
  private _tooltipFormat: string;
  private _inputType: string;
  private _displayMode: DATE_DISPLAY_TYPES;
  private _clipFormat: string;
  private _focused = false;

  constructor(
    private readonly dialogService: DialogService,
    private readonly cdr: ChangeDetectorRef,
    private readonly clipboard: Clipboard,
    private readonly notificationService: NotificationService
  ) {}

  ngOnDestroy(): void {
    this.close();
  }

  ngOnChanges(changes: SimpleChanges) {
    if ('value' in changes && !changes.value.firstChange) return; // only on first change to value
    if (this._focused) return; // don't update if focused
    this.update();
  }

  writeValue(val: any): void {
    this.value = val;
  }

  onFocus(event?: Event) {
    this.tooltip.hideTooltip();
    this._focused = true;
    this.focus.emit(event);
  }

  onBlur(event?: Event) {
    this.onTouchedCallback();
    this._focused = false;

    this.update();
    if (!this.dateInvalid && this.input.value !== this.displayValue) {
      this.input.value = this.displayValue;
    }
    this.blur.emit(event);
  }

  open(): void {
    const value = moment(this._value);
    const isValid = value.isValid();

    this.setDialogDate(isValid ? value : new Date());

    this.dialog = this.dialogService.create({
      cssClass: 'ngx-date-time-dialog',
      template: this.calendarTpl,
      closeButton: false
    });
  }

  apply(): void {
    this.value = this.dialogModel.toDate();
    this.update();
    this.dateTimeSelected.emit(this.value);
    this.close();
  }

  setDialogDate(date: Datelike) {
    this.dialogModel = this.createMoment(date);
    this.hour = +this.dialogModel.format('hh');
    this.minute = this.dialogModel.format('mm');
    this.second = this.dialogModel.format('ss');
    this.millisecond = this.dialogModel.format('SSS');
    this.amPmVal = this.dialogModel.format('A');
  }

  minuteChanged(newVal: number): void {
    this.dialogModel = this.dialogModel.clone().minute(newVal);
    this.minute = this.dialogModel.format('mm');
  }

  secondChanged(newVal: number): void {
    this.dialogModel = this.dialogModel.clone().second(newVal);
    this.second = this.dialogModel.format('ss');
  }

  millisecondChanged(newVal: number): void {
    this.dialogModel = this.dialogModel.clone().millisecond(newVal);
    this.millisecond = this.dialogModel.format('SSS');
  }

  hourChanged(newVal: number): void {
    newVal = +newVal % 12;
    if (this.amPmVal === 'PM') {
      newVal = 12 + newVal;
    }
    this.dialogModel = this.dialogModel.clone().hour(newVal);
    this.hour = +this.dialogModel.format('hh');
  }

  selectCurrent(): void {
    this.setDialogDate(new Date());
  }

  isCurrent() {
    const now = this.createMoment(new Date());
    if (this.inputType === 'time') {
      return (
        now.hour() === this.dialogModel.hour() &&
        now.minute() === this.dialogModel.minute() &&
        now.second() === this.dialogModel.second() &&
        now.millisecond() === this.dialogModel.millisecond()
      );
    }
    return now.isSame(this.dialogModel, this.inputType === 'datetime' ? 'millisecond' : 'minute');
  }

  clear(): void {
    this.value = undefined;
    this.update();
    this.dateTimeSelected.emit(this.value);
    this.close();
  }

  onAmPmChange(newVal: string): void {
    const clone = this.dialogModel.clone();
    if (newVal === 'AM' && this.amPmVal === 'PM') {
      this.dialogModel = clone.subtract(12, 'h');
    } else if (newVal === 'PM' && this.amPmVal === 'AM') {
      this.dialogModel = clone.add(12, 'h');
    }
    this.amPmVal = this.dialogModel.format('A');
  }

  getDayDisabled(date: moment.Moment): boolean {
    if (!date) return false;

    const isBeforeMin = this.minDate && date.isBefore(this.parseDate(this.minDate));
    const isAfterMax = this.maxDate && date.isAfter(this.parseDate(this.maxDate));

    return isBeforeMin || isAfterMax;
  }

  isTimeDisabled(mode: moment.unitOfTime.StartOf): boolean {
    return this.modes.indexOf(`${this.precision}`) > this.modes.indexOf(`${mode}`);
  }

  inputChanged(val: string): void {
    this.value = val;
    this.inputChange.emit(val);
    // since this update is coming from the input, we need to keep the display value
    this.displayValue = val;
  }

  close(): void {
    if (!this.dialog) return;

    // tear down the dialog instance
    this.dialogService.destroy(this.dialog);
    this.update();
  }

  registerOnChange(fn: any): void {
    this.onChangeCallback = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouchedCallback = fn;
  }

  onClick(item: any) {
    this.clipboard.copy(item.value.clip);
    this.notificationService.create({
      body: `${item.key} date copied to clipboard`,
      styleType: NotificationStyleType.success,
      timeout: 3000
    });
  }

  validate(c: UntypedFormControl): ValidationErrors | null {
    if (!c.value) return null;

    return {
      ...(this.dateInvalid ? { invalid: true } : null),
      ...(this.dateOutOfRange ? { outOfRange: true } : null)
    };
  }

  setDisabledState(isDisabled: boolean) {
    this.disabled = isDisabled;
  }

  onTouchedCallback: () => void = () => {
    // placeholder
  };

  onChangeCallback: (_: any) => void = () => {
    // placeholder
  };

  onInputKeyDown(event: KeyboardEvent): void {
    if (event.code === KeyboardKeys.ARROW_DOWN) {
      // Down Arrow    Open the calendar pop-up
      this.open();
      setTimeout(() => {
        this.calendar.focus();
      }, 200);
    } else if (event.code === KeyboardKeys.ESCAPE) {
      // Escape    Close the calendar pop-up
      this.close();
    }
  }

  private roundTo(val: moment.Moment, key: string): moment.Moment {
    /* istanbul ignore if */
    if (!key || !val) {
      return val;
    }
    val = val.clone();

    const idx = this.modes.indexOf(key);
    if (idx > 0) {
      this.modes.forEach((mode, index) => {
        if (index < idx) {
          val = val[mode](mode === 'date' ? 1 : 0);
        }
      });
    }
    return val;
  }

  private parseDate(date: string | Date): moment.Moment {
    if (date instanceof Date) {
      /* istanbul ignore next */
      date = isNaN(date.getTime()) ? date.toString() : date.toISOString();
    }
    // Ensures that the input formats includes the display format
    const inputFormats = [...this.inputFormats];
    if (this.format && !inputFormats.includes(this.format)) {
      inputFormats.unshift(this.format);
    }
    const timezone = this.timezone || (this.displayMode === DATE_DISPLAY_TYPES.TIMEZONE ? guessTimeZone : undefined);
    let m = timezone ? moment.tz(date, inputFormats, timezone) : moment(date, inputFormats);
    m = this.precision ? this.roundTo(m, this.precision) : m;
    return m;
  }

  // Converts datelike to a moment object, considers if timezone is needed
  private createMoment(date: Datelike): moment.Moment {
    let m = moment(date).clone();
    const timezone = this.timezone || (this.displayMode === DATE_DISPLAY_TYPES.TIMEZONE ? guessTimeZone : undefined);
    m = timezone ? m.tz(timezone) : m;
    m = this.precision ? this.roundTo(m, this.precision) : m;
    return m;
  }

  private update() {
    const isDate = this.value instanceof Date;
    this.dateInvalid = !!this.value && !isDate; // if there is a value and it's not a date then it is invalid, falsy values are valid
    this.displayValue = !this.value ? '' : String(this.value);
    this.dateOutOfRange = false;
    this.timeValues = {};

    if (!isDate) return;

    const mdate = this.createMoment(this.value);
    this.displayValue = mdate.format(this.format);
    this.dateOutOfRange = !this.dateInvalid && this.getDayDisabled(mdate);

    if (!this.hasPopup) return;

    for (const key in this.timezones) {
      const tz = this.timezones[key] || guessTimeZone;
      const date = mdate.clone().tz(tz);
      const clip = date.format(this.clipFormat);
      const display = date.format(this.tooltipFormat);
      this.timeValues[key] = {
        key,
        clip,
        display
      };
    }
  }
}