import DataModel from './DataModel.js'
import DataObject from './DataObject.js'
import EventHandler from './EventHandler.js'
/**
An ordered list of DataModel instances, either locally or [fetched](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) from a service.
There are various ways to sort the collection, from {@link DataCollection.sort} to {@link DataCollection.keepSortedByField}.
The comparator functions used in sorting should use the same return values (e.g. -1, 0, 1) as the [Array.prototype.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) comparators.
@example <caption>Constructed with data:</caption>
* this.collection = new DataCollection([
* { id: 0, title: 'first model'},
* { id: 1, title: 'second model'},
* { id: 3, title: 'third model'}
* ])
@example <caption>A custom class that is populated from a service:</caption>
* class ExampleCollection extends Collection {
* get url() { return '/api/examples' }
* }
* this.collection = new ExampleCollection()
* this.collection.fetch().then(() => { ... }).catch((err) => { ... })
*/
const DataCollection = class extends DataObject {
/**
@param {Array<Object>} [data=[]] An array of data objects that are loaded into {@link DataModel}s
@param {Object} [options={}]
@param {class} [options.dataObject=DataModel] the `class` of a `DataObject` type to use to wrap each data item in this collection
*/
constructor(data = [], options = {}) {
super(options)
if (data == null) data = []
/** @type {Array<DataObject>} */
this.dataObjects = []
this._inReset = false
this._inAddBatch = false
this._boundRelayListener = this._relayListener.bind(this)
for (const datum of data) {
this.add(this._generateDataObject(datum))
}
}
cleanup() {
super.cleanup()
for (const obj of this.dataObjects) {
obj.removeListener(this._boundRelayListener)
}
this.dataObjects.length = 0
}
/**
@return {string} a JSON-formatted version of this collection's data
*/
stringify() {
throw new Error('Extending DataCollections must implement their own stringify')
}
/**
@param {int} index
@return {DataObject} the DataObject at `index` in the internal list
@throws {Error} throw when index is out of range
*/
at(index) {
if (index < 0 || index > this.dataObjects.length - 1) {
throw new Error(`Index out of range: ${index}`)
}
return this.dataObjects[index]
}
/**
@param {Object} data - the data used to create a new DataModel in this collection
@param {Object} [options={}] - the options passed into {@link DataModel.constructor}
@return {Promise<DataModel,Error>}
*/
create(data, options = {}) {
// Creates an child instance and POSTs it to the collection
return new Promise(
function (resolve, reject) {
const fetchOptions = Object.assign(options, this.fetchOptions)
fetchOptions.method = 'post'
fetchOptions.body = JSON.stringify(data)
this._innerFetch(this.url, fetchOptions)
.then((response) => {
if (response.status != 200) {
throw new Error('Create failed with status ' + response.status)
}
return response.json()
})
.then((data) => {
const dataObject = this._generateDataObject(data)
this.add(dataObject)
resolve(dataObject)
})
.catch(reject)
}.bind(this)
)
}
/**
@param {DataObject} dataObject
@return {DataCollection} - returns `this` for easy chaining
*/
add(dataObject) {
if (dataObject instanceof DataObject == false) {
dataObject = this._generateDataObject(dataObject)
}
if (this.dataObjects.indexOf(dataObject) !== -1) {
// TODO stop using indexOf because equality doesn't work
return
}
this.dataObjects.push(dataObject)
dataObject.collection = this
this.trigger(DataCollection.AddedEvent, this, dataObject)
if (this._comparator && this._inReset == false && this._inAddBatch == false) {
this.sort(this._comparator)
}
dataObject.addListener(EventHandler.ALL_EVENTS, this._boundRelayListener)
}
_relayListener(...params) {
this.trigger(...params)
}
/**
Add an array of DataObjects to the end of the collection
@param {Array<DataObject>} dataObjects
*/
addBatch(dataObjects) {
this._inAddBatch = true
for (let dataObject in dataObjects) {
if (dataObject instanceof DataObject == false) {
dataObject = this._generateDataObject(dataObject)
}
this.add(dataObject)
}
this._inAddBatch = false
}
/**
@param {DataObject} dataObject
@return {number} // the positive index integer or -1
*/
indexOf(dataObject) {
for (let i = 0; i < this.dataObjects.length; i++) {
if (this.dataObjects[i].equals(dataObject)) {
return i
}
}
return -1
}
/**
Find the first DataModel with a certain field value.
@example
* this.collection = new DataCollection(...)
* this.collection.fetch().then(() => {
* console.log('DataModel with id 42:', this.collection.firstByField('id', 42)
* })
@param {string} dataField - The name of the DataModel field in which to look
@param {*} value - The value of the field to match using `===`
@return {DataObject|null} The first matching DataModel or null if there is no match
*/
firstByField(dataField, value) {
for (const model of this) {
if (model.get(dataField) === value) {
return model
}
}
return null
}
/**
@param {DataObject} dataObject
@return {DataCollection} returns `this` (the collection) for easy chaining
*/
remove(dataObject) {
const index = this.indexOf(dataObject)
if (index === -1) {
return this
}
this.dataObjects[index].removeListener(EventHandler.ALL_EVENTS, this._boundRelayListener)
this.dataObjects.splice(index, 1)
dataObject.collection = null
this.trigger(DataCollection.RemovedEvent, this, dataObject)
return this
}
/**
Reset the state of the collection.
@param {Array<Object>} data - Used like the `data` parameter of the contructor to reset the state of the collection
*/
reset(data) {
this._inReset = true
for (const obj of this.dataObjects.slice()) {
this.remove(obj)
}
for (const datum of data) {
this.add(this._generateDataObject(datum))
}
this._inReset = false
if (this._comparator) {
this.sort(this._comparator)
}
this.trigger(DataObject.ResetEvent, this)
}
/**
@callback DataCollection~comparator
@param {DataObject} dataObject1
@param {DataObject} dataObject2
@return {integer}
*/
/**
Rearranges the order of the Collection using a specific sorting algorithm
@param {DataCollection~comparator} comparator
*/
sort(comparator = DataCollection.defaultComparator) {
this.dataObjects.sort(comparator)
this.trigger(DataCollection.SortedEvent, this)
}
/**
@param {string} attributeName
@param {DataCollection~comparator} [comparator=DataCollection.defaultComparator]
*/
sortByAttribute(attributeName, comparator = DataCollection.defaultComparator) {
this.sort((obj1, obj2) => {
return comparator(obj1.get(attributeName), obj2.get(attributeName))
})
}
/**
@param {string} dataField
@param {DataCollection~comparator} [comparator=DataCollection.defaultComparator]
*/
keepSortedByField(dataField, comparator = DataCollection.defaultComparator) {
this._comparator = (obj1, obj2) => {
return comparator(obj1.get(dataField), obj2.get(dataField))
}
this.addListener(DataCollection.ChangedEventPrefix + dataField, () => {
if (this._comparator && this._inReset == false && this._inAddBatch == false) {
this.sort(this._comparator)
}
})
}
/** @return {Iterator<DataObject>} */
*[Symbol.iterator]() {
for (const obj of this.dataObjects) {
yield obj
}
}
/**
The number of data items in this collection
@type {int}
*/
get length() {
return this.dataObjects.length
}
_generateDataObject(data) {
const options = { collection: this }
let dataObj
if (this.options.dataObject) {
dataObj = new this.options.dataObject(data, options)
} else {
dataObj = new DataModel(data, options)
}
dataObj._new = false
return dataObj
}
}
DataCollection.ChangedEventPrefix = 'changed:'
DataCollection.AddedEvent = Symbol('dc-added')
DataCollection.RemovedEvent = Symbol('dc-removed')
DataCollection.SortedEvent = Symbol('dc-sorted')
DataCollection.defaultComparator = function (dataObject1, dataObject2) {
if (dataObject1 === dataObject2) return 0
if (typeof dataObject1.equals === 'function' && dataObject1.equals(dataObject2)) return 0
if (typeof dataObject1.get === 'function' && typeof dataObject2.get === 'function') {
const val1 = dataObject1.get('id', -1)
const val2 = dataObject2.get('id', -1)
if (val1 === val2) return 0
if (val1 < val2) return -1
return 1
}
if (dataObject1 < dataObject2) return -1
return 1
}
export default DataCollection
export { DataCollection }