/*
* Copyright (c) 2014-2016, Wanadev <http://www.wanadev.fr/>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* * Neither the name of Wanadev nor the names of its contributors may be used
* to endorse or promote products derived from this software without specific
* prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Authored by: Valentin Ledrapier
*/
/**
* PhotonUI - Javascript Web User Interface.
*
* @module PhotonUI
* @submodule DataView
* @namespace photonui
*/
var lodash = require("lodash");
var Helpers = require("../helpers.js");
var Widget = require("../widget.js");
/**
* DataView container.
*
* wEvents:
*
* * item-click:
* - description: called when an item is clicked.
* - callback: function (event, item)
*
* * item-select:
* - description: called when an item is selected.
* - callback: function (item)
*
* * item-unselect:
* - description: called when an item is unselected.
* - callback: function (item)
*
* * item-sort:
* - description: called when an item is moved to a new position.
* - callback: function (item, position)
*
* @class DataView
* @constructor
* @extends photonui.Widget
*/
var DataView = Widget.$extend({
// Constructor
__init__: function (params) {
this._lockItemsUpdate = true;
this.$data._childrenNames = [];
this.$data.selectable = true;
this.$data.unselectOnOutsideClick = true;
this.$data.multiSelectable = false;
this.$data.multiSelectWithCtrl = true;
this.$data.allowShiftSelect = true;
this.$data.dragAndDroppable = false;
this.$data.containerElement = "ul";
this.$data.itemElement = "li";
this.$data.columnElement = "span";
this.$data._manuallySetColumns = (params && params.columns) ? true : false;
if (params && params.containerElement) {
this.$data.containerElement = params.containerElement;
params.containerElement = null;
}
this._addIdentifier("dataview");
this._addIdentifier(params && params.identifier);
this._initialSelectionItemIndex = null;
this._registerWEvents([
"item-select",
"item-unselect",
"item-click",
"item-sort",
]);
this.$super(params);
this._lockItemsUpdate = false;
this._buildItemsHtml();
this._bindEvent("click", this.__html.container, "click", this.__onClick.bind(this));
this._bindEvent("dragstart", this.__html.container, "dragstart", this.__onDragStart.bind(this));
this._bindEvent("dragenter", this.__html.container, "dragenter", this.__onDragEnter.bind(this));
this._bindEvent("dragend", this.__html.container, "dragend", this.__onDragEnd.bind(this));
this._bindEvent("dragover", this.__html.container, "dragover", this.__onDragOver.bind(this));
this._bindEvent("drop", this.__html.container, "drop", this.__onDrop.bind(this));
},
/**
* The collection of items displayed by the data view widget.
*
* @property items
* @type Array
* @default null
*/
getItems: function () {
return this.$data.items;
},
setItems: function (items) {
this._empty();
items = items || [];
this.$data.items = items.map(function (item, index) {
return typeof(item) === "object" ? {
index: index,
selected: false,
value: item,
} : {
index: index,
selected: false,
value: {
__generated__: item.toString(),
},
};
});
if (!this.$data._manuallySetColumns) {
this._generateColumns();
}
this._buildItemsHtml();
},
/**
* Defines if the data items can be selected.
*
* @property selectable
* @type Boolean
* @default false
*/
isSelectable: function () {
return this.$data.selectable;
},
setSelectable: function (selectable) {
this.$data.selectable = selectable;
},
/**
* If true, clicking outside of the items (in the container) will unselect
* currently selected items.
* Only used when "selectable" option is set to "true".
*
* @property unselectOnOutsideClick
* @type Boolean
* @default false
*/
getUnselectOnOutsideClick: function () {
return this.$data.unselectOnOutsideClick;
},
setUnselectOnOutsideClick: function (unselectOnOutsideClick) {
this.$data.unselectOnOutsideClick = unselectOnOutsideClick;
},
/**
* Defines if the data items can be multi-selected.
* Only used when "selectable" option is set to "true".
*
* @property multiSelectable
* @type Boolean
* @default false
*/
isMultiSelectable: function () {
return this.$data.multiSelectable;
},
setMultiSelectable: function (multiSelectable) {
this.$data.multiSelectable = multiSelectable;
},
/**
* Defines wether or not "ctrl" key has to be pressed to multi-select items.
* Only used when "multiSelectable" option is set to "true".
*
* @property multiSelectWithCtrl
* @type Boolean
* @default true
*/
getMultiSelectWithCtrl: function () {
return this.$data.multiSelectWithCtrl;
},
setMultiSelectWithCtrl: function (multiSelectWithCtrl) {
this.$data.multiSelectWithCtrl = multiSelectWithCtrl;
},
/**
* If true, allow selecting multiple items with one click when pressing
* "shift" key.
* Only used when "multiSelectable" option is set to "true".
*
* @property allowShiftSelect
* @type Boolean
* @default true
*/
getAllowShiftSelect: function () {
return this.$data.allowShiftSelect;
},
setAllowShiftSelect: function (allowShiftSelect) {
this.$data.allowShiftSelect = allowShiftSelect;
},
/**
* Defines if the data items can be drag & dropped.
*
* @property dragAndDroppable
* @type Boolean
* @default false
*/
isDragAndDroppable: function () {
return this.$data.dragAndDroppable;
},
setDragAndDroppable: function (dragAndDroppable) {
this.$data.dragAndDroppable = dragAndDroppable;
},
/**
* A custom formater function which overrides the default rendering process
* of the widget.
*
* @property customWidgetFormater
* @type Function
* @default null
*/
getCustomWidgetFormater: function () {
return this.$data.customWidgetFormater;
},
setCustomWidgetFormater: function (customWidgetFormater) {
this.$data.customWidgetFormater = customWidgetFormater;
},
/**
* The currently selected items.
*
* @property selectedItems
* @type Array
* @default []
*/
getSelectedItems: function () {
return this.$data.items ?
this.$data.items.filter(function (item) {
return item.selected;
}) : [];
},
/**
* The list of columns which defines the structure of the items (if not
* setted manually, the columns are automatically generated).
*
* @property columns
* @type Array
* @default null
*/
setColumns: function (columns) {
this.$data.columns = columns.map(function (column, index) {
return typeof(column) === "string" ? {
id: column,
value: column
} :
column.value ? lodash.merge({
id: typeof(column.value) === "string" ? column.value : "column" + index,
}, column) :
null;
}).filter(function (col) {
return col !== null;
});
this._buildItemsHtml();
},
/**
* Html outer element of the widget (if any).
*
* @property html
* @type HTMLElement
* @default null
* @readOnly
*/
getHtml: function () {
return this.__html.container;
},
/**
* The type of the container DOM element which will be created during the
* render process.
*
* @property containerElement
* @type String
* @default "ul"
*/
getContainerElement: function () {
return this.$data.containerElement;
},
setContainerElement: function (containerElement) {
this.$data.containerElement = containerElement;
},
/**
* The type of the items DOM elements which will be created during the
* render process.
*
* @property itemElement
* @type String
* @default "li"
*/
getItemElement: function () {
return this.$data.itemElement;
},
setItemElement: function (itemElement) {
this.$data.itemElement = itemElement;
},
/**
* The type of the columns DOM elements which will be created during the
* render process.
*
* @property columnElement
* @type String
* @default "span"
*/
getColumnElement: function () {
return this.$data.columnElement;
},
setColumnElement: function (columnElement) {
this.$data.columnElement = columnElement;
},
/**
* The list of identifiers wich will be added to every generated elements
* of the widget as classnames.
*
* @property identifiers
* @type Array
* @default []
* @private
*/
_addIdentifier: function (identifier) {
if (!identifier) {
return;
}
if (!this.$data._identifiers) {
this.$data._identifiers = [identifier];
} else if (this.$data._identifiers.indexOf(identifier) === -1) {
this.$data._identifiers.push(identifier);
}
},
//////////////////////////////////////////
// Methods //
//////////////////////////////////////////
// ====== Public methods ======
/**
* Destroy the widget.
*
* @method destroy
*/
destroy: function () {
this._empty();
this.$super();
},
/**
* Selects the item(s) at given indexes.
*
* @method selectItems
* @param {...Number|Number[]} indexes
*/
selectItems: function () {
lodash.chain(arguments)
.map()
.flatten()
.uniq()
.value()
.forEach(function (item) {
if (typeof(item) === "number") {
item = this._getItemByIndex(item);
}
if (item) {
this._selectItem(item, true);
}
}.bind(this));
},
/**
* Unselects the item(s) at given indexes.
*
* @method unselectItems
* @param {...Number|Number[]} indexes
*/
unselectItems: function () {
lodash.chain(arguments)
.map()
.flatten()
.uniq()
.value()
.forEach(function (item) {
if (typeof(item) === "number") {
item = this._getItemByIndex(item);
}
if (item) {
this._unselectItem(item, true);
}
}.bind(this));
},
// ====== Private methods ======
/**
* Destroy all children of the layout
*
* @method _empty
* @private
*/
_empty: function () {
var children = this._getChildren();
for (var i = 0 ; i < children.length ; i++) {
if (children[i]) {
children[i].destroy();
}
}
this.$data._childrenNames = [];
},
/**
* Layout children widgets.
*
* @method _getChildren
* @private
* @return {Array} the childen widget
*/
_getChildren: function () {
var children = [];
var widget;
for (var i = 0 ; i < this.$data._childrenNames.length ; i++) {
widget = Widget.getWidget(this.$data._childrenNames[i]);
if (widget instanceof Widget) {
children.push(widget);
}
}
return children;
},
/**
* Returns the item at a given index.
*
* @method _getItemByIndex
* @private
* @param {Number} index
* @return {Object} the item
*/
_getItemByIndex: function (index) {
return lodash.find(this.items, function (item) {
return item.index === index;
});
},
/**
* Build the widget HTML.
*
* @method _buildHtml
* @private
*/
_buildHtml: function () {
this._buildContainerHtml();
},
/**
* Build the widget container HTML.
*
* @method _buildContainerHtml
* @private
*/
_buildContainerHtml: function () {
this.__html.container = document.createElement(this.containerElement);
this.__html.container.className = "photonui-widget";
this._addIdentifiersClasses(this.__html.container);
this._addIdentifiersClasses(this.__html.container, "container");
},
/**
* Build the items list HTML.
*
* @method _buildItemsHtml
* @private
*/
_buildItemsHtml: function () {
if (this._lockItemsUpdate) {
return;
}
Helpers.cleanNode(this.__html.container);
this.$data._childrenNames = [];
if (this.$data.items) {
var fragment = document.createDocumentFragment();
this.$data.items.forEach(function (item) {
var itemNode = this._renderItem(item);
if (this.$data.dragAndDroppable) {
itemNode.setAttribute("draggable", true);
}
item.node = itemNode;
fragment.appendChild(itemNode);
}.bind(this));
this.__html.container.appendChild(fragment);
}
},
/**
* Renders a given item.
*
* @method _renderItem
* @private
* @param {Object} item
* @return {Element} the rendered item
*/
_renderItem: function (item) {
var node = document.createElement(this.itemElement);
node.className = "photonui-dataview-item";
node.setAttribute("data-photonui-dataview-item-index", item.index);
this._addIdentifiersClasses(node, "item");
if (this.customWidgetFormater && typeof(this.customWidgetFormater) === "function") {
var widget = this.customWidgetFormater.call(this, item.value);
if (widget && widget instanceof Widget) {
this.$data._childrenNames.push(widget.name);
node.appendChild(widget.getHtml());
return node;
}
}
return this._renderItemInner(node, item);
},
/**
* Renders all the columns of a given item.
*
* @method _renderItemInner
* @private
* @param {Element} itemNode the container element of the item
* @param {Object} item the rendered item
* @return {Element} the rendered item
*/
_renderItemInner: function (itemNode, item) {
if (this.$data.columns) {
this.$data.columns.forEach(function (column) {
var content = typeof(column.value) === "string" ? lodash.get(item.value, column.value) :
typeof(column.value) === "function" ? column.value.call(this, item.value) :
null;
itemNode.appendChild(this._renderColumn(content, column.id, column.rawHtml));
}.bind(this));
}
return itemNode;
},
/**
* Renders a given column.
*
* @method _renderColumn
* @private
* @param {photonui.Widget|String} content the content of the column
* @param {String} columnId the identifier of the column
* @return {Element} the rendered column
*/
_renderColumn: function (content, columnId, rawHtml) {
var node = document.createElement(this.columnElement);
this._addIdentifiersClasses(node, "column");
if (columnId !== "__generated__") {
this._addIdentifiersClasses(node, "column-" + columnId);
}
if (content instanceof Widget) {
this.$data._childrenNames.push(content.name);
node.appendChild(content.getHtml());
} else if (rawHtml) {
node.innerHTML = content || "";
} else {
node.textContent = content || "";
}
return node;
},
/**
* Generate the list of columns.
*
* @method _generateColumns
* @private
*/
_generateColumns: function () {
var keys = [];
if (this.$data.items) {
this.$data.items.forEach(function (item) {
if (typeof(item.value) === "object") {
Object.keys(item.value).forEach(function (key) {
if (keys.indexOf(key) === -1) {
keys.push(key);
}
});
}
});
this.$data.columns = keys.map(function (key) {
return {
value: key,
id: key,
};
});
this._buildItemsHtml();
}
},
/**
* Adds classes defined by the identifiers property to a given element, with
* a given suffix.
*
* @method _addIdentifiersClasses
* @private
* @param {Element} node the node
* @param {String} suffix the suffix of the classes
*/
_addIdentifiersClasses: function (node, suffix) {
if (this.$data._identifiers) {
this.$data._identifiers.forEach(function (identifier) {
node.classList.add(
suffix ?
"photonui-" + identifier + "-" + suffix
.replace(/[^a-zA-Z0-9]+/gi, "-")
.replace(/(^[^a-zA-Z0-9]|[^a-zA-Z0-9]$)/gi, "")
.toLowerCase() :
"photonui-" + identifier
);
});
}
},
/**
* Selects an item.
*
* @method _selectItem
* @private
* @param {Object} item the item
*/
_selectItem: function (item, preventEvent) {
item.selected = true;
item.node.classList.add("selected");
if (!preventEvent) {
this._callCallbacks("item-select", [item]);
}
},
/**
* Unselects an item.
*
* @method _unselectItem
* @private
* @param {Object} item the item
*/
_unselectItem: function (item, preventEvent) {
item.selected = false;
item.node.classList.remove("selected");
if (!preventEvent) {
this._callCallbacks("item-unselect", [item]);
}
},
/**
* Selects all items from the current selection to a given item.
*
* @method _selectItemsTo
* @private
* @param {Object} item the item
*/
_selectItemsTo: function (item) {
this.unselectAllItems();
if (this._initialSelectionItemIndex < item.index) {
for (var i = this._initialSelectionItemIndex; i <= item.index; i++) {
this._selectItem(this.items[i]);
}
} else {
for (var j = this._initialSelectionItemIndex; j >= item.index; j--) {
this._selectItem(this.items[j]);
}
}
},
/**
* Unselects all items.
*
* @method unselectAllItems
* @private
*/
unselectAllItems: function () {
this.getSelectedItems().forEach(function (item) {
this._unselectItem(item);
}.bind(this));
},
/**
* Gets an item of the collection from a given item DOM element.
*
* @method _getItemFromNode
* @private
* @param {Element} itemNode the item DOM element
* @return {Object} the item
*/
_getItemFromNode: function (itemNode) {
var index = itemNode.getAttribute("data-photonui-dataview-item-index");
return index ? this.$data.items[parseInt(index, 10)] : null;
},
/**
* Moves the item at a givent index to another given index. Rebuilds the
* dataview.
*
* @method _moveItem
* @private
* @param {Number} itemIndex the index of the item to move
* @param {Number} destinationIndex the destination index
*/
_moveItem: function (itemIndex, destinationIndex) {
this.items.splice(destinationIndex, 0, this.items.splice(itemIndex, 1)[0]);
this.setItems(
this.items.map(function (item) {
return item.value;
})
);
},
/**
* Handle item click events.
*
* @method _handleClick
* @private
* @param {Object} item the item
* @param {Object} modifiers the modifiers states
* @param {Object} modifiers.ctrl
* @param {Object} modifiers.shift
*/
_handleClick: function (clickedItem, modifiers) {
if (this.selectable) {
if (this.multiSelectable) {
// No item is selected, select clicked item
if (this.selectedItems.length === 0) {
this._selectItem(clickedItem);
this._initialSelectionItemIndex = clickedItem.index;
} else {
if (this.allowShiftSelect && modifiers.shift) {
this._selectItemsTo(clickedItem);
} else if (this.multiSelectWithCtrl) {
if (modifiers.ctrl) {
if (clickedItem.selected) {
this._unselectItem(clickedItem);
} else {
this._selectItem(clickedItem);
}
} else {
this.unselectAllItems();
this._selectItem(clickedItem);
this._initialSelectionItemIndex = clickedItem.index;
}
} else {
if (clickedItem.selected) {
this._unselectItem(clickedItem);
} else {
this._selectItem(clickedItem);
}
}
}
} else {
if (modifiers.ctrl && clickedItem.selected) {
this._unselectItem(clickedItem);
} else {
this.unselectAllItems();
this._selectItem(clickedItem);
}
}
}
},
/**
* Generates a placeholder item element.
*
* @method _generatePlaceholderElement
* @private
* @return {Element} the placeholder item element
*/
_generatePlaceholderElement: function (itemNode) {
var placeholderElement = document.createElement(this.$data.itemElement);
this.$data.columns.forEach(function () {
var column = document.createElement(this.$data.columnElement);
placeholderElement.appendChild(column);
}.bind(this));
placeholderElement.style.height = itemNode.offsetHeight + "px";
placeholderElement.style.width = itemNode.offsetWidth + "px";
placeholderElement.style.margin = itemNode.style.margin;
this._addIdentifiersClasses(placeholderElement, "item-placeholder");
return placeholderElement;
},
//////////////////////////////////////////
// Internal Events Callbacks //
//////////////////////////////////////////
/**
* Called when an element is clicked.
*
* @method __onClick
* @private
* @param {Object} event the click event
*/
__onClick: function (event) {
var clickedItemNode = Helpers.getClosest(event.target, ".photonui-dataview-item");
event.stopPropagation();
if (clickedItemNode) {
var item = this._getItemFromNode(clickedItemNode);
if (!item) {
return;
}
this.__onItemClick(event, this._getItemFromNode(clickedItemNode));
} else if (this.unselectOnOutsideClick){
this.unselectAllItems();
}
},
/**
* Called when an item is clicked.
*
* @method __onItemClick
* @private
* @param {Object} event the click event
* @param {item} item the clicked item
*/
__onItemClick: function (event, item) {
this._handleClick(item, {
shift: event.shiftKey,
ctrl: event.ctrlKey,
});
this._callCallbacks("item-click", [item, event]);
event.stopPropagation();
},
/**
* Called when an item is dragged.
*
* @method __onDragStart
* @private
* @param {Object} event
*/
__onDragStart: function (event) {
if (!this.$data.dragAndDroppable) {
return;
}
event.dataTransfer.setData("text", "");
var draggedItemNode = Helpers.getClosest(event.target, ".photonui-dataview-item");
if (draggedItemNode) {
this.$data._draggedItem = this._getItemFromNode(draggedItemNode);
this.unselectAllItems();
this.$data._placeholderElement = this._generatePlaceholderElement(draggedItemNode);
this.$data._lastPlaceholderIndex = this.$data._draggedItem.index;
lodash.defer(function () {
this.__html.container.insertBefore(this.$data._placeholderElement, draggedItemNode);
this.$data._draggedItem.node.style.display = "none";
}.bind(this));
}
event.stopPropagation();
},
/**
* Called when a dragged item enters into another element.
*
* @method __onDragEnter
* @private
* @param {Object} event
*/
__onDragEnter: function (event) {
if (!this.$data.dragAndDroppable) {
return;
}
var enteredItemNode = Helpers.getClosest(event.target, ".photonui-dataview-item");
if (enteredItemNode) {
var enteredIndex = this._getItemFromNode(enteredItemNode).index;
var placeholderIndex =
enteredIndex + (this.$data._lastPlaceholderIndex <= enteredIndex ? 1 : 0);
var nextItem = this._getItemByIndex(placeholderIndex);
this.$data._lastPlaceholderIndex = placeholderIndex;
if (nextItem) {
this.__html.container.insertBefore(this.$data._placeholderElement, nextItem.node);
} else {
this.__html.container.appendChild(this.$data._placeholderElement);
}
}
event.stopPropagation();
return false;
},
/**
* Called when a item drag has ended.
*
* @method __onDragEnd
* @private
* @param {Object} event
*/
__onDragEnd: function (event) {
if (!this.$data.dragAndDroppable) {
return;
}
this.$data._draggedItem.node.style.display = "";
if (this.$data._placeholderElement.parentNode === this.__html.container) {
var destIndex = this.$data._lastPlaceholderIndex > this.$data._draggedItem.index ?
this.$data._lastPlaceholderIndex - 1 :
this.$data._lastPlaceholderIndex;
this._moveItem(this.$data._draggedItem.index, destIndex);
this._callCallbacks("item-sort", [this.$data._draggedItem, destIndex, event]);
}
this.$data._placeholderElement = null;
this.$data._draggedItem = null;
event.stopPropagation();
},
/**
* Called when a item is dragged (fix for firefox).
*
* @method __onDragOver
* @private
* @param {Object} event
*/
__onDragOver: function (event) {
if (!this.$data.dragAndDroppable) {
return;
}
event.preventDefault();
event.stopPropagation();
},
/**
* Called when a item is dropped (fix for firefox).
*
* @method __onDrop
* @private
* @param {Object} event
*/
__onDrop: function (event) {
if (!this.$data.dragAndDroppable) {
return;
}
event.preventDefault();
event.stopPropagation();
},
/**
* Called when the locale is changed.
*
* @method __onLocaleChanged
* @private
*/
__onLocaleChanged: function () {
this._buildItemsHtml();
},
});
module.exports = DataView;