import $ from '$qui/lib/jquery.module.js'
import Logger from '$qui/lib/logger.module.js'
import {gettext} from '$qui/base/i18n.js'
import {mix} from '$qui/base/mixwith.js'
import StockIcon from '$qui/icons/stock-icon.js'
import * as Lists from '$qui/lists/lists.js'
import {asap} from '$qui/utils/misc.js'
import * as ObjectUtils from '$qui/utils/object.js'
import {ProgressViewMixin} from '$qui/views/common-views/common-views.js'
import {StructuredViewMixin} from '$qui/views/common-views/common-views.js'
import ViewMixin from '$qui/views/view.js'
const logger = Logger.get('qui.lists.list')
/**
* A list view.
* @alias qui.lists.List
* @mixes qui.views.ViewMixin
* @mixes qui.views.commonviews.StructuredViewMixin
* @mixes qui.views.commonviews.ProgressViewMixin
*/
class List extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin) {
/**
* @constructs
* @param {qui.lists.ListItem[]} [initialItems] initial list items
* @param {Boolean} [searchEnabled] set to `true` to enable the search feature (defaults to `false`)
* @param {Boolean} [addEnabled] set to `true` to enable the add item feature (defaults to `false`)
* @param {String} [selectMode] one of:
* * {@link qui.lists.LIST_SELECT_MODE_DISABLED}
* * {@link qui.lists.LIST_SELECT_MODE_SINGLE} (default)
* * {@link qui.lists.LIST_SELECT_MODE_MULTIPLE}
* @param {Boolean} longPressMultipleSelection set to `true` to enable toggling between single and multiple select
* modes by long pressing items
* @param {...*} args parent class parameters
*/
constructor({
initialItems = null,
searchEnabled = false,
addEnabled = false,
selectMode = Lists.LIST_SELECT_MODE_SINGLE,
longPressMultipleSelection = false,
...args
} = {}) {
super(args)
this._items = initialItems || []
this._searchEnabled = searchEnabled
this._addEnabled = addEnabled
this._selectMode = selectMode
this._longPressMultipleSelection = longPressMultipleSelection
this._addElem = null
this._searchElem = null
this._filterInput = null
}
makeHTML() {
return $('<div></div>', {class: 'qui-list'})
}
initHTML(html) {
super.initHTML(html)
html.addClass(`select-mode-${this._selectMode}`)
}
init() {
super.init()
/* Set initial items */
if (this._items.length) {
this.setItems(this._items)
}
}
makeBody() {
let bodyDiv = $('<div></div>', {class: 'qui-list-body'})
if (this._searchEnabled) {
this._enableSearch(bodyDiv)
}
if (this._addEnabled) {
this._enableAdd(bodyDiv)
}
return bodyDiv
}
/* Items */
/**
* Return all items.
* @returns {qui.lists.ListItem[]}
*/
getItems() {
return this._items.slice()
}
/**
* Set the items of the list.
* @param {qui.lists.ListItem[]} items list items
*/
setItems(items) {
this._items.forEach(i => i.getHTML().remove())
items.forEach(i => this.prepareItem(i))
this._items = items
if (this._searchEnabled) {
this._applySearchFilter()
}
this._items.forEach(function (item) {
if (this._addElem) {
this._addElem.before(item.getHTML())
}
else {
this.getBody().append(item.getHTML())
}
}, this)
}
/**
* Update one item.
* @param {Number} index the index where to perform the update
* @param {qui.lists.ListItem} item the item to update
*/
setItem(index, item) {
this.prepareItem(item)
if (this._searchEnabled) {
this._applySearchFilter(item)
}
this._items[index].getHTML().replaceWith(item.getHTML())
this._items[index] = item
}
/**
* Add one item to the list.
* @param {Number} index the index where the item should be added; `-1` will add the item at the end
* @param {qui.lists.ListItem} item the item
*/
addItem(index, item) {
this.prepareItem(item)
if (this._searchEnabled) {
this._applySearchFilter(item)
}
if (index < 0 || !this._items.length) {
if (this._addElem) {
this._addElem.before(item.getHTML())
}
else {
this.getBody().append(item.getHTML())
}
this._items.push(item)
}
else {
this._items[index].getHTML().before(item.getHTML())
this._items.splice(index, 0, item)
}
}
/**
* Remove the item at a given index.
* @param {Number} index the index of the item to remove
* @returns {?qui.lists.ListItem} the removed item
*/
removeItemAt(index) {
if (this._items[index]) {
this._items[index].getHTML().remove()
}
return this._items.splice(index, 1)[0] || null
}
/**
* Remove a specific item.
* @param {qui.lists.ListItem} item the item to remove
* @returns {Boolean} `true` if item found and removed, `false` otherwise
*/
removeItem(item) {
return this.removeItems(i => i === item).length > 0
}
/**
* Remove all items that match a condition.
* @param {qui.lists.ListItemMatchFunc} matchFunc
* @returns {qui.lists.ListItem[]} the removed items
*/
removeItems(matchFunc) {
let removedItems = []
for (let i = 0; i < this._items.length; i++) {
if (matchFunc(this._items[i])) {
removedItems.push(this.removeItemAt(i--))
}
}
return removedItems
}
/**
* Prepare item to be part of this list.
* @param {qui.lists.ListItem} item
*/
prepareItem(item) {
item.setList(this)
let html = item.getHTML()
html.on('click', this._handleItemClick.bind(this, item))
html.longpress(this._handleLongPress.bind(this))
item.setSelectMode(this._selectMode)
}
_handleItemClick(item) {
/* Flag to prevent handling clicks on long press */
if (item._wasLongPressed) {
item._wasLongPressed = false
return
}
if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
return
}
let oldItems = this._items.filter(i => i.isSelected())
let newItems = []
let addedItems = []
let removedItems = []
if (this._selectMode === Lists.LIST_SELECT_MODE_MULTIPLE) {
/* In multi-selection mode, simply add/remove new item to/from selection */
if (oldItems.includes(item)) {
newItems = oldItems.filter(i => i !== item)
removedItems.push(item)
}
else {
newItems = oldItems.concat([item])
addedItems.push(item)
}
}
else { /* Assuming Lists.LIST_SELECT_MODE_SINGLE */
newItems.push(item)
removedItems = oldItems
addedItems.push(item)
}
if (ObjectUtils.deepEquals(oldItems, newItems)) {
return /* Selection unchanged */
}
let promise = this.onSelectionChange(oldItems, newItems) || Promise.resolve()
promise.then(function () {
try {
removedItems.forEach(i => i.setSelected(false))
addedItems.forEach(i => i.setSelected(true))
}
catch (e) {
logger.errorStack('setSelected failed', e)
}
}).catch(function (e) {
if (e == null) {
logger.debug('selection change rejected')
}
else {
throw e
}
})
}
_handleLongPress(item) {
if (!this._longPressMultipleSelection) {
return
}
// TODO: replace jQuery longpress plugin with a simple, more integrated long press event manager
if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
this.setSelectMode(Lists.LIST_SELECT_MODE_MULTIPLE)
let selectedItems = this.getSelectedItems()
if (!selectedItems.includes(item)) {
selectedItems.push(item)
this.setSelectedItems(selectedItems)
}
}
else if (this._selectMode === Lists.LIST_SELECT_MODE_MULTIPLE) {
this.setSelectMode(Lists.LIST_SELECT_MODE_SINGLE)
this.setSelectedItems([item])
}
item._wasLongPressed = true
}
/* Add feature */
/**
* Tell if the add feature is enabled
* @returns {Boolean}
*/
isAddEnabled() {
return this._addEnabled
}
/**
* Enable the search feature.
*/
enableAdd() {
if (this._addEnabled) {
return
}
this._addEnabled = true
this._enableAdd(this.getBody())
}
_enableAdd(element) {
this._addElem = this._makeAddElem()
element.append(this._addElem)
element.addClass('add-enabled')
}
/**
* Disable the add feature.
*/
disableAdd() {
if (!this._addEnabled) {
return
}
this._addEnabled = false
this._disableAdd()
}
_disableAdd() {
this._addElem.remove()
this._addElem = null
this.getBody().removeClass('add-enabled')
}
_makeAddElem() {
let addElem = $('<div></div>', {class: 'qui-base-button qui-list-child qui-list-add'})
let addIcon = $('<div></div>', {class: 'qui-icon'})
addElem.append(addIcon)
new StockIcon({name: 'plus', variant: 'interactive'}).applyTo(addIcon)
addElem.on('click', function () {
let promise = this.onAdd()
promise = promise || Promise.resolve()
promise.then(function () {
try {
this._items.forEach(i => i.setSelected(false))
}
catch (e) {
logger.errorStack('setSelected failed', e)
}
}.bind(this)).catch(function (e) {
if (e == null) {
logger.debug('add rejected')
}
else {
throw e
}
})
}.bind(this))
return addElem
}
/**
* Override this to define the behavior of the list when the add button is pressed.
* @returns {?Promise} an optional promise which, if rejected with no argument, will cancel adding
*/
onAdd() {
}
/* Search feature */
_makeSearchElem() {
let list = this
let searchElem = $('<div></div>', {class: 'qui-list-child qui-list-search'})
let searchInput = $('<input>', {type: 'text'})
searchInput.attr('placeholder', gettext('search...'))
let searchWrapper = $('<div></div>', {class: 'qui-list-search-wrapper'})
searchWrapper.append(searchInput)
searchElem.append(searchWrapper)
let searchIcon = $('<div></div>', {class: 'qui-icon'})
new StockIcon({
name: 'magnifier', variant: 'interactive',
activeName: 'magnifier', activeVariant: 'interactive',
focusedName: 'close', focusedVariant: 'background'
}).applyTo(searchIcon)
searchWrapper.append(searchIcon)
searchInput.on('keydown', function (e) {
if (e.which === 27) {
if (list._filterInput.val().length) {
list._clearSearch()
}
else {
list._filterInput.blur()
}
}
})
searchInput.on('keyup', function () {
list._applySearchFilter()
})
searchInput.on('paste', function () {
list._applySearchFilter()
})
searchIcon.on('pointerdown', function () {
if (searchInput.is(':focus')) {
searchInput.blur()
list._clearSearch()
return false
}
else {
asap(function () {
searchInput.focus()
})
}
})
return searchElem
}
_applySearchFilter(item = null) {
let searchText = this._filterInput.val().trim().toLowerCase()
searchText = searchText.replace(/\s\s+/g, ' ')
let searchTextParts = searchText.split(' ')
/* If item is specified, apply filtering only to given item */
if (item) {
if (!this._filterInput) {
if (item.isHidden()) {
item.show()
}
}
else {
let match = searchTextParts.every(s => item.isMatch(s))
if (match) {
if (item.isHidden()) {
item.show()
}
}
else {
if (!item.isHidden()) {
item.hide()
}
}
}
return
}
if (!this._filterInput) {
this._items.filter(i => i.isHidden()).forEach(i => i.show())
return
}
this._items.forEach(function (item) {
let match = searchTextParts.every(s => item.isMatch(s))
if (match) {
if (item.isHidden()) {
item.show()
}
}
else {
if (!item.isHidden()) {
item.hide()
}
}
})
}
_clearSearch() {
this._filterInput.val('')
this._applySearchFilter()
}
/**
* Tell if the search feature is enabled
* @returns {Boolean}
*/
isSearchEnabled() {
return this._searchEnabled
}
/**
* Enable the search feature.
*/
enableSearch() {
if (this._searchEnabled) {
return
}
this._searchEnabled = true
this._enableSearch(this.getBody())
}
_enableSearch(element) {
this._searchElem = this._makeSearchElem()
this._filterInput = this._searchElem.find('input[type=text]')
element.prepend(this._searchElem)
element.addClass('search-enabled')
}
/**
* Disable the search feature.
*/
disableSearch() {
if (!this._searchEnabled) {
return
}
this._searchEnabled = false
this._disableSearch()
}
_disableSearch() {
this._searchElem.remove()
this._searchElem = null
this._filterInput = null
this.getBody().removeClass('search-enabled')
this._applySearchFilter()
}
/* Selection */
/**
* Set selection mode.
* @param {String} selectMode one of:
* * {@link qui.lists.LIST_SELECT_MODE_DISABLED}
* * {@link qui.lists.LIST_SELECT_MODE_SINGLE} (default)
* * {@link qui.lists.LIST_SELECT_MODE_MULTIPLE}
*/
setSelectMode(selectMode) {
this._selectMode = selectMode
let selectedItems = this._items.filter(i => i.isSelected())
if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
selectedItems.forEach(i => i.setSelected(false))
}
else if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
if (selectedItems.length > 1) {
selectedItems.slice(1).forEach(i => i.setSelected(false))
}
}
/* Update HTML class according to new select mode */
let html = this.getHTML()
html.removeClass([
Lists.LIST_SELECT_MODE_DISABLED,
Lists.LIST_SELECT_MODE_SINGLE,
Lists.LIST_SELECT_MODE_MULTIPLE
].map(m => `select-mode-${m}`).join(' '))
html.addClass(`select-mode-${this._selectMode}`)
/* Update items select mode */
this.getItems().forEach(i => i.setSelectMode(this._selectMode))
}
/**
* Return the currently selected items.
* @returns {qui.lists.ListItem[]}
*/
getSelectedItems() {
return this._items.filter(i => i.isSelected())
}
/**
* Update current selection.
* @param {qui.lists.ListItem[]} items the list of new items to select; empty list clears selection
*/
setSelectedItems(items) {
if (this._selectMode === Lists.LIST_SELECT_MODE_DISABLED) {
return
}
else if (this._selectMode === Lists.LIST_SELECT_MODE_SINGLE) {
if (items.length > 1) {
items = items.slice(0, 1) /* Keep only first element in single selection mode */
}
}
let selectedItems = this._items.filter(i => i.isSelected())
/* Remove selection from items no longer selected */
selectedItems.filter(i => !items.includes(i)).forEach(i => i.setSelected(false))
/* Add selection to newly selected items */
items.filter(i => !selectedItems.includes(i)).forEach(i => i.setSelected(true))
}
/**
* Called when the current selection is changed by user.
* @param {qui.lists.ListItem[]} oldItems the previously selected items (can be empty)
* @param {qui.lists.ListItem[]} newItems the new selected items (can be empty)
* @returns {?Promise} an optional promise which, if rejected with no argument, will cancel the selection change
*/
onSelectionChange(oldItems, newItems) {
}
}
export default List