APIs

Show:
/**
  @module ember-flexberry
*/

import { computed, observer } from '@ember/object';
import { isBlank, isNone } from '@ember/utils';
import $ from 'jquery';
import moment from 'moment';
import { scheduleOnce } from '@ember/runloop';
import FlexberryBaseComponent from './flexberry-base-component';
import { translationMacro as t } from 'ember-i18n';

/**
  Wrapper for input[type='date/datetime/datetime-local'] component.
  **Please keep in mind that these input types are not supported in all browsers, [see supported browsers](http://caniuse.com/#feat=input-datetime).**

  @example
    ```handlebars
    {{flexberry-simpledatetime
      type="datetime-local"
      value=model.orderDate
      min=model.orderDateMin
      max=model.orderDateMax
      defaultHour=defaultHour
      defaultMinute=defaultMinute
    }}
    ```

  @class FlexberrySimpledatetime
  @extends FlexberryBaseComponent
*/
export default FlexberryBaseComponent.extend({

  /**
    Store current value as Date object.

    @property _valueAsDate
    @type Date
    @default null
    @private
  */
  _valueAsDate: computed(() => null),

  /**
    Convert date in `value` to appropriate input datatype.
    For example, [see here](https://www.w3.org/TR/html-markup/input.datetime-local.html#input.datetime-local.attrs.value).

    @property _valueAsString
    @type String
    @default null
    @private
  */
  _valueAsString: computed(() => null),

  /**
    Convert date in `min` to appropriate input datatype.
    For example, [see here](https://www.w3.org/TR/html-markup/input.datetime-local.html#input.datetime-local.attrs.min).

    @property _minAsString
    @type String
    @default null
    @private
  */
  _minAsString: computed(() => null),

  /**
    Convert date in `max` to appropriate input datatype.
    For example, [see here](https://www.w3.org/TR/html-markup/input.datetime-local.html#input.datetime-local.attrs.max).

    @property _maxAsString
    @type String
    @default null
    @private
  */
  _maxAsString: computed(() => null),

  /**
    Checks whether the browser supports the date field.

    @property currentTypeSupported
    @type Boolean
    @readOnly
  */
  currentTypeSupported: computed('type', {
    get() {
      let type = this.get('type');
      let input = document.createElement('input');
      input.setAttribute('type', type);

      return input.type === type;
    },
  }).readOnly(),

  /**
    Type of date picker.

    @property type
    @default null
    @type String
  */
  type: computed(() => null),

  /**
    Flag indicates only flatpickr using for this component.

    @property useOnlyFlatpickr
    @default true
    @type Boolean
  */
  useBrowserInput: false,

  /**
    Value of date.

    @property value
    @type Date
  */
  value: computed('_valueAsString', '_valueAsDate', 'useBrowserInput', 'currentTypeSupported', {
    get() {
      if (this.get('useBrowserInput') && this.get('currentTypeSupported')) {
        if (this.get('type') === 'date') {
          return this._convertDateToLocal(this.get('_valueAsString'));
        }

        return this._convertStringToDate(this.get('_valueAsString'));
      } else {
        if (this.get('type') === 'date') {
          return this._convertDateToLocal(this.get('_valueAsDate'));
        }

        return this.get('_valueAsDate');
      }
    },
    set(key, value) {
      if (this.get('useBrowserInput') && this.get('currentTypeSupported')) {
        this.set('_valueAsString', this._convertDateToString(value));
      } else {
        this.set('_valueAsDate', value);
        let flatpickr = this.get('_flatpickr');
        if (flatpickr) {
          flatpickr.setDate(value);
        }
      }

      return value;
    },
  }),

  /**
    Minimum value of date.

    @property min
    @type Date
  */
  min: computed('_minAsString', 'useBrowserInput', 'currentTypeSupported', {
    get() {
      return this.get('_minAsString');
    },
    set(key, value) {
      let minValue = value instanceof Date ? value.setMilliseconds(0) : moment(value).toDate().setMilliseconds(0);
      if (this.get('useBrowserInput') && this.get('currentTypeSupported')) {
        this.set('_minAsString', this._convertDateToString(minValue));
      }

      return value;
    },
  }),

  /**
    Maximum value of date.

    @property max
    @type Date
  */
  max: computed('_maxAsString', 'useBrowserInput', 'currentTypeSupported', {
    get() {
      return this.get('_maxAsString');
    },
    set(key, value) {
      let maxValue = value instanceof Date ? value.setMilliseconds(0) : moment(value).toDate().setMilliseconds(0);
      if (this.get('useBrowserInput') && this.get('currentTypeSupported')) {
        this.set('_maxAsString', this._convertDateToString(maxValue));
      }

      return value;
    },
  }),

  /**
    Default value of hour.

    @property defaultHour
    @default 12
    @type Integer
  */
  defaultHour: 12,

  /**
    Default value of minute.

    @property defaultMinute
    @default 0
    @type Integer
  */
  defaultMinute: 0,

  /**
    Text to be displayed in field, if field has not been filled.

    @property placeholder
    @type String
    @default 't('components.flexberry-simpledatetime.placeholder')'
  */
  placeholder: t('components.flexberry-simpledatetime.placeholder'),

  /**
    Array CSS class names.
    [More info.](https://emberjs.com/api/ember/release/classes/Component#property_classNames)

    @property classNames
    @type Array
    @readOnly
  */
  classNames: ['flexberry-simpledatetime'],

  /**
    Array CSS class names for scroll.

    @property scrollClassNames
    @type Array
  */
  scrollSelectors: computed(() => (['.full.height'])),

  /**
    Namespase for page event.

    @property eventNamespace
    @type String
  */
  eventNamespace: undefined,

  /**
    If true, then onClick calling flatpickr.open().

    @property canClick
    @type Bool
  */
  canClick: true,

  /**
    If undefined, then uses application locale.
    Supported locales: 'en', 'ru'.

    @property locale
    @type String
  */
  locale: undefined,

  /**
    If true, then flatpickr has remove button.

    @property removeButton
    @type Bool
  */
  removeButton: true,

  /**
    If true, then flatpickr has open button.

    @property openButton
    @type Bool
  */
  openButton: true,

  /**
    The selector to get the element (using `jQuery`) for [the `appendTo` flatpickr option](https://flatpickr.js.org/options/).

    @property calendarContext
    @type String
  */
  calendarContext: undefined,

  /**
    A string of characters which are used to define how the date will be displayed in the input box.
    The supported characters are defined in [the table below](https://flatpickr.js.org/formatting/).

    @property dateFormat
    @type String
  */
  dateFormat: 'Y-m-d',

  /**
    A string of characters which are used to define how the date will be displayed in the input box.
    The supported characters are defined in [the table below](https://flatpickr.js.org/formatting/).

    @property dateTimeFormat
    @type String
  */
  dateTimeFormat: 'Y-m-dTH:i',

  /**
    Exactly the same as date format, but for the altInput field.

    @property altDateFormat
    @type String
  */
  altDateFormat: 'd.m.Y',

  /**
    Exactly the same as date format, but for the altInput field.

    @property altDateTimeFormat
    @type String
  */
  altDateTimeFormat: 'd.m.Y H:i',

  /**
    Date mask.

    @property dateMask
    @type String
  */
  dateMask: '99.99.9999',

  /**
    DateTime mask.

    @property dateTimeMask
    @type String
  */
  dateTimeMask: '99.99.9999 99:99',

  /**
    Date format for validate.

    @property validDateFormat
    @type String
  */
  validDateFormat: 'DD.MM.YYYY',

  /**
    DateTime format for validate.

    @property validDateTimeFormat
    @type String
  */
  validDateTimeFormat: 'DD.MM.YYYY HH:mm',

  /**
    Path to component's settings in application configuration (JSON from ./config/environment.js).

    @property appConfigSettingsPath
    @type String
    @default 'APP.components.flexberrySimpledatetime'
  */
  appConfigSettingsPath: 'APP.components.flexberrySimpledatetime',

  /**
    See [EmberJS API](https://emberjs.com/api/).

    @method init
  */
  init() {
    this._super(...arguments);

    this.initProperty({ propertyName: 'calendarContext', defaultValue: undefined });
  },

  /**
    Initializes DOM-related component's logic.
  */
  didInsertElement() {
    this._super(...arguments);
    if (!(this.get('useBrowserInput') && this.get('currentTypeSupported'))) {
      this._flatpickrCreate();
    }
  },

  /**
    Called when the element of the view is going to be destroyed. Override this function to do any teardown that requires an element, like removing event listeners.
    [More info](https://emberjs.com/api/ember/release/classes/Component#event_willDestroyElement).

    @method willDestroyElement
  */
  willDestroyElement() {
    this._super(...arguments);
    this._flatpickrDestroy();

    let namespace = this.get('eventNamespace');
    $(this.get('scrollSelectors').join()).off(`scroll.${namespace}`);
    $(document).off(`mousedown.${namespace}`);
  },

  /**
    Component click handler.

    @method click
    @private
  */
  click() {
    if (this.get('canClick') && !(this.get('useBrowserInput') && this.get('currentTypeSupported')) && !this.get('readonly')) {
      this.set('canClick', false);
      this.get('_flatpickr').open();
    }
  },

  /**
    Validation date and time.

    @method _validationDateTime
    @private
  */
  _validationDateTime() {
    let dateIsValid = true;
    let inputValue = this.$('.custom-flatpickr')[0].value;
    let date = this.get('type') === 'date' ? moment(inputValue, this.validDateFormat) : moment(inputValue, this.validDateTimeFormat);
    if (date.isValid()) {
      let dateArray = inputValue.match(/(\d+)/g) || [];
      if (dateArray.length > 0) {
        let dateFormatArray = date._f.match(/([A-Z]+)/g) || [];
        let dateValid = dateFormatArray.find(function(item, index) {
          let dateElement;
          switch (item) {
            case 'DD':
              dateElement = date.date();
              break;
            case 'MM':
              dateElement = date.month() + 1;
              break;
            case 'YYYY':
              dateElement = date.year();
              break;
          }

          return !(dateElement === Number(dateArray[index]))
        });

        dateIsValid = this.get('type') === 'date' ? !dateValid : dateValid && date.hours() === Number(dateArray[3]) &&
          date.minutes() === Number(dateArray[4]);
      }
    } else {
      dateIsValid = false;
    }

    if (dateIsValid) {
      /* If before value was not set (undefined) then moment(this.get('_valueAsDate')) returns current date.
        If user input current date then moment(...).isSame(date) will return TRUE while in reality it is FALSE.*/
      let valueAsDate = this.get('_valueAsDate');
      if ((isNone(valueAsDate) && !isNone(date))
            || !moment(valueAsDate).isSame(date, this.get('type') === 'date' ? 'day' : 'second')) {
        this.get('_flatpickr').setDate(date.toDate());
        this.set('_valueAsDate', this.get('_flatpickr').selectedDates[0]);
      }
    } else {
      if (!isNone(inputValue)) {
        this.get('_flatpickr').clear();
        this.set('_valueAsDate', this.get('_flatpickr').selectedDates[0]);
      }
    }
  },

  _onChange() {
    const oldValue = this.get('value');
    this._validationDateTime();
    const newValue = this.get('value');

    if (newValue && newValue !== oldValue) {
      this.set('_valueAsDate', newValue);

      const onChange = this.get('onChange');
      if (typeof onChange === 'function') {
        onChange(newValue, oldValue);
      }
    }
  },

  /**
    Create Flatpickr instance, and save it into `_flatpickr` property.

    @method _flatpickrCreate
    @private
  */
  _flatpickrCreate() {
    const calendarContext = this.get('calendarContext');
    const timeless = this.get('type') === 'date';
    const min = this.get('min');
    const max = this.get('max');

    if (!isNone(min) && min.getSeconds() > 0) {
      min.setMinutes(min.getMinutes() + 1);
      min.setSeconds(0);
    }

    const options = {
      altInput: true,
      time_24hr: true,
      enableTime: !timeless,
      allowInput: true,
      clickOpens: false,
      disableMobile: true,
      altInputClass: 'custom-flatpickr',
      minDate: timeless && min ? moment(min).startOf('day').toDate() : min,
      maxDate: timeless && max ? moment(max).endOf('day').toDate() : max,
      defaultDate: this.get('value'),
      defaultHour: this.get('defaultHour'),
      defaultMinute: this.get('defaultMinute'),
      appendTo: calendarContext ? $(calendarContext).get(0) : undefined,
      locale: this.get('locale') || this.get('i18n.locale'),
      altFormat: timeless ? this.altDateFormat : this.altDateTimeFormat,
      dateFormat: timeless ? this.dateFormat : this.dateTimeFormat,
      onChange: () => {
        let inputValue = this.$('.custom-flatpickr')[0].value;
        if (!isBlank(inputValue)) {
          this._onChange();
        }
      },
      onClose: () => {
        this.set('canClick', true);
        this.$('.custom-flatpickr').blur();
      },
    };

    this.set('_flatpickr', this.$('.flatpickr > input').flatpickr(options));
    $('.flatpickr-calendar .numInput.flatpickr-hour').prop('readonly', true);
    $('.flatpickr-calendar .numInput.flatpickr-minute').prop('readonly', true);
    this.$('.custom-flatpickr').mask(timeless ? this.dateMask : this.dateTimeMask);

    this.$('.custom-flatpickr').change($.proxy(function () {
      this._onChange();
    }, this));

    this.$('.custom-flatpickr').keydown($.proxy(function (e) {
      if (e.which === 13) {
        this.$('.custom-flatpickr').blur();
        this._onChange();
      }
    }, this));

    this.$('.custom-flatpickr').prop('readonly', this.get('readonly'));

    let namespace = this.elementId;
    this.set('eventNamespace', namespace);
    $(document).on(`mousedown.${namespace}`, (e) => {
      let clicky = $(e.target);
      if (clicky.closest('.flatpickr-calendar').length === 0 && clicky.get(0) !== this.$('.custom-flatpickr').get(0)) {
        this.get('_flatpickr').close();
      }
    });
    $(this.get('scrollSelectors').join()).on(`scroll.${namespace}`, () => this.get('_flatpickr').close());
  },

  /**
    Sets readonly attr for flatpickr.
  */
  readonlyObserver: observer('readonly', function() {
    this.$('.custom-flatpickr').prop('readonly', this.get('readonly'));
  }),

  /**
    Observer for reinit flatpickr (defaultHour, defaultMinute, and others, for dynamically updating because set() for this options doesn't update view).

    @method reinitFlatpickrObserver
  */
  reinitFlatpickrObserver: observer('type', 'min', 'max', 'locale', 'i18n.locale', 'defaultHour', 'defaultMinute', function () {
    if (!this.get('useBrowserInput') || !this.get('currentTypeSupported')) {
      this._flatpickrDestroy();
      scheduleOnce('afterRender', this, this._flatpickrCreate);
    }
  }),

  /**
    Destroy Flatpickr instance.

    @method _flatpickrDestroy
    @private
  */
  _flatpickrDestroy() {
    let flatpickr = this.get('_flatpickr');
    if (flatpickr) {
      flatpickr.destroy();
      this.set('_flatpickr', null);
    }
  },

  /**
    Convert Date object to appropriate string value for input.

    @method _convertDateToString
    @param {Date} value Object of Date.
    @return {String} Date in string format.
    @private
  */
  _convertDateToString(value) {
    if (!value) {
      return null;
    }

    if (value instanceof Date) {
      let date = moment(value);
      switch (this.get('type')) {
        case 'datetime-local':
        case 'datetime':
          return date.format('YYYY-MM-DDTHH:mm');

        case 'date':
          return date.format('YYYY-MM-DD');

        default:
          throw new Error(`Not supported type:'${this.get('type')}'.`);
      }
    } else {
      throw new Error('Expected type of Date object.');
    }
  },

  /**
    Converts string value of input to Date object.

    @method _convertStringToDate
    @param {String} value Date in string format.
    @return {Date} Object of Date.
    @private
  */
  _convertStringToDate(value) {
    if (!value) {
      return null;
    }

    if (typeof value !== 'string') {
      throw new Error('Expected type the string.');
    }

    return moment(value).toDate();
  },

  /**
    Converts date (without time) to local date.

    @method _convertDateToLocal
    @param {String|Date} value Date without timezone shift.
    @return {Date} Object of Date.
    @private
  */
  _convertDateToLocal(value) {
    let dateToSet = value;
    if (!isBlank(dateToSet)) {
      dateToSet.setHours(13);
      dateToSet.setUTCHours(11);
      dateToSet.setUTCMinutes(0);
      dateToSet.setUTCSeconds(0);
      dateToSet.setUTCMilliseconds(0);
    }

    return dateToSet;
  },

  actions: {
    /**
      Clear current value.

      @method actions.remove
    */
    remove() {
      if (!this.get('readonly')) {
        const flatpickr = this.get('_flatpickr');
        const value = this.get('value') || new Date();

        value.setHours(this.get('defaultHour'), this.get('defaultMinute'));

        flatpickr.setDate(value, false);
        flatpickr.clear();
        this._onChange();
      }
    },

    /**
      Open flatpickr.

      @method actions.open
    */
    open() {
      this.click();
    }
  }
});