import dom from '../../DOM.js'
import Component from '../../Component.js'
import { lt, ld } from '../../Localizer.js'
import DataObject from '../../DataObject.js'
import DataCollection from '../../DataCollection.js'
import LabelComponent from '../atoms/LabelComponent.js'
/**
DefaultItemComponent is used by {@link CollectionComponent} if no 'itemComponent' option is passed.
The only thing in this {@link Component} is a {@link LabelComponent} with the first field that exists of: 'title', 'name', or 'text'.
You'll usually want to pass an `itemComponent` option to `CollectionComponent` to present a custom item.
*/
const DefaultItemComponent = class extends Component {
constructor(dataObject = null, options = {}) {
super(dataObject, Object.assign({ dom: dom.li() }, options))
if (dataObject instanceof DataObject === false) throw new Error('DefaultItemComponent requires a dataObject')
this.addClass('default-item-component')
this.setName('DefaultItemComponent')
const itemName = dataObject.getFirst('title', 'name', 'text') || new String(dataObject)
this._labelComponent = new LabelComponent(undefined, { text: lt('Item: ') + itemName }).appendTo(this)
}
}
/**
CollectionComponent provides a generic list UI for {@link DataCollection}s.
@todo Add rearrangement via drag and drop
@todo Add pagination support once {@link DataCollection} supports it
@example <caption>Use the DefaultItemComponent</caption>
* const myCollection = new DataCollection(...snip...)
* const collectionComponent = new CollectionComponent(myCollection)
@example <caption>Use a custom item Component</caption>
* const myCollection = new DataCollection(...snip...)
* class CustomItemComponent extends Component {
* constructor(dataObject=null, options={}) {
* // Set up your item UI here
* }
* }
* const collectionComponent = new CollectionComponent(myCollection, {
* itemComponent: CustomItemComponent, // the class, not an instance
* itemOptions: { someKey: 'someValue' } // passed as options to item component constructors
* })
@example <caption>Responding to clicks on items</caption>
* const myCollection = new DataCollection(...snip...)
* const collectionComponent = new CollectionComponent(myCollection, {
* onClick: (dataObject) => {
* // Do something with the DataObject (probably a DataModel) whose item has been clicked
* }
* })
*/
const CollectionComponent = class extends Component {
/**
@callback CollectionComponent~onClick
@param {DataObject} obj
*/
/**
@param {DataObject} [dataObject=null]
@param {Object} [options={}]
@param {Component} [options.itemComponent=DefaultItemComponent] a Component class used to render each item in this list
@param {Object} [options.itemOptions] a set of options to pass to each item Component
@param {CollectionComponent~onClick} [options.onClick] a function to call with the dataObject whose item Component is clicked
*/
constructor(dataObject = null, options = {}) {
super(dataObject, Object.assign({ dom: dom.ul() }, options))
this.addClass('collection-component')
this.setName('CollectionComponent')
if (dataObject instanceof DataCollection === false) throw 'CollectionComponent requires a DataCollection dataObject'
this._inGroupChange = false // True while resetting or other group change
this._dataObjectComponents = new Map() // dataObject.id -> Component
this.listenTo(DataObject.ResetEvent, this.dataObject, (eventName, target) => {
this._handleCollectionReset(target)
})
this.listenTo(DataCollection.AddedEvent, this.dataObject, (eventName, collection, dataObject) => {
this._handleCollectionAdded(collection, dataObject)
})
this.listenTo(DataCollection.RemovedEvent, this.dataObject, (eventName, collection, dataObject) => {
this._handleCollectionRemoved(collection, dataObject)
})
if (this.dataObject.isNew === false) {
this._handleCollectionReset(this.dataObject)
} else if (this.dataObject.length > 0) {
this._inGroupChange = true
for (const dataObject of this.dataObject) {
this._add(this._createItemComponent(dataObject), false)
}
this._inGroupChange = false
this.trigger(CollectionComponent.Reset, this)
}
}
/**
@param {int} index
@return {?Component} indexed `Component` or `null` if index is out of bounds
*/
at(index) {
if (index < 0) return null
if (index >= this.children.length) return null
return this.children.item(index).component
}
/**
@param {DataObject} dataObject
@return {?Component}
*/
componentForDataObject(dataObject) {
return this._dataObjectComponents.get(dataObject.get('id'))
}
/**
@callback CollectionComponent~filter
@param {DataObject} model
@return {boolean} true if the DataObject's item Component should be shown
*/
/**
Call this to change whether item Components are show or hidden based on the result of a function.
A common pattern is to listen to Collection events like fetched or reset and then re-run a filter.
@example
const collectionComponent = new CollectionComponent(...snip...)
// Only show items for DataModels with an 'active' field that is true
collectionComponent.filter((dataObject) => {
// Assuming that the dataObject is a DataModel and not a sub-DataCollection
return dataObject.get('active') === true
})
// Show all items by passing a null filter
collectionComponent.filter(null)
@param {CollectionComponent~filter} filterFn
*/
filter(filterFn = null) {
for (const itemComponent of this._dataObjectComponents.values()) {
let display
if (typeof filterFn === 'function') {
display = filterFn(itemComponent.dataObject)
} else {
display = true
}
if (display) {
itemComponent.show()
} else {
itemComponent.hide()
}
}
}
_handleCollectionAdded(collection, dataObject) {
this._add(this._createItemComponent(dataObject))
}
_handleCollectionRemoved(collection, dataObject) {
const component = this.componentForDataObject(dataObject)
if (component) {
this._remove(component)
}
}
_handleCollectionReset(target) {
if (target !== this.dataObject) return // It was a reset for an item in the collection, not the collection itself
this._inGroupChange = true
this.trigger(CollectionComponent.Resetting, this)
for (const itemComponent of this._dataObjectComponents.values()) {
this._remove(itemComponent)
}
this._dataObjectComponents.clear()
for (const dataObject of this.dataObject) {
this._add(this._createItemComponent(dataObject))
}
this._inGroupChange = false
this.trigger(CollectionComponent.Reset, this)
}
_handleItemClick(ev, itemComponent) {
if (this.options.onClick) {
ev.preventDefault()
this.options.onClick(itemComponent.dataObject)
}
}
_add(itemComponent, checkForDoubles = true) {
/** @todo this assumes the PK is called 'id' */
if (checkForDoubles && this._dataObjectComponents.get(itemComponent.dataObject.get('id'))) {
// Already have it, ignore the add
return
}
this._dataObjectComponents.set(itemComponent.dataObject.get('id'), itemComponent)
this.append(itemComponent)
if (this.options.onClick) {
this.listenTo('click', itemComponent.dom, (ev) => {
this._handleItemClick(ev, itemComponent)
})
}
this.listenTo('deleted', itemComponent.dataObject, this._handleDeleted.bind(this), true)
}
_remove(itemComponent) {
this._dataObjectComponents.delete(itemComponent.dataObject.get('id'))
this.removeComponent(itemComponent)
itemComponent.removeListener('click', null)
itemComponent.cleanup()
}
_handleDeleted(eventName, dataObject, error) {
if (error) return
const component = this._dataObjectComponents.get(dataObject.get('id'))
if (component) {
this._remove(component)
}
}
_createItemComponent(itemDataObject) {
let options
if (this.options.itemOptions) {
options = Object.assign({}, this.options.itemOptions)
} else {
options = {}
}
let itemComponent
if (this.options.itemComponent) {
itemComponent = new this.options.itemComponent(itemDataObject, options)
} else {
itemComponent = new DefaultItemComponent(itemDataObject, options)
}
itemComponent.addClass('collection-item')
return itemComponent
}
}
CollectionComponent.Resetting = 'collection-component-resetting'
CollectionComponent.Reset = 'collection-component-reset'
export default CollectionComponent
export { CollectionComponent, DefaultItemComponent }