diff --git a/app/code/Magento/Ui/view/base/web/js/grid/listing.js b/app/code/Magento/Ui/view/base/web/js/grid/listing.js index 67e91821d663c..d0d932e0f2265 100644 --- a/app/code/Magento/Ui/view/base/web/js/grid/listing.js +++ b/app/code/Magento/Ui/view/base/web/js/grid/listing.js @@ -17,7 +17,16 @@ define([ defaults: { template: 'ui/grid/listing', stickyTmpl: 'ui/grid/sticky/listing', + viewSwitcherTmpl: 'ui/grid/view-switcher', positions: false, + displayMode: 'grid', + displayModes: { + grid: { + value: 'grid', + label: 'Grid', + template: '${ $.template }' + } + }, dndConfig: { name: '${ $.name }_dnd', component: 'Magento_Ui/js/grid/dnd', @@ -48,6 +57,12 @@ define([ modules: { dnd: '${ $.dndConfig.name }', resize: '${ $.resizeConfig.name }' + }, + tracks: { + displayMode: true + }, + statefull: { + displayMode: true } }, @@ -96,7 +111,7 @@ define([ }, /** - * Inititalizes resize component. + * Initializes resize component. * * @returns {Listing} Chainable. */ @@ -170,7 +185,7 @@ define([ }, /** - * Reseorts child elements array according to provided positions. + * Resorts child elements array according to provided positions. * * @param {Object} positions - Object where key represents child * index and value is its' position. @@ -202,6 +217,41 @@ define([ return observable || this.visibleColumns; }, + /** + * Returns path to the template + * defined for a current display mode. + * + * @returns {String} Path to the template. + */ + getTemplate: function () { + var mode = this.displayModes[this.displayMode]; + + return mode.template; + }, + + /** + * Returns an array of available display modes. + * + * @returns {Array} + */ + getDisplayModes: function () { + var modes = this.displayModes; + + return _.values(modes); + }, + + /** + * Sets display mode to provided value. + * + * @param {String} index + * @returns {Listing} Chainable + */ + setDisplayMode: function (index) { + this.displayMode = index; + + return this; + }, + /** * Returns total number of displayed columns in grid. * diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js index de64a6d657a45..7d5a95346cf02 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/bootstrap.js @@ -21,6 +21,7 @@ define(function (require) { return { i18n: require('./i18n'), scope: require('./scope'), + range: require('./range'), mageInit: require('./mage-init'), keyboard: require('./keyboard'), optgroup: require('./optgroup'), @@ -32,6 +33,7 @@ define(function (require) { collapsible: require('./collapsible'), staticChecked: require('./staticChecked'), simpleChecked: require('./simple-checked'), + tooltip: require('./tooltip'), repeat: require('knockoutjs/knockout-repeat'), fastForEach: require('knockoutjs/knockout-fast-foreach') }; diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/range.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/range.js new file mode 100644 index 0000000000000..208952e990af1 --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/range.js @@ -0,0 +1,203 @@ +/** + * Copyright © 2015 Magento. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'ko', + 'jquery', + 'underscore', + '../template/renderer', + 'jquery/ui' +], function (ko, $, _, renderer) { + 'use strict'; + + var isTouchDevice = !_.isUndefined(document.ontouchstart), + sliderFn = 'slider'; + + ko.bindingHandlers.range = { + + /** + * Initializes binding and a slider update. + * + * @param {HTMLElement} element + * @param {Function} valueAccessor + */ + init: function (element, valueAccessor) { + var config = valueAccessor(), + value = config.value; + + _.extend(config, { + value: value(), + + /** + * Callback which is being called when sliders' value changes. + * + * @param {Event} event + * @param {Object} ui + */ + slide: function (event, ui) { + value(ui.value); + } + }); + + $(element)[sliderFn](config); + }, + + /** + * Updates sliders' plugin configuration. + * + * @param {HTMLElement} element + * @param {Function} valueAccessor + */ + update: function (element, valueAccessor) { + var config = valueAccessor(); + + config.value = ko.unwrap(config.value); + + $(element)[sliderFn]('option', config); + } + }; + + renderer.addAttribute('range'); + + if (!isTouchDevice) { + return; + } + + $.widget('mage.touchSlider', $.ui.slider, { + + /** + * Creates instance of widget. + * + * @override + */ + _create: function () { + _.bindAll( + this, + '_mouseDown', + '_mouseMove', + '_onTouchEnd' + ); + + return this._superApply(arguments); + }, + + /** + * Initializes mouse events on element. + * @override + */ + _mouseInit: function () { + var result = this._superApply(arguments); + + this.element + .off('mousedown.' + this.widgetName) + .on('touchstart.' + this.widgetName, this._mouseDown); + + return result; + }, + + /** + * Elements' 'mousedown' event handler polyfill. + * @override + */ + _mouseDown: function (event) { + var prevDelegate = this._mouseMoveDelegate, + result; + + event = this._touchToMouse(event); + result = this._super(event); + + if (prevDelegate === this._mouseMoveDelegate) { + return result; + } + + $(document) + .off('mousemove.' + this.widgetName) + .off('mouseup.' + this.widgetName); + + $(document) + .on('touchmove.' + this.widgetName, this._mouseMove) + .on('touchend.' + this.widgetName, this._onTouchEnd) + .on('tochleave.' + this.widgetName, this._onTouchEnd); + + return result; + }, + + /** + * Documents' 'mousemove' event handler polyfill. + * + * @override + * @param {Event} event - Touch event object. + */ + _mouseMove: function (event) { + event = this._touchToMouse(event); + + return this._super(event); + }, + + /** + * Documents' 'touchend' event handler. + */ + _onTouchEnd: function (event) { + $(document).trigger('mouseup'); + + return this._mouseUp(event); + }, + + /** + * Removes previously assigned touch handlers. + * + * @override + */ + _mouseUp: function () { + this._removeTouchHandlers(); + + return this._superApply(arguments); + }, + + /** + * Removes previously assigned touch handlers. + * + * @override + */ + _mouseDestroy: function () { + this._removeTouchHandlers(); + + return this._superApply(arguments); + }, + + /** + * Removes touch events from document object. + */ + _removeTouchHandlers: function () { + $(document) + .off('touchmove.' + this.widgetName) + .off('touchend.' + this.widgetName) + .off('touchleave.' + this.widgetName); + }, + + /** + * Adds properties to the touch event to mimic mouse event. + * + * @param {Event} event - Touch event object. + * @returns {Event} + */ + _touchToMouse: function (event) { + var orig = event.originalEvent, + touch = orig.touches[0]; + + return _.extend(event, { + which: 1, + pageX: touch.pageX, + pageY: touch.pageY, + clientX: touch.clientX, + clientY: touch.clientY, + screenX: touch.screenX, + screenY: touch.screenY + }); + } + }); + + sliderFn = 'touchSlider'; +}); diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/tooltip.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/tooltip.js new file mode 100644 index 0000000000000..99870e0d70aa9 --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/bindings/tooltip.js @@ -0,0 +1,743 @@ +/** + * Copyright © 2015 Magento. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery', + 'ko', + 'underscore', + 'mage/template', + 'text!ui/template/tooltip/tooltip.html', + '../template/renderer' +], function ($, ko, _, template, tooltipTmpl, renderer) { + 'use strict'; + + var tooltip, + defaults, + positions, + transformProp, + checkedPositions = {}, + iterator = 0, + previousTooltip, + tooltipData, + positionData = {}, + tooltipsCollection = {}, + isTouchDevice = (function () { + return 'ontouchstart' in document.documentElement; + })(), + CLICK_EVENT = (function () { + return isTouchDevice ? 'touchstart' : 'click'; + })(); + + defaults = { + tooltipWrapper: '[data-tooltip=tooltip-wrapper]', + tooltipContentBlock: 'data-tooltip-content', + closeButtonClass: 'action-close', + tailClass: 'data-tooltip-tail', + action: 'hover', + delay: 300, + track: false, + step: 20, + position: 'top', + closeButton: false, + showed: false, + strict: true, + center: false + }; + + tooltipData = { + trigger: false, + timeout: 0, + element: false, + event: false, + targetElement: {}, + showed: false, + currentID: 0 + }; + + /** + * Polyfill for css transform + */ + transformProp = (function () { + var style = document.createElement('div').style, + base = 'Transform', + vendors = ['webkit', 'moz', 'ms', 'o'], + vi = vendors.length, + property; + + if (typeof style.transform !== 'undefined') { + return 'transform'; + } + + while (vi--) { + property = vendors[vi] + base; + + if (typeof style[property] !== 'undefined') { + return property; + } + } + })(); + + positions = { + + /*eslint max-depth: [0, 0]*/ + + map: { + horizontal: { + s: 'w', + p: 'left' + }, + vertical: { + s: 'h', + p: 'top' + } + }, + + /** + * Wrapper function to get tooltip data (position, className, etc) + * + * @param {Object} s - object with sizes and positions elements + * @returns {Object} tooltip data (position, className, etc) + */ + top: function (s) { + return positions._topLeftChecker(s, positions.map, 'vertical', '_bottom', 'top', 'right'); + }, + + /** + * Wrapper function to get tooltip data (position, className, etc) + * + * @param {Object} s - object with sizes and positions elements + * @returns {Object} tooltip data (position, className, etc) + */ + left: function (s) { + return positions._topLeftChecker(s, positions.map, 'horizontal', '_right', 'left', 'top'); + }, + + /** + * Wrapper function to get tooltip data (position, className, etc) + * + * @param {Object} s - object with sizes and positions elements + * @returns {Object} tooltip data (position, className, etc) + */ + bottom: function (s) { + return positions._bottomRightChecker(s, positions.map, 'vertical', '_top', 'bottom', 'left'); + }, + + /** + * Wrapper function to get tooltip data (position, className, etc) + * + * @param {Object} s - object with sizes and positions elements + * @returns {Object} tooltip data (position, className, etc) + */ + right: function (s) { + return positions._bottomRightChecker(s, positions.map, 'horizontal', '_left', 'right', 'bottom'); + }, + + /** + * Check can tooltip setted on current position or not. If can't setted - delegate call. + * + * @param {Object} s - object with sizes and positions elements + * @param {Object} map - mapping for get direction positions + * @param {String} direction - vertical or horizontal + * @param {String} className - class whats should be setted to tooltip + * @param {String} side - parent method name + * @param {String} delegate - method name if tooltip can't be setted in current position + * @returns {Object} tooltip data (position, className, etc) + */ + _topLeftChecker: function (s, map, direction, className, side, delegate) { + var result = { + position: {} + }, + config = tooltip.getTooltip(tooltipData.currentID), + startPosition = !config.strict ? s.eventPosition : s.elementPosition, + changedDirection; + + checkedPositions[side] = true; + + if ( + startPosition[map[direction].p] - s.tooltipSize[map[direction].s] - config.step > + s.scrollPosition[map[direction].p] + ) { + result.position[map[direction].p] = startPosition[map[direction].p] - s.tooltipSize[map[direction].s] - + config.step; + result.className = className; + result.side = side; + changedDirection = direction === 'vertical' ? 'horizontal' : 'vertical'; + result = positions._normalize(s, result, config, delegate, map, changedDirection); + } else if (!checkedPositions[delegate]) { + result = positions[delegate].apply(null, arguments); + } else { + result = positions.positionCenter(s, result); + } + + return result; + }, + + /** + * Check can tooltip setted on current position or not. If can't setted - delegate call. + * + * @param {Object} s - object with sizes and positions elements + * @param {Object} map - mapping for get direction positions + * @param {String} direction - vertical or horizontal + * @param {String} className - class whats should be setted to tooltip + * @param {String} side - parent method name + * @param {String} delegate - method name if tooltip can't be setted in current position + * @returns {Object} tooltip data (position, className, etc) + */ + _bottomRightChecker: function (s, map, direction, className, side, delegate) { + var result = { + position: {} + }, + config = tooltip.getTooltip(tooltipData.currentID), + startPosition = !config.strict ? s.eventPosition : { + top: s.elementPosition.top + s.elementSize.h, + left: s.elementPosition.left + s.elementSize.w + }, + changedDirection; + + checkedPositions[side] = true; + + if ( + startPosition[map[direction].p] + s.tooltipSize[map[direction].s] + config.step < + s.scrollPosition[map[direction].p] + s.windowSize[map[direction].s] + ) { + result.position[map[direction].p] = startPosition[map[direction].p] + config.step; + result.className = className; + result.side = side; + changedDirection = direction === 'vertical' ? 'horizontal' : 'vertical'; + result = positions._normalize(s, result, config, delegate, map, changedDirection); + } else if (!checkedPositions[delegate]) { + result = positions[delegate].apply(null, arguments); + } else { + result = positions.positionCenter(s, result); + } + + return result; + }, + + /** + * Centered tooltip if tooltip does not fit in window + * + * @param {Object} s - object with sizes and positions elements + * @param {Object} data - current data (position, className, etc) + * @returns {Object} tooltip data (position, className, etc) + */ + positionCenter: function (s, data) { + data = positions._positionCenter(s, data, 'horizontal', positions.map); + data = positions._positionCenter(s, data, 'vertical', positions.map); + + return data; + }, + + /** + * Centered tooltip side + * + * @param {Object} s - object with sizes and positions elements + * @param {Object} data - current data (position, className, etc) + * @param {String} direction - vertical or horizontal + * @param {Object} map - mapping for get direction positions + * @returns {Object} tooltip data (position, className, etc) + */ + _positionCenter: function (s, data, direction, map) { + if (s.tooltipSize[map[direction].s] < s.windowSize[map[direction].s]) { + data.position[map[direction].p] = (s.windowSize[map[direction].s] - + s.tooltipSize[map[direction].s]) / 2 + s.scrollPosition[map[direction].p]; + } else { + data.position[map[direction].p] = s.scrollPosition[map[direction].p]; + data.tooltipSize = {}; + data.tooltipSize[map[direction].s] = s.windowSize[map[direction].s]; + } + + return data; + }, + + /** + * Normalize horizontal or vertical position. + * + * @param {Object} s - object with sizes and positions elements + * @param {Object} data - current data (position, className, etc) + * @param {Object} config - tooltip config + * @param {String} delegate - method name if tooltip can't be setted in current position + * @param {Object} map - mapping for get direction positions + * @param {String} direction - vertical or horizontal + * @returns {Object} tooltip data (position, className, etc) + */ + _normalize: function (s, data, config, delegate, map, direction) { + var startPosition = !config.center ? s.eventPosition : { + left: s.elementPosition.left + s.elementSize.w / 2, + top: s.elementPosition.top + s.elementSize.h / 2 + }, + depResult; + + if (startPosition[map[direction].p] - s.tooltipSize[map[direction].s] / 2 > + s.scrollPosition[map[direction].p] && startPosition[map[direction].p] + + s.tooltipSize[map[direction].s] / 2 < + s.scrollPosition[map[direction].p] + s.windowSize[map[direction].s] + ) { + data.position[map[direction].p] = startPosition[map[direction].p] - s.tooltipSize[map[direction].s] / 2; + } else { + + /*eslint-disable no-lonely-if*/ + if (!checkedPositions[delegate]) { + depResult = positions[delegate].apply(null, arguments); + + if (depResult.hasOwnProperty('className')) { + data = depResult; + } else { + data = positions._normalizeTail(s, data, config, delegate, map, direction, startPosition); + } + } else { + data = positions._normalizeTail(s, data, config, delegate, map, direction, startPosition); + } + } + + return data; + }, + + /** + * Calc tail position. + * + * @param {Object} s - object with sizes and positions elements + * @param {Object} data - current data (position, className, etc) + * @param {Object} config - tooltip config + * @param {String} delegate - method name if tooltip can't be setted in current position + * @param {Object} map - mapping for get direction positions + * @param {String} direction - vertical or horizontal + * @param {Object} startPosition - start position + * @returns {Object} tooltip data (position, className, etc) + */ + _normalizeTail: function (s, data, config, delegate, map, direction, startPosition) { + data.tail = {}; + + if (s.tooltipSize[map[direction].s] < s.windowSize[map[direction].s]) { + + if ( + startPosition[map[direction].p] > + s.windowSize[map[direction].s] / 2 + s.scrollPosition[map[direction].p] + ) { + data.position[map[direction].p] = s.windowSize[map[direction].s] + + s.scrollPosition[map[direction].p] - s.tooltipSize[map[direction].s]; + data.tail[map[direction].p] = startPosition[map[direction].p] - + s.tooltipSize[map[direction].s] / 2 - data.position[map[direction].p]; + } else { + data.position[map[direction].p] = s.scrollPosition[map[direction].p]; + data.tail[map[direction].p] = startPosition[map[direction].p] - + s.tooltipSize[map[direction].s] / 2 - data.position[map[direction].p]; + } + } else { + data.position[map[direction].p] = s.scrollPosition[map[direction].p]; + data.tail[map[direction].p] = s.eventPosition[map[direction].p] - s.windowSize[map[direction].s] / 2; + data.tooltipSize = {}; + data.tooltipSize[map[direction].s] = s.windowSize[map[direction].s]; + } + + return data; + } + }; + + tooltip = { + + /** + * Set new tooltip to tooltipCollection, save config, and add unic id + * + * @param {Object} config - tooltip config + * @returns {String} tooltip id + */ + setTooltip: function (config) { + var property = 'id-' + iterator; + + tooltipsCollection[property] = config; + iterator++; + + return property; + }, + + /** + * Get tooltip config by id + * + * @param {String} id - tooltip id + * @returns {Object} tooltip config + */ + getTooltip: function (id) { + return tooltipsCollection[id]; + }, + + /** + * Set content to current tooltip + * + * @param {Object} tooltipElement - tooltip element + * @param {Object} viewModel - tooltip view model + * @param {String} id - tooltip id + * @param {Object} bindingCtx - tooltip context + * @param {Object} event - action event + */ + setContent: function (tooltipElement, viewModel, id, bindingCtx, event) { + var html = $(tooltipElement).html(), + config = tooltip.getTooltip(id), + body = $('body'); + + tooltipData.currentID = id; + tooltipData.trigger = $(event.currentTarget); + tooltip.setTargetData(event); + body.on('mousemove.setTargetData', tooltip.setTargetData); + tooltip.clearTimeout(id); + + tooltipData.timeout = _.delay(function () { + body.off('mousemove.setTargetData', tooltip.setTargetData); + + if (tooltipData.trigger[0] === tooltipData.targetElement) { + tooltip.destroy(id); + event.stopPropagation(); + tooltipElement = tooltip.createTooltip(id); + tooltipElement.find('.' + defaults.tooltipContentBlock).append(html); + tooltipElement.applyBindings(bindingCtx); + tooltip.setHandlers(id); + tooltip.setPosition(tooltipElement, id); + previousTooltip = id; + } + + }, config.delay); + }, + + /** + * Set position to current tooltip + * + * @param {Object} tooltipElement - tooltip element + * @param {String} id - tooltip id + */ + setPosition: function (tooltipElement, id) { + var config = tooltip.getTooltip(id); + + tooltip.sizeData = { + windowSize: { + h: $(window).outerHeight(), + w: $(window).outerWidth() + }, + scrollPosition: { + top: $(window).scrollTop(), + left: $(window).scrollLeft() + }, + tooltipSize: { + h: tooltipElement.outerHeight(), + w: tooltipElement.outerWidth() + }, + elementSize: { + h: tooltipData.trigger.outerHeight(), + w: tooltipData.trigger.outerWidth() + }, + elementPosition: tooltipData.trigger.offset(), + eventPosition: this.getEventPosition(tooltipData.event) + }; + + _.extend(positionData, positions[config.position](tooltip.sizeData)); + tooltipElement.css(positionData.position); + tooltipElement.addClass(positionData.className); + tooltip._setTooltipSize(positionData, tooltipElement); + tooltip._setTailPosition(positionData, tooltipElement); + checkedPositions = {}; + }, + + /** + * Check position data and change tooltip size if needs + * + * @param {Object} data - position data + * @param {Object} tooltipElement - tooltip element + */ + _setTooltipSize: function (data, tooltipElement) { + if (data.tooltipSize) { + data.tooltipSize.w ? + tooltipElement.css('width', data.tooltipSize.w) : + tooltipElement.css('height', data.tooltipSize.h); + } + }, + + /** + * Check position data and set position to tail + * + * @param {Object} data - position data + * @param {Object} tooltipElement - tooltip element + */ + _setTailPosition: function (data, tooltipElement) { + var tail, + tailMargin; + + if (data.tail) { + tail = tooltipElement.find('.' + defaults.tailClass); + + if (data.tail.left) { + tailMargin = parseInt(tail.css('margin-left'), 10); + tail.css('margin-left', tailMargin + data.tail.left); + } else { + tailMargin = parseInt(tail.css('margin-top'), 10); + tail.css('margin-top', tailMargin + data.tail.top); + } + } + }, + + /** + * Resolves position for tooltip + * + * @param {Object} event + * @returns {Object} + */ + getEventPosition: function (event) { + var position = { + left: event.originalEvent && event.originalEvent.pageX || 0, + top: event.originalEvent && event.originalEvent.pageY || 0 + }; + + if (position.left === 0 && position.top === 0) { + _.extend(position, event.target.getBoundingClientRect()); + } + + return position; + }, + + /** + * Close tooltip if action happened outside handler and tooltip element + * + * @param {String} id - tooltip id + * @param {Object} event - action event + */ + outerClick: function (id, event) { + var tooltipElement = $(event.target).parents(defaults.tooltipWrapper)[0], + isTrigger = event.target === tooltipData.trigger[0] || $.contains(tooltipData.trigger[0], event.target); + + if (tooltipData.showed && tooltipElement !== tooltipData.element[0] && !isTrigger) { + tooltip.destroy(id); + } + }, + + /** + * Parse keydown event and if event trigger is escape key - close tooltip + * + * @param {Object} event - action event + */ + keydownHandler: function (event) { + if (tooltipData.showed && event.keyCode === 27) { + tooltip.destroy(tooltipData.currentID); + } + }, + + /** + * Change tooltip position when track is enabled + * + * @param {Object} event - current event + */ + track: function (event) { + var inequality = {}, + map = positions.map, + translate = { + left: 'translateX', + top: 'translateY' + }, + eventPosition = { + left: event.pageX, + top: event.pageY + }, + tooltipSize = { + w: tooltipData.element.outerWidth(), + h: tooltipData.element.outerHeight() + }, + direction = positionData.side === 'bottom' || positionData.side === 'top' ? 'horizontal' : 'vertical'; + + inequality[map[direction].p] = eventPosition[map[direction].p] - (positionData.position[map[direction].p] + + tooltipSize[map[direction].s] / 2); + + if (positionData.position[map[direction].p] + inequality[map[direction].p] + + tooltip.sizeData.tooltipSize[map[direction].s] > + tooltip.sizeData.windowSize[map[direction].s] + tooltip.sizeData.scrollPosition[map[direction].p] || + inequality[map[direction].p] + positionData.position[map[direction].p] < + tooltip.sizeData.scrollPosition[map[direction].p]) { + + return false; + } + + tooltipData.element[0].style[transformProp] = translate[map[direction].p] + + '(' + inequality[map[direction].p] + 'px)'; + }, + + /** + * Set handlers to tooltip + * + * @param {String} id - tooltip id + */ + setHandlers: function (id) { + var config = tooltip.getTooltip(id); + + if (config.track) { + tooltipData.trigger.on('mousemove.track', tooltip.track); + } + + if (config.action === 'click') { + $(window).on(CLICK_EVENT + '.outerClick', tooltip.outerClick.bind(null, id)); + } + + if (config.closeButton) { + $('.' + config.closeButtonClass).on('click.closeButton', tooltip.destroy.bind(null, id)); + } + + document.addEventListener('scroll', tooltip.destroy, true); + $(window).on('keydown.tooltip', tooltip.keydownHandler); + $(window).on('scroll.tooltip', tooltip.outerClick.bind(null, id)); + $(window).on('resize.outerClick', tooltip.outerClick.bind(null, id)); + }, + + /** + * Toggle tooltip + * + * @param {Object} tooltipElement - tooltip element + * @param {Object} viewModel - tooltip view model + * @param {String} id - tooltip id + */ + toggleTooltip: function (tooltipElement, viewModel, id) { + if (previousTooltip === id && tooltipData.showed) { + tooltip.destroy(id); + + return false; + } + + tooltip.setContent.apply(null, arguments); + }, + + /** + * Create tooltip and append to DOM + * + * @param {String} id - tooltip id + * @returns {Object} tooltip element + */ + createTooltip: function (id) { + var body = $('body'), + config = tooltip.getTooltip(id); + + $(template(tooltipTmpl, { + data: config + })).appendTo(body); + + tooltipData.showed = true; + tooltipData.element = $(config.tooltipWrapper); + + return tooltipData.element; + }, + + /** + * Check action and clean timeout + * + * @param {String} id - tooltip id + */ + clearTimeout: function (id) { + var config = tooltip.getTooltip(id); + + if (config.action === 'hover') { + clearTimeout(tooltipData.timeout); + } + }, + + /** + * Check previous tooltip + */ + checkPreviousTooltip: function () { + if (!tooltipData.timeout) { + tooltip.destroy(); + } + }, + + /** + * Destroy tooltip instance + */ + destroy: function () { + if (tooltipData.element) { + tooltipData.element.remove(); + tooltipData.showed = false; + } + + positionData = {}; + tooltipData.timeout = false; + tooltip.removeHandlers(); + }, + + /** + * Remove tooltip handlers + */ + removeHandlers: function () { + $('.' + defaults.closeButtonClass).off('click.closeButton'); + tooltipData.trigger.off('mousemove.track'); + document.removeEventListener('scroll', tooltip.destroy, true); + $(window).off(CLICK_EVENT + '.outerClick'); + $(window).off('keydown.tooltip'); + $(window).off('resize.outerClick'); + }, + + /** + * Set target element + * + * @param {Object} event - current event + */ + setTargetData: function (event) { + tooltipData.event = event; + tooltipData.targetElement = event.type === 'mousemove' ? + event.target : event.currentTarget; + }, + + /** + * Merged user config with defaults configuration + * + * @param {Object} config - user config + * @returns {Object} merged config + */ + processingConfig: function (config) { + return _.extend({}, defaults, config); + } + }; + + ko.bindingHandlers.tooltip = { + + /** + * Initialize tooltip + * + * @param {Object} elem - tooltip DOM element + * @param {Function} valueAccessor - ko observable property, tooltip data + * @param {Object} allBindings - all bindings on current element + * @param {Object} viewModel - current element viewModel + * @param {Object} bindingCtx - current element binding context + */ + init: function (elem, valueAccessor, allBindings, viewModel, bindingCtx) { + var config = tooltip.processingConfig(valueAccessor()), + $parentScope = config.parentScope ? $(config.parentScope) : $(elem).parent(), + tooltipId; + + $(elem).addClass('hidden'); + + if (isTouchDevice) { + config.action = 'click'; + } + tooltipId = tooltip.setTooltip(config); + + if (config.action === 'hover') { + $parentScope.on( + 'mouseenter', + config.trigger, + tooltip.setContent.bind(null, elem, viewModel, tooltipId, bindingCtx) + ); + $parentScope.on( + 'mouseleave', + config.trigger, + tooltip.checkPreviousTooltip.bind(null, tooltipId) + ); + } else if (config.action === 'click') { + $parentScope.on( + 'click', + config.trigger, + tooltip.toggleTooltip.bind(null, elem, viewModel, tooltipId, bindingCtx) + ); + } + + return { + controlsDescendantBindings: true + }; + } + }; + + renderer.addAttribute('tooltip'); +}); diff --git a/app/code/Magento/Ui/view/base/web/js/lib/knockout/template/renderer.js b/app/code/Magento/Ui/view/base/web/js/lib/knockout/template/renderer.js index 4bb148a0995c4..8f5fb6646efef 100644 --- a/app/code/Magento/Ui/view/base/web/js/lib/knockout/template/renderer.js +++ b/app/code/Magento/Ui/view/base/web/js/lib/knockout/template/renderer.js @@ -462,6 +462,8 @@ define([ 'textInput', 'component', 'uniqueName', + 'optionsText', + 'optionsValue', 'checkedValue', 'selectedOptions' ], Array.prototype) diff --git a/app/code/Magento/Ui/view/base/web/js/timeline/timeline-view.js b/app/code/Magento/Ui/view/base/web/js/timeline/timeline-view.js new file mode 100644 index 0000000000000..a5bafc72b5e6d --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/timeline/timeline-view.js @@ -0,0 +1,415 @@ +/** + * Copyright © 2015 Magento. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'ko', + 'Magento_Ui/js/lib/view/utils/async', + 'underscore', + 'Magento_Ui/js/lib/view/utils/raf', + 'uiRegistry', + 'uiClass' +], function (ko, $, _, raf, registry, Class) { + 'use strict'; + + var hasClassList = (function () { + var list = document.createElement('_').classList; + + return !!list && !list.toggle('_test', false); + })(); + + /** + * Polyfill of the 'classList.toggle' method. + * + * @param {HTMLElement} elem + */ + function toggleClass(elem) { + var classList = elem.classList, + args = Array.prototype.slice.call(arguments, 1), + $elem; + + if (hasClassList) { + classList.toggle.apply(classList, args); + } else { + $elem = $(elem); + $elem.toggleClass.apply($elem, args); + } + } + + return Class.extend({ + defaults: { + selectors: { + content: '.timeline-content', + timeUnit: '.timeline-unit', + item: '.timeline-item:not([data-role=no-data-msg])', + event: '.timeline-event' + } + }, + + /** + * Initializes TimelineView component. + * + * @returns {TimelineView} Chainable. + */ + initialize: function () { + _.bindAll( + this, + 'refresh', + 'initContent', + 'initItem', + 'initTimeUnit', + 'getItemBindings', + 'updateItemsPosition', + 'onScaleChange', + 'onEventElementRender', + 'onWindowResize', + 'onContentScroll', + 'onDataReloaded', + 'onToStartClick', + 'onToEndClick' + ); + + this._super() + .initModel() + .waitContent(); + + return this; + }, + + /** + * Applies listeners for the model properties changes. + * + * @returns {TimelineView} Chainable. + */ + initModel: function () { + var model = registry.get(this.model); + + model.on('scale', this.onScaleChange); + model.source.on('reloaded', this.onDataReloaded); + + this.model = model; + + return this; + }, + + /** + * Applies DOM watcher for the + * content element rendering. + * + * @returns {TimelineView} Chainable. + */ + waitContent: function () { + $.async({ + selector: this.selectors.content, + component: this.model + }, this.initContent); + + return this; + }, + + /** + * Initializes timelines' content element. + * + * @param {HTMLElement} content + * @returns {TimelineView} Chainable. + */ + initContent: function (content) { + this.$content = content; + + $(content).on('scroll', this.onContentScroll); + $(window).on('resize', this.onWindowResize); + + $.async(this.selectors.item, content, this.initItem); + $.async(this.selectors.event, content, this.onEventElementRender); + $.async(this.selectors.timeUnit, content, this.initTimeUnit); + + this.refresh(); + + return this; + }, + + /** + * Initializes timeline item element, + * e.g. establishes event listeners and applies data bindings. + * + * @param {HTMLElement} elem + * @returns {TimelineView} Chainable. + */ + initItem: function (elem) { + $(elem) + .bindings(this.getItemBindings) + .on('click', '._toend', this.onToEndClick) + .on('click', '._tostart', this.onToStartClick); + + return this; + }, + + /** + * Initializes timeline unit element. + * + * @param {HTMLElement} elem + * @returns {TimelineView} Chainable. + */ + initTimeUnit: function (elem) { + $(elem).bindings(this.getTimeUnitBindings()); + + return this; + }, + + /** + * Updates items positions in a + * loop if state of a view has changed. + */ + refresh: function () { + raf(this.refresh); + + if (this._update) { + this._update = false; + + this.updateItemsPosition(); + } + }, + + /** + * Returns object width additional bindings + * for a timeline unit element. + * + * @returns {Object} + */ + getTimeUnitBindings: function () { + return { + style: { + width: ko.computed(function () { + return this.getTimeUnitWidth() + '%'; + }.bind(this)) + } + }; + }, + + /** + * Returns object with additional + * bindings for a timeline item element. + * + * @param {Object} ctx + * @returns {Object} + */ + getItemBindings: function (ctx) { + return { + style: { + width: ko.computed(function () { + return this.getItemWidth(ctx.$row()) + '%'; + }.bind(this)), + + 'margin-left': ko.computed(function () { + return this.getItemMargin(ctx.$row()) + '%'; + }.bind(this)) + } + }; + }, + + /** + * Calculates width in percents of a timeline unit element. + * + * @returns {Number} + */ + getTimeUnitWidth: function () { + return 100 / this.model.scale; + }, + + /** + * Calculates width of a record in percents. + * + * @param {Object} record + * @returns {String} + */ + getItemWidth: function (record) { + var days = 0; + + if (record) { + days = this.model.getDaysLength(record); + } + + return this.getTimeUnitWidth() * days; + }, + + /** + * Calculates left margin value for provided record. + * + * @param {Object} record + * @returns {String} + */ + getItemMargin: function (record) { + var offset = 0; + + if (record) { + offset = this.model.getStartDelta(record); + } + + return this.getTimeUnitWidth() * offset; + }, + + /** + * Returns collection of currently available + * timeline item elements. + * + * @returns {Array} + */ + getItems: function () { + var items = this.$content.querySelectorAll(this.selectors.item); + + return _.toArray(items); + }, + + /** + * Updates positions of timeline elements. + * + * @returns {TimelineView} Chainable. + */ + updateItemsPosition: function () { + this.getItems() + .forEach(this.updatePositionFor, this); + + return this; + }, + + /** + * Updates position of provided timeline element. + * + * @param {HTMLElement} $elem + * @returns {TimelineView} Chainable. + */ + updatePositionFor: function ($elem) { + var $event = $elem.querySelector(this.selectors.event), + leftEdge = this.getLeftEdgeFor($elem), + rightEdge = this.getRightEdgeFor($elem); + + if ($event) { + $event.style.left = Math.max(-leftEdge, 0) + 'px'; + $event.style.right = Math.max(rightEdge, 0) + 'px'; + } + + toggleClass($elem, '_scroll-start', leftEdge < 0); + toggleClass($elem, '_scroll-end', rightEdge > 0); + + return this; + }, + + /** + * Scrolls content area to the start of provided element. + * + * @param {HTMLElement} elem + * @returns {TimelineView} + */ + toStartOf: function (elem) { + var leftEdge = this.getLeftEdgeFor(elem); + + this.$content.scrollLeft += leftEdge; + + return this; + }, + + /** + * Scrolls content area to the end of provided element. + * + * @param {HTMLElement} elem + * @returns {TimelineView} + */ + toEndOf: function (elem) { + var rightEdge = this.getRightEdgeFor(elem); + + this.$content.scrollLeft += rightEdge + 1; + + return this; + }, + + /** + * Calculates location of the left edge of an element + * relative to the contents' left edge. + * + * @param {HTMLElement} elem + * @returns {Number} + */ + getLeftEdgeFor: function (elem) { + var leftOffset = elem.getBoundingClientRect().left; + + return leftOffset - this.$content.getBoundingClientRect().left; + }, + + /** + * Calculates location of the right edge of an element + * relative to the contents' right edge. + * + * @param {HTMLElement} elem + * @returns {Number} + */ + getRightEdgeFor: function (elem) { + var elemWidth = elem.offsetWidth, + leftEdge = this.getLeftEdgeFor(elem); + + return leftEdge + elemWidth - this.$content.offsetWidth; + }, + + /** + * 'To Start' button 'click' event handler. + * + * @param {jQueryEvent} event + */ + onToStartClick: function (event) { + var elem = event.originalEvent.currentTarget; + + event.stopPropagation(); + + this.toStartOf(elem); + }, + + /** + * 'To End' button 'click' event handler. + * + * @param {jQueryEvent} event + */ + onToEndClick: function (event) { + var elem = event.originalEvent.currentTarget; + + event.stopPropagation(); + + this.toEndOf(elem); + }, + + /** + * Handler of the scale value 'change' event. + */ + onScaleChange: function () { + this._update = true; + }, + + /** + * Callback function which is invoked + * when event element was rendered. + */ + onEventElementRender: function () { + this._update = true; + }, + + /** + * Window 'resize' event handler. + */ + onWindowResize: function () { + this._update = true; + }, + + /** + * Content container 'scroll' event handler. + */ + onContentScroll: function () { + this._update = true; + }, + + /** + * Data 'reload' event handler. + */ + onDataReloaded: function () { + this._update = true; + } + }); +}); diff --git a/app/code/Magento/Ui/view/base/web/js/timeline/timeline.js b/app/code/Magento/Ui/view/base/web/js/timeline/timeline.js new file mode 100644 index 0000000000000..50ab80d3f1548 --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/js/timeline/timeline.js @@ -0,0 +1,329 @@ +/** + * Copyright © 2015 Magento. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'underscore', + 'moment', + 'uiLayout', + 'Magento_Ui/js/grid/listing' +], function (_, moment, layout, Listing) { + 'use strict'; + + var ONE_DAY = 86400000; + + return Listing.extend({ + defaults: { + recordTmpl: 'ui/timeline/record', + dateFormat: 'YYYY-MM-DD HH:mm:ss', + headerFormat: 'ddd MM/DD', + detailsFormat: 'DD/MM/YYYY HH:mm:ss', + scale: 7, + scaleStep: 1, + minScale: 7, + maxScale: 28, + minDays: 28, + displayMode: 'timeline', + displayModes: { + timeline: { + label: 'Timeline', + value: 'timeline', + template: 'ui/timeline/timeline' + } + }, + viewConfig: { + component: 'Magento_Ui/js/timeline/timeline-view', + name: '${ $.name }_view', + model: '${ $.name }' + }, + tracks: { + scale: true + }, + statefull: { + scale: true + }, + range: {} + }, + + /** + * Initializes Timeline component. + * + * @returns {Timeline} Chainable. + */ + initialize: function () { + this._super() + .initView() + .updateRange(); + + return this; + }, + + /** + * Initializes components configuration. + * + * @returns {Timeline} Chainable. + */ + initConfig: function () { + this._super(); + + this.maxScale = Math.min(this.minDays, this.maxScale); + this.minScale = Math.min(this.maxScale, this.minScale); + + return this; + }, + + /** + * Initializes observable properties. + * + * @returns {Timeline} Chainable. + */ + initObservable: function () { + this._super() + .observe.call(this.range, true, 'hasToday'); + + return this; + }, + + /** + * Initializes TimelineView component. + * + * @returns {Timeline} Chainable. + */ + initView: function () { + layout([this.viewConfig]); + + return this; + }, + + /** + * Checks if provided event record is active, + * i.e. it has already started. + * + * @param {Object} record + * @returns {Boolean} + */ + isActive: function (record) { + return record.status === 1; + }, + + /** + * Checks if provided event record is upcoming, + * i.e. it will start later on. + * + * @param {Object} record + * @returns {Boolean} + */ + isUpcoming: function (record) { + return record.status === 2; + }, + + /** + * Checks if provided event record is permanent, + * i.e. it has no ending time. + * + * @param {Object} record + * @returns {Boolean} + */ + isPermanent: function (record) { + return !this.getEndDate(record); + }, + + /** + * Checks if provided date indicates current day. + * + * @param {(Number|Moment)} date + * @returns {Boolenan} + */ + isToday: function (date) { + return moment().isSame(date, 'day'); + }, + + /** + * Checks if range object contains todays date. + * + * @returns {Boolean} + */ + hasToday: function () { + return this.range.hasToday; + }, + + /** + * Returns start date of provided record. + * + * @param {Object} record + * @returns {String} + */ + getStartDate: function (record) { + return record['start_time']; + }, + + /** + * Returns end date of provided record. + * + * @param {Object} record + * @returns {String} + */ + getEndDate: function (record) { + return record['end_time']; + }, + + /** + * Returns difference in days between records' start date + * and a first day of a range. + * + * @param {Object} record + * @returns {Number} + */ + getStartDelta: function (record) { + var start = this.createDate(this.getStartDate(record)), + firstDay = this.range.firstDay; + + return start.diff(firstDay, 'days', true); + }, + + /** + * Calculates the amount of days that provided event lasts. + * + * @param {Object} record + * @returns {Number} + */ + getDaysLength: function (record) { + var start = this.createDate(this.getStartDate(record)), + end = this.createDate(this.getEndDate(record)); + + if (!end.isValid()) { + end = this.range.lastDay.endOf('day'); + } + + return end.diff(start, 'days', true); + }, + + /** + * Creates new date object based on provided date string value. + * + * @param {String} dateStr + * @returns {Moment} + */ + createDate: function (dateStr) { + return moment(dateStr, this.dateFormat); + }, + + /** + * Converts days to weeks. + * + * @param {Number} days + * @returns {Number} + */ + daysToWeeks: function (days) { + var weeks = days / 7; + + if (weeks % 1) { + weeks = weeks.toFixed(1); + } + + return weeks; + }, + + /** + * Updates data of a range object, + * e.g. total days, first day and last day, etc. + * + * @returns {Object} Range instance. + */ + updateRange: function () { + var firstDay = this._getFirstDay(), + lastDay = this._getLastDay(), + totalDays = lastDay.diff(firstDay, 'days'), + days = [], + i = -1; + + if (totalDays < this.minDays) { + totalDays += this.minDays - totalDays - 1; + } + + while (++i <= totalDays) { + days.push(+firstDay + ONE_DAY * i); + } + + return _.extend(this.range, { + days: days, + totalDays: totalDays, + firstDay: firstDay, + lastDay: moment(_.last(days)), + hasToday: this.isToday(firstDay) + }); + }, + + /** + * + * @private + * @param {String} key + * @returns {Array} + */ + _getDates: function (key) { + var dates = []; + + this.rows.forEach(function (record) { + if (record[key]) { + dates.push(this.createDate(record[key])); + } + }, this); + + return dates; + }, + + /** + * Returns date which is closest to the current day. + * + * @private + * @returns {Moment} + */ + _getFirstDay: function () { + var dates = this._getDates('start_time'), + first = moment.min(dates).subtract(1, 'day'), + today = moment(); + + if (!first.isValid() || first < today) { + first = today; + } + + return first.startOf('day'); + }, + + /** + * Returns the most distant date + * specified in available records. + * + * @private + * @returns {Moment} + */ + _getLastDay: function () { + var startDates = this._getDates('start_time'), + endDates = this._getDates('end_time'), + last = moment.max(startDates.concat(endDates)); + + return last.add(1, 'day').startOf('day'); + }, + + /** + * TODO: remove after integration with date binding. + * + * @param {Number} timestamp + * @returns {String} + */ + formatHeader: function (timestamp) { + return moment(timestamp).format(this.headerFormat); + }, + + /** + * TODO: remove after integration with date binding. + * + * @param {String} date + * @returns {String} + */ + formatDetails: function (date) { + return moment(date).format(this.detailsFormat); + } + }); +}); diff --git a/app/code/Magento/Ui/view/base/web/templates/grid/listing.html b/app/code/Magento/Ui/view/base/web/templates/grid/listing.html index 702c07351767c..d02233cbe07c2 100644 --- a/app/code/Magento/Ui/view/base/web/templates/grid/listing.html +++ b/app/code/Magento/Ui/view/base/web/templates/grid/listing.html @@ -12,7 +12,7 @@ + css="getFieldClass($row())" click="getFieldHandler($row())" template="getBody()"/> diff --git a/app/code/Magento/Ui/view/base/web/templates/grid/view-switcher.html b/app/code/Magento/Ui/view/base/web/templates/grid/view-switcher.html new file mode 100644 index 0000000000000..d5335e2f4a38d --- /dev/null +++ b/app/code/Magento/Ui/view/base/web/templates/grid/view-switcher.html @@ -0,0 +1,16 @@ + + +
+