UNPKG

comindware.core.ui

Version:

Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.

723 lines (652 loc) • 26.9 kB
import { keyCode, dateHelpers, helpers } from 'utils'; import LocalizationService from '../../services/LocalizationService'; import template from './templates/durationEditor.hbs'; import BaseEditorView from './base/BaseEditorView'; import formRepository from '../formRepository'; import iconWrapRemove from './iconsWraps/iconWrapRemove.html'; import iconWrapNumber from './iconsWraps/iconWrapNumber.html'; const focusablePartId = { DAYS: 'days', HOURS: 'hours', MINUTES: 'minutes', SECONDS: 'seconds' }; const changeModes = { keydown: 'keydown', blur: 'blur' }; const createFocusableParts = function(options) { const result = []; const settings = {}; settings.daysSettings = _.defaultsPure(options.days, options.allFocusableParts, { text: LocalizationService.get('CORE.FORM.EDITORS.DURATION.WORKDURATION.DAYS'), maxLength: 4 }); settings.hoursSettings = _.defaultsPure(options.hours, options.allFocusableParts, { text: LocalizationService.get('CORE.FORM.EDITORS.DURATION.WORKDURATION.HOURS'), maxLength: 4 }); settings.minutesSettings = _.defaultsPure(options.minutes, options.allFocusableParts, { text: LocalizationService.get('CORE.FORM.EDITORS.DURATION.WORKDURATION.MINUTES'), maxLength: 4 }); settings.secondsSettings = _.defaultsPure(options.seconds, options.allFocusableParts, { text: LocalizationService.get('CORE.FORM.EDITORS.DURATION.WORKDURATION.SECONDS'), maxLength: 4 }); Object.values(settings).forEach(setting => (setting.text = ` ${setting.text}`)); //RegExp in getSegmentValue method based on ' ' (\s) if (options.allowDays) { result.push({ id: focusablePartId.DAYS, text: settings.daysSettings.text, maxLength: settings.daysSettings.maxLength, milliseconds: 1000 * 60 * 60 * options.hoursPerDay }); } if (options.allowHours) { result.push({ id: focusablePartId.HOURS, text: settings.hoursSettings.text, maxLength: settings.hoursSettings.maxLength, milliseconds: 1000 * 60 * 60 }); } if (options.allowMinutes) { result.push({ id: focusablePartId.MINUTES, text: settings.minutesSettings.text, maxLength: settings.minutesSettings.maxLength, milliseconds: 1000 * 60 }); } if (options.allowSeconds) { result.push({ id: focusablePartId.SECONDS, text: settings.secondsSettings.text, maxLength: settings.secondsSettings.maxLength, milliseconds: 1000 }); } return result; }; const defaultOptions = () => ({ hoursPerDay: 24, allowDays: true, allowHours: true, allowMinutes: true, allowSeconds: true, showTitle: true, showEmptyParts: false, hideClearButton: false, changeMode: changeModes.blur, fillZero: false, normalTime: false, class: '', emptyPlaceholder: Localizer.get('CORE.FORM.EDITORS.DURATION.NOTSET'), max: undefined, days: undefined, hours: undefined, minutes: undefined, seconds: undefined, min: undefined // allFocusableParts: undefined, // seconds: undefined // days, minutes, hours }); const stateModes = { EDIT: 'edit', VIEW: 'view' }; /** * @name DurationEditorView * @memberof module:core.form.editors * @class Inline duration editor. Supported data type: <code>String</code> in ISO8601 format (for example: 'P4DT1H4M'). * @extends module:core.form.editors.base.BaseEditorView * @param {Object} options Options object. All the properties of {@link module:core.form.editors.base.BaseEditorView BaseEditorView} class are also supported. * @param {Number} [options.hoursPerDay=24] The amount of hours per day. The intended use case is counting work days taking work hours into account. * The logic is disabled by default: a day is considered as 24 hours. * @param {Boolean} [options.allowDays=true] Whether to display the day segment. At least one segment must be displayed. * @param {Boolean} [options.allowHours=true] Whether to display the hour segment. At least one segment must be displayed. * @param {Boolean} [options.allowMinutes=true] Whether to display the minute segment. At least one segment must be displayed. * @param {Boolean} [options.allowSeconds=true] Whether to display the second segment. At least one segment must be displayed. * @param {Boolean} {options.showTitle=true} Whether to show title attribute. * @param {Object} [seconds] second Options * @param {Number} [seconds.maxLength=4] Max digit capacity of seconds * @param {Number} [seconds.text=(localization)] Separator. Show after part. * Similar options for days, hours, minutes. If all options are similar, use @param {Object} [allFocusableParts] by default. * @param {Object, String, Number} [max, min] Max, min value. Type - like arg for moment.duration * */ export default formRepository.editors.Duration = BaseEditorView.extend({ initialize(options = {}) { this.__applyOptions(options, defaultOptions); this.focusableParts = createFocusableParts(this.options); this.state = { mode: stateModes.VIEW, displayValue: dateHelpers.durationISOToObject(this.value) }; }, template: Handlebars.compile(template), templateContext() { return { size: this.__getInputSize(), showIcon: !this.readonly }; }, focusElement: '.js-input', className: 'js-duration editor editor_duration', ui: { input: '.js-input', clearButton: '.js-clear-button' }, regions: { durationRegion: 'js-duration' }, events: { 'click @ui.clearButton': '__onClearClickHandler', 'dblclick @ui.clearButton': '__onClearDblclick', 'focus @ui.input': '__focus', 'click @ui.input': '__onClick', 'blur @ui.input': '__onBlur', 'keydown @ui.input': '__keydown', 'keyup @ui.input': '__keyup', mouseenter: '__onMouseenter' }, __onClearClick() { if (this.__isDoubleClicked) { this.__isDoubleClicked = false; return; } this.triggeredByClean = true; this.__updateState({ mode: stateModes.VIEW, displayValue: null }); this.__value(null, true); this.focus(); }, __onClick() { this.trigger('click'); this.__focus(); }, __focus() { if (this.readonly) { return; } const curretPos = this.getCaretPos(); this.__updateState({ mode: stateModes.EDIT }); const pos = this.fixCaretPos(curretPos); this.setCaretPos(pos); }, __onBlur() { if (this.state.mode === stateModes.VIEW) { return; } this.__updateValueByInput(true); }, __getInputSize(value) { const inEditMode = this.state.mode === stateModes.EDIT; const inputValue = value || this.state.displayValue; const minInputSize = 5; const valueString = value || this.__createInputString(inputValue, inEditMode); const specialCoefficient = 0.97; // to get new size, this is because length refers to number of characters, where as size in most browsers refers to em units const valueSymbols = valueString.split(''); if (valueSymbols.length === 0) { return minInputSize * specialCoefficient; } const colons = valueSymbols.filter(symbol => symbol === ':').length; const letters = valueSymbols.length - colons; const durationSize = letters * specialCoefficient + colons * 0.3; // because duration has ':' return durationSize; }, __updateValueByInput(updateState) { let valueObject = this.__getObjectValueFromInput(); valueObject = this.__checkMaxMinObject(valueObject, this.options.max, this.options.min); if (updateState) { this.__updateState({ mode: stateModes.VIEW, displayValue: valueObject }); } if (this.triggeredByClean) { this.triggeredByClean = false; if (Object.values(valueObject).every(value => value === 0)) { // this.ui.input.val(null); // this.__value(null, true); return; } } this.__value(moment.duration(valueObject).toISOString(), true); }, __getObjectValueFromInput() { const values = this.getSegmentValue(); const valueObject = { days: 0, hours: 0, minutes: 0, seconds: 0 }; this.focusableParts.forEach((seg, i) => { valueObject[seg.id] = Number(values[i]); }); const days = valueObject.days; const devider = 24 / this.options.hoursPerDay; const realDays = Math.floor(days / devider); const daysRemainder = days % devider; valueObject.days = realDays; valueObject.hours += daysRemainder * this.options.hoursPerDay; return valueObject; }, getCaretPos() { return this.ui.input[0].selectionStart; }, fixCaretPos(pos) { let resultPosition; const index = this.getSegmentIndex(pos); const focusablePart1 = this.focusableParts[index]; const focusablePart2 = this.focusableParts[index + 1]; if (pos >= focusablePart1.start && pos <= focusablePart1.end) { resultPosition = pos; } else if (pos > focusablePart1.end && (focusablePart2 ? pos < focusablePart2.start : true)) { resultPosition = focusablePart1.end; } return resultPosition !== undefined ? resultPosition : focusablePart1.start; }, setCaretPos(pos) { this.ui.input[0].setSelectionRange(pos, pos); }, moveCaret(delta) { this.setCaretPos(this.getCaretPos() + delta); }, getSegmentIndex(pos) { // returns the index of the segment where we are at let i; let segmentIndex; segmentIndex = this.focusableParts.length - 1; this.initSegmentStartEnd(); for (i = 0; i < this.focusableParts.length; i++) { const focusablePart1 = this.focusableParts[i]; const focusablePart2 = this.focusableParts[i + 1]; if (focusablePart1.start <= pos && pos <= focusablePart1.end) { // the position is within the first segment segmentIndex = i; break; } if (focusablePart2) { if (focusablePart1.end < pos && pos < focusablePart2.start) { const whitespaceLength = 1; if (pos < focusablePart2.start - whitespaceLength) { // the position is at '1 <here>d 2 h' >> first fragment segmentIndex = i; } else { // the position is at '1 d<here> 2 h' >> second fragment segmentIndex = i + 1; } break; } } } return segmentIndex; }, getSegmentValue(index) { const segments = []; for (let i = 0; i < this.focusableParts.length; i++) { // matches '123 d' segment segments.push('(\\S*)\\s+\\S*'); } const regexStr = `^\\s*${segments.join('\\s+')}$`; let result = new RegExp(regexStr, 'g').exec(this.ui.input.val()); if (!result) { result = this.focusableParts.map(() => '0'); result.push('0'); } return index !== undefined ? result[index + 1] : result.slice(1, this.focusableParts.length + 1); }, getCurrentSegmentValue() { return this.getSegmentValue(this.getSegmentIndex(this.getCaretPos())); }, setSegmentValue(index, value, replace) { let val = this.getSegmentValue(index); val = !replace ? parseInt(val) + value : value; if (val < 0) { return false; } if (this.options.normalTime) { val = this.__floorTime(val, this.focusableParts[index].id); } val = val.toString(); if (val.length > this.focusableParts[index].maxLength) { return false; } const str = this.ui.input.val(); this.ui.input.val(str.substr(0, this.focusableParts[index].start) + val + str.substr(this.focusableParts[index].end)); return true; }, atSegmentEnd(position) { const index = this.getSegmentIndex(position); return position === this.focusableParts[index].end; }, atSegmentStart(position) { const index = this.getSegmentIndex(position); return position === this.focusableParts[index].start; }, __value(value, triggerChange) { if (value === this.value) { return; } this.value = value; this.__updateEmpty(); if (triggerChange) { this.__triggerChange(); } }, __keydown(event) { // this.ui.input[0].hasAttribute('readonly') for time editor, because setReadonly set tabindex -1 if (event.ctrlKey || !this.getEditable() || this.ui.input[0].hasAttribute('readonly')) { return; } const position = this.getCaretPos(); const index = this.getSegmentIndex(position); const focusablePart = this.focusableParts[index]; switch (event.keyCode) { case keyCode.UP: if (this.setSegmentValue(index, 1)) { this.initSegmentStartEnd(); this.setCaretPos(focusablePart.end); } return false; case keyCode.DOWN: if (this.setSegmentValue(index, -1)) { this.initSegmentStartEnd(); this.setCaretPos(focusablePart.end); } return false; case keyCode.PAGE_UP: if (this.setSegmentValue(index, 10)) { this.initSegmentStartEnd(); this.setCaretPos(focusablePart.end); } return false; case keyCode.PAGE_DOWN: if (this.setSegmentValue(index, -10)) { this.initSegmentStartEnd(); this.setCaretPos(focusablePart.end); } return false; case keyCode.LEFT: if (this.atSegmentStart(position)) { this.__setCaretToPreviousPart(index); return false; } break; case keyCode.RIGHT: if (this.atSegmentEnd(position)) { this.__setCaretToNextPart(index); return false; } break; case keyCode.DELETE: if (this.atSegmentEnd(position)) { this.__setCaretToNextPart(index); return false; } if (this.getCurrentSegmentValue().length === 1) { this.__replaceModeFor(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], '0', 'right'); this.moveCaret(1); return false; } break; case keyCode.BACKSPACE: if (this.atSegmentStart(position)) { this.__setCaretToPreviousPart(index); return false; } if (this.getCurrentSegmentValue().length === 1) { this.__replaceModeFor(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], '0', 'left'); this.moveCaret(-1); return false; } break; case keyCode.ESCAPE: this.ui.input.blur(); this.__updateState({ mode: stateModes.VIEW }); break; case keyCode.ENTER: break; case keyCode.TAB: { const delta = event.shiftKey ? -1 : 1; if (this.focusableParts[index + delta]) { this.setCaretPos(this.focusableParts[index + delta].start); return false; } break; } case keyCode.HOME: this.setCaretPos(this.focusableParts[0].start); return false; case keyCode.END: this.setCaretPos(this.focusableParts[this.focusableParts.length - 1].end); return false; default: { let charValue = null; if (event.keyCode >= keyCode.NUM_0 && event.keyCode <= keyCode.NUM_9) { charValue = event.keyCode - keyCode.NUM_0; } else if (event.keyCode >= keyCode.NUMPAD_0 && event.keyCode <= keyCode.NUMPAD_9) { charValue = event.keyCode - keyCode.NUMPAD_0; } const valid = charValue !== null; if (!valid) { return false; } if (this.getCurrentSegmentValue() === '0' && this.atSegmentEnd(position)) { this.__replaceModeFor(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], '', 'left'); this.moveCaret(-1); } else { this.__replaceModeFor(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); } if (this.getSegmentValue(index).length >= focusablePart.maxLength) { return false; } } } this.trigger('keydown', event); }, __keyup(event) { if (event.ctrlKey || this.readonly) { return; } if (event.keyCode === keyCode.ENTER || this.options.changeMode === changeModes.keydown) { this.__updateValueByInput(); } if ( (event.keyCode >= keyCode.NUM_0 && event.keyCode <= keyCode.NUM_9) || (event.keyCode >= keyCode.NUMPAD_0 && event.keyCode <= keyCode.NUMPAD_9) || event.keyCode === keyCode.DELETE ) { const position = this.getCaretPos(); const index = this.getSegmentIndex(position); const focusablePart = this.focusableParts[index]; const segmentValue = this.getSegmentValue(index); if (segmentValue.length < focusablePart.maxLength) { return; } if (this.atSegmentEnd(position)) { if (this.options.normalTime) { this.setSegmentValue(index, this.__floorTime(segmentValue, focusablePart.id), true); } this.__setCaretToNextPart(index); } } if (event.keyCode === keyCode.BACKSPACE) { const position = this.getCaretPos(); const index = this.getSegmentIndex(position); const focusablePart = this.focusableParts[index]; if (this.getSegmentValue(index).length < focusablePart.maxLength) { return; } if (this.atSegmentStart(position)) { this.__setCaretToPreviousPart(index); } } }, __floorTime(segValue, id) { const maxValue = { [focusablePartId.DAYS]: 31, [focusablePartId.HOURS]: 23, [focusablePartId.MINUTES]: 59, [focusablePartId.SECONDS]: 59 }; return Number(segValue) > maxValue[id] ? maxValue[id] : segValue; }, __setCaretToNextPart(index) { if (this.focusableParts[index + 1]) { this.setCaretPos(this.focusableParts[index + 1].start); } }, __setCaretToPreviousPart(index) { if (this.focusableParts[index - 1]) { this.setCaretPos(this.focusableParts[index - 1].end); } }, __replaceModeFor(arrChar, insertChar = '', direction = 'right') { const inpValue = this.ui.input.val(); const dirClarity = direction === 'left' ? 1 : 0; const caretPos = this.getCaretPos(); const valueAfterCaret = inpValue[caretPos - dirClarity]; if (arrChar.some(char => char === valueAfterCaret)) { this.ui.input.val(this.__replaceChar(inpValue, caretPos - dirClarity, insertChar)); this.setCaretPos(caretPos); } }, __replaceChar(str, i, insertChar = '') { return str.substr(0, i) + insertChar + str.slice(i + 1); }, initSegmentStartEnd() { const values = this.getSegmentValue(); let start = 0; for (let i = 0; i < this.focusableParts.length; i++) { const focusablePart = this.focusableParts[i]; if (i > 0) { // counting whitespace before the value of the segment start++; } focusablePart.start = start; focusablePart.end = focusablePart.start + values[i].length; start = focusablePart.end + focusablePart.text.length; } }, __createInputString(value, editable) { // The methods creates a string which reflects current mode (view/edit) and value. // The string is set into UI in __updateState const isNull = value === null; const seconds = !isNull ? value.seconds : 0; const minutes = !isNull ? value.minutes : 0; const hours = !isNull ? value.hours : 0; const days = !isNull ? value.days : 0; const data = { [focusablePartId.DAYS]: days, [focusablePartId.HOURS]: hours, [focusablePartId.MINUTES]: minutes, [focusablePartId.SECONDS]: seconds }; if (!editable && isNull) { // null value is rendered as empty text return ''; } if (!this.options.showEmptyParts && !editable) { const filledSegments = this.focusableParts.filter(part => Boolean(data[part.id])); if (filledSegments.length > 0) { // returns string like '0d 4h 32m' return filledSegments.reduce((p, seg) => `${p}${data[seg.id]}${seg.text} `, '').trim(); } // returns string like '0d' return `0${this.focusableParts[0].text}`; } // always returns string with all editable segments like '0 d 5 h 2 m' return this.focusableParts .map(seg => { const val = data[seg.id]; let valStr = this.options.showEmptyParts ? String(val) : _.isNumber(val) ? String(val) : ''; valStr = this.options.fillZero ? this.__fillZero(valStr, seg.maxLength) : valStr; return valStr + seg.text; }) .join(' '); }, __fillZero(string, length) { const mask = '000000000000'; return (mask + string).slice(-length); }, __normalizeDuration(object) { // Data normalization: // Object like this: { days: 2, hours: 3, minutes: 133, seconds: 0 } // Is converted into this: { days: 2, hours: 5, minutes: 13, seconds: 0 } // But if hours segment is disallowed, it will look like this: { days: 2, hours: 0, minutes: 313, seconds: 0 } // 313 = 133 + 3*60 if (object === null) { return null; } let totalMilliseconds = moment.duration(object).asMilliseconds(); const result = { days: 0, hours: 0, minutes: 0, seconds: 0 }; this.focusableParts.forEach(seg => { result[seg.id] = Math.floor(totalMilliseconds / seg.milliseconds); totalMilliseconds %= seg.milliseconds; }); return result; }, setValue(value, triggerChange) { this.__value(value, triggerChange); this.__updateState({ mode: stateModes.VIEW, displayValue: dateHelpers.durationISOToObject(value) }); }, __updateState(newState) { // updates inner state variables // updates UI if (!newState.mode || (newState.mode === stateModes.EDIT && newState.displayValue !== undefined)) { helpers.throwInvalidOperationError("The operation is inconsistent or isn't supported by this logic."); } if (this.state.mode === newState.mode && newState.mode === stateModes.EDIT) { return; } this.state.mode = newState.mode; if (newState.displayValue !== undefined) { this.state.displayValue = newState.displayValue; } if (!this.isRendered()) { return; } const normalizedDisplayValue = this.__normalizeDuration(this.state.displayValue); const inEditMode = this.state.mode === stateModes.EDIT; const val = this.__createInputString(normalizedDisplayValue, inEditMode); this.ui.input.get(0).size = this.__getInputSize(val); this.ui.input.val(val); if (this.options.showTitle && !inEditMode) { this.$editorEl.prop('title', val); } }, __onMouseenter() { this.$editorEl.off('mouseenter'); if (!this.options.hideClearButton) { this.renderIcons(iconWrapNumber, iconWrapRemove); } }, __checkMaxMinObject(valueObject, maxValue, minValue) { let value = moment.duration(valueObject).asMilliseconds(); if (maxValue != null) { const max = moment.duration(maxValue).asMilliseconds(); value = value > max ? max : value; } if (minValue != null) { const min = moment.duration(minValue).asMilliseconds(); value = value < min ? min : value; } return dateHelpers.durationToObject(value); } });