Source: DataObject.js

import EventHandler from './EventHandler.js'

/**
`DataObject` is the abstract base class for {@link DataModel} and {@link DataCollection}.

This holds the event handling and the generic function of fetching data from a remote service.
*/
const DataObject = class extends EventHandler {
	/**
	@param {Object} [options={}] Not used by DataObject but helpful for extending classes
	*/
	constructor(options = {}) {
		super()
		/** @type {Object<string,*>} */
		this.options = options
		this._new = true // True until the first fetch returns, regardless of http status
		/** True after {@link DataObject.cleanup} has been called. */
		this.cleanedUp = false
	}

	/**
	When models are no longer needed `cleanup` should be called to release resources like event listeners.

	@return {DataObject} returns `this` for easy chaining
	*/
	cleanup() {
		if (this.cleanedUp) return
		this.cleanedUp = true
		super.cleanup()
		return this
	}

	/**
	True until a fetch (even a failed fetch) returns	

	@return {boolean}
	*/
	get isNew() {
		return this._new
	}

	/**
	The URL (relative or full) as a string for the endpoint used by {@link DataObject.fetch}.
	@return {string} 
	*/
	get url() {
		throw new Error('Extending classes must implement url()')
	}

	/** Clear out old data and set it to data, should trigger a 'reset' event */
	reset(data = {}) {
		throw new Error('Extending classes must implement reset')
	}

	/** Extending classes can override this to parse the data received via a fetch */
	parse(data) {
		return data
	}

	/** Extending classes can override this to allow less strict equality */
	equals(obj) {
		return this === obj
	}

	/**
	@return {string} Extending classes must return a JSON-formatted version of their data
	*/
	stringify() {
		throw new Error('Extending classes must implement stringify')
	}

	/**
	@callback DataObject~resetCallback
	@param {DataObject} dataObject
	*/

	/**
	If already reset, immediately call callback, otherwise wait until the first reset and then call callback

	@example
	* class ExampleModel extends DataModel {
	* 	get url() { return '/api/example' }
	* }
	* const model = new ExampleModel()
	* model.onFirstReset((model) => { ... })
	* model.fetch()
	* // the callback passed to onFirstReset will be called when the fetch completes

	@param {DataObject~resetCallback} callback
	*/
	onFirstReset(callback) {
		if (this._new) {
			this.addListener(
				'reset',
				() => {
					callback(this)
				},
				true
			)
		} else {
			callback(this)
		}
	}
	/**
	Extending classes can override this to add headers, methods, etc to the fetch call

	By default the only fetch option set is `credentials: same-origin'.

	@return {Object}
	*/
	get fetchOptions() {
		return {
			credentials: 'same-origin',
		}
	}

	/**
	Ask the server for data for this model or collection.

	Depends on {@link DataObject.url}, {@link DataObject.parse} and {@link DataObject.reset}.

	@return {Promise<DataObject, Error>}
	*/
	fetch() {
		return new Promise(
			function (resolve, reject) {
				this.trigger(DataObject.FetchingEvent, this)
				this._innerFetch(this.url, this.fetchOptions)
					.then((response) => {
						if (response.status != 200) {
							throw 'Fetch failed with status ' + response.status
						}
						return response.json()
					})
					.then((data) => {
						data = this.parse(data)
						this._new = false
						this.reset(data)
						this.trigger(DataObject.FetchedEvent, this, data, null)
						resolve(this)
					})
					.catch((err) => {
						this._new = false
						this.trigger(DataObject.FetchedEvent, this, null, err)
						reject(err)
					})
			}.bind(this)
		)
	}

	/**
	This can be overridden to use something other than `window.fetch` during internal testing.

	Extending classes should override `fetch` if they're not using `window.fetch`
	@private
	*/
	_innerFetch(...params) {
		return fetch(...params)
	}

	/**
	Fetch each DataObject and then wait for them all to return

	This resolves when the fetches complete, regardless of whether they succeed or fail.

	@example
	this.model = new DataModel()
	this.collection = new DataCollection()
	DataObject.fetchAll(model, collection).then((mod, col) => { ... })

	@param {...DataObject} dataObjects
	@return {(Promise<...DataObjects>|Error)}
	*/
	static fetchAll(...dataObjects) {
		const allAreFetched = () => {
			for (const dataObject of dataObjects) {
				if (dataObject.isNew) return false
			}
			return true
		}
		return new Promise((resolve, reject) => {
			if (allAreFetched()) {
				resolve(...dataObjects)
				return
			}
			for (const dataObject of dataObjects) {
				dataObject
					.fetch()
					.then(() => {
						if (allAreFetched()) resolve(...dataObjects)
					})
					.catch((err) => {
						if (allAreFetched()) resolve(...dataObjects)
					})
			}
		})
	}

	/**
	Tell the server to create (POST) or update (PUT) this model or collection.

	If {@link DataObject.isNew} is true it will POST, otherwise it will PUT.

	@return {Promise<DataObject,Error>}
	*/
	save() {
		return new Promise(
			function (resolve, reject) {
				this.trigger(DataObject.SavingEvent, this)
				const options = Object.assign({}, this.fetchOptions)
				if (this.isNew) {
					options.method = 'post'
				} else {
					options.method = 'put'
				}
				options.body = this.stringify()
				this._innerFetch(this.url, options)
					.then((response) => {
						if (response.status != 200) {
							throw 'Save failed with status ' + response.status
						}
						return response.json()
					})
					.then((data) => {
						data = this.parse(data)
						this.reset(data)
						this._new = false
						this.trigger(DataObject.SavedEvent, this, data, null)
						resolve(this)
					})
					.catch((err) => {
						this.trigger(DataObject.SavedEvent, this, null, err)
						reject(err)
					})
			}.bind(this)
		)
	}

	/**
	Call `DELETE` on this object's remote endpoint.

	@return {Promise<undefined,Error>}
	*/
	delete() {
		return new Promise(
			function (resolve, reject) {
				this.trigger(DataObject.DeletingEvent, this)
				const options = Object.assign({}, this.fetchOptions)
				options.method = 'delete'
				this._innerFetch(this.url, options)
					.then((response) => {
						if (response.status != 200) {
							throw 'Delete failed with status ' + response.status
						}
						this.trigger(DataObject.DeletedEvent, this, null)
						resolve()
					})
					.catch((err) => {
						this.trigger(DataObject.DeletedEvent, this, err)
						reject(err)
					})
			}.bind(this)
		)
	}
}

DataObject.FetchingEvent = Symbol('do-fetching')
DataObject.FetchedEvent = Symbol('do-fetched')
DataObject.ResetEvent = Symbol('do-reset')
DataObject.SavingEvent = Symbol('do-saving')
DataObject.SavedEvent = Symbol('do-saved')
DataObject.DeletingEvent = Symbol('no-deleting')
DataObject.DeletedEvent = Symbol('no-deleted')

export default DataObject