// Collection Service // ------------------ // Transformers import { keys } from 'ts-transformer-keys' // Runtime import { cloneDeep, extend, find, forEach, get, isArray, isEmpty, isFunction, isObject, isUndefined, map, set, throttle, reduce, clone, has } from 'lodash' import {auto} from 'angular' import {Stratus} from '@stratusjs/runtime/stratus' // Stratus Core import {ErrorBase} from '@stratusjs/core/errors/errorBase' import {ModelBase} from '@stratusjs/core/datastore/modelBase' import {EventManager} from '@stratusjs/core/events/eventManager' import {cookie} from '@stratusjs/core/environment' import { isJSON, LooseFunction, LooseObject, ucfirst } from '@stratusjs/core/misc' import {XHR, XHRRequest} from '@stratusjs/core/datastore/xhr' // AngularJS Services import {Model, ModelOptions} from './model' // Third-Party import Toastify from 'toastify-js' export interface HttpPrototype { headers: LooseObject method: string url: string data?: string } export interface CollectionOptions { autoSave?: boolean, autoSaveInterval?: number, cache?: boolean, // decay?: number direct?: boolean, // infinite?: boolean, // qualifier?: string, target?: string, targetSuffix?: string, // threshold?: number, urlRoot?: string, watch?: boolean, payload?: string, convoy?: string, headers?: LooseObject, } export interface CollectionModelOptions extends ModelOptions { // This adds a new model to the beginning of the collection.models prepend?: boolean, // This forces a save (intended for use without autoSave enabled) save?: boolean // This triggers the collection add event trigger?: boolean } export const CollectionOptionKeys = keys() export interface CollectionSyncOptions { headers?: LooseObject nocache?: boolean } export class Collection extends EventManager { // Base Information name = 'Collection' // Environment direct = false target?: any = null targetSuffix?: string = null urlRoot = '/Api' toast = true // Unsure usage qualifier = '' // data-ng-if serviceId?: number = null // Infinite Scrolling infinite = false threshold = 0.5 decay = 0 // Infrastructure header = new ModelBase() meta = new ModelBase() model = Model models: Model[] | (Model['data'])[] = [] types: Array = [] xhr: XHR withCredentials = false headers: LooseObject = {} cacheResponse: LooseObject|string> = {} cacheHeaders: LooseObject> = {} // Internals cache = false pending = false error = false completed = false // Action Flags filtering = false paginate = false collectionApiHydratedFromUrl = false // Allow watching models watch = false // Allow AutoSaving autoSave = false autoSaveInterval = 2500 // Methods throttle = throttle(this.fetch, 1000) constructor(options: CollectionOptions = {}) { super() // Initialize required options options = (!options || typeof options !== 'object') ? {} : options // Inject Options // extend(this, this.sanitizeOptions(options)) extend(this, options) // Generate URL if (this.target) { this.urlRoot += '/' + ucfirst(this.target) } // Handle Convoy if (options.convoy) { const convoy = isJSON(options.convoy) ? JSON.parse(options.convoy) : options.convoy if (isObject(convoy)) { this.meta.set((convoy as LooseObject).meta || {}) const models = (convoy as LooseObject).payload if (isArray(models)) { this.inject(models) this.completed = true } else { console.error('malformed payload:', models) } } else { console.error('malformed convoy:', convoy) } } // Handle Payload if (options.payload) { const models = isJSON(options.payload) ? JSON.parse(options.payload) : options.payload if (isArray(models)) { this.inject(models) this.completed = true } else { console.error('malformed payload:', models) } } // Scope Binding // this.serialize = this.serialize.bind(this) // this.url = this.url.bind(this) // this.inject = this.inject.bind(this) // this.sync = this.sync.bind(this) // this.fetch = this.fetch.bind(this) // this.filter = this.filter.bind(this) // this.throttleFilter = this.throttleFilter.bind(this) // this.page = this.page.bind(this) // this.toJSON = this.toJSON.bind(this) // this.add = this.add.bind(this) // this.remove = this.remove.bind(this) // this.find = this.find.bind(this) // this.pluck = this.pluck.bind(this) // this.exists = this.exists.bind(this) // Infinite Scrolling // this.infiniteModels = { // numLoaded_: 0, // toLoad_: 0, // // Required. // getItemAtIndex: function(index) { // if (index > this.numLoaded_) { // this.fetchMoreItems_(index) // return null // } // return index // }, // // Required. // // For infinite scroll behavior, we always return a slightly higher // // number than the previously loaded items. // getLength: function() { // return this.numLoaded_ + 5 // }, // fetchMoreItems_: function(index) { // // For demo purposes, we simulate loading more items with a timed // // promise. In real code, this function would likely contain an // // XHR request. // if (this.toLoad_ < index) { // this.toLoad_ += 20 // $timeout(angular.noop, 300).then(angular.bind(this, function() { // this.numLoaded_ = this.toLoad_ // })) // } // } // } } sanitizeOptions(options: LooseObject): LooseObject { const sanitizedOptions = {} forEach(CollectionOptionKeys, (key) => { const data = get(options, key) if (isUndefined(data)) { return } set(sanitizedOptions, key, data) }) return sanitizedOptions } serialize(obj: any, chain?: any) { const str: string[] = [] obj = obj || {} forEach(obj, (value: any, key: any) => { if (isObject(value)) { if (chain) { key = chain + '[' + key + ']' } const serialized = this.serialize(value, key) if (serialized) { str.push(serialized) } } else { let encoded = '' if (chain) { encoded += chain + '[' } encoded += key if (chain) { encoded += ']' } str.push(encoded + '=' + value) } }) return str.join('&') } url() { return this.urlRoot + (this.targetSuffix || '') } inject(data: Array, type?: string) { if (!isArray(data)) { return } if (this.types && this.types.indexOf(type) === -1) { this.types.push(type) } // TODO: Make this able to be flagged as direct entities if (!this.direct) { data.forEach((target: any) => { // TODO: Add references to the Catalog when creating these models (this.models as Model[]).push(new Model({ autoSave: this.autoSave, autoSaveInterval: this.autoSaveInterval, collection: this, completed: true, received: true, toast: this.toast, type: type || null, watch: this.watch }, target)) }) } } // TODO: Abstract this deeper sync(action?: string, data?: LooseObject, options?: CollectionSyncOptions) { // XHR Flags this.pending = true return new Promise(async (resolve: any, reject: any) => { action = action || 'GET' options = options || {} const request: XHRRequest = { method: action, url: this.url(), headers: clone(this.headers), withCredentials: this.withCredentials, } if (!isUndefined(data)) { if (isObject(data) && Object.prototype.hasOwnProperty.call(data, 'p')) { const validPage = this.normalizePositiveWholeNumber((data as LooseObject).p) if (isUndefined(validPage)) { delete (data as LooseObject).p } else { (data as LooseObject).p = validPage } } if (action === 'GET') { if (isObject(data) && Object.keys(data).length) { request.url += request.url.includes('?') ? '&' : '?' request.url += this.serialize(data) } } else { request.headers['Content-Type'] = 'application/json' request.data = JSON.stringify(data) } } if (Object.prototype.hasOwnProperty.call(options, 'headers') && typeof options.headers === 'object') { Object.keys(options.headers).forEach((headerKey: string) => { request.headers[headerKey] = options.headers[headerKey] }) } // Create QueryHash for Responses const queryHash = `${request.method}:${request.url}` // Clear Cache upon Request if (options.nocache) { if (queryHash in this.cacheResponse) { delete this.cacheResponse[queryHash] } if (queryHash in this.cacheHeaders) { delete this.cacheHeaders[queryHash] } } // begin request const xhr = new XHR(request) this.xhr = xhr // TODO: Make this into an over-writable function const handler = (response: LooseObject | Array | string, responseXhr: XHR = xhr) => { if (!isObject(response) && !isArray(response)) { // Build Report const error = new ErrorBase({ payload: response, message: `Invalid Payload: ${request.method} ${request.url}` }, {}) // XHR Flags this.error = true this.pending = false // Note: I've disabled this because a model should not be marked // as completed if it hasn't received a proper entity or prototype // initially. This is to ensure we don't save entities with the // possibility of nullified fields due to a broken retrieval, // resulting in the replacement of good data for bad. // this.completed = true // Trigger Change Event this.throttleTrigger('change') this.trigger('error', error) // Promise reject(error) return } // TODO: Make this able to wipe the cache let responseHeaders: LooseObject = null // Handle Cache on GET methods if (this.cache && request.method === 'GET') { // Cache Request if (!(queryHash in this.cacheResponse)) { this.cacheResponse[queryHash] = cloneDeep(response) } // Cache Headers if (!(queryHash in this.cacheHeaders)) { this.cacheHeaders[queryHash] = responseXhr.getAllResponseHeaders() } else { responseHeaders = this.cacheHeaders[queryHash] } } // Data this.header.set(responseHeaders || responseXhr.getAllResponseHeaders()) this.meta.set((response as LooseObject).meta || {}) this.models = [] const payload = (response as LooseObject).payload || response // XHR Flags this.error = false // Check Status and Associate Payload if ( (this.meta.has('success') && !this.meta.get('success')) // Removing checks for status[0] // || (!this.meta.has('success') && this.meta.has('status') && this.meta.get('status[0].code') !== 'SUCCESS') ) { this.error = true } else if (this.direct) { this.models = payload } else if (isArray(payload)) { this.inject(payload) } else if (isObject(payload)) { // Note: this is explicitly stated due to context binding forEach(payload, (value: any, key: any) => { this.inject(value, key) }) } else { // If we've gotten this far, it's passed the status check if one is available if (!this.meta.has('status') && !this.meta.has('success')) { // If the status check was not available, this classifies as an error, since the payload is invalid. this.error = true } console.warn(`Invalid Payload: ${request.method} ${request.url}`) } // XHR Flags this.pending = false this.completed = true // Action Flags this.filtering = !isEmpty(this.meta.get('api.q')) this.paginate = !isEmpty(this.meta.get('api.p')) // Clear Meta Temps this.meta.clearTemp() // Trigger Change Event this.throttleTrigger('change') this.trigger('complete') // Promise resolve(this.models) } // handle response cache (headers are cached in the handler) if (this.cache && request.method === 'GET' && queryHash in this.cacheResponse) { handler(this.cacheResponse[queryHash]) return } const prewarmRequest = typeof window !== 'undefined' && (window as any).StratusApiPrewarm ? (window as any).StratusApiPrewarm[request.url] : null const prewarm: Promise|string> | null = request.method === 'GET' && prewarmRequest && typeof prewarmRequest.then === 'function' ? prewarmRequest : null if (prewarm) { prewarm .then((response: Response|LooseObject|Array|string) => { if (response && typeof (response as Response).clone === 'function') { return (response as Response).clone().json() } return response }) .then((response: LooseObject|Array|string) => { const prewarmXhr = { getAllResponseHeaders: () => ({}) } as XHR this.xhr = prewarmXhr handler(response, prewarmXhr) }) .catch(() => { const retryXhr = new XHR(request) this.xhr = retryXhr const xhrPromise = retryXhr.send() if (request.method === 'GET' && typeof window !== 'undefined') { (window as any).StratusApiPrewarm = (window as any).StratusApiPrewarm || {} ;(window as any).StratusApiPrewarm[request.url] = xhrPromise } xhrPromise.then((response: LooseObject | Array | string) => handler(response, retryXhr)) .catch((error: any) => { console.error(`XHR: ${request.method} ${request.url}`) this.throttleTrigger('change') this.trigger('error', error) reject(error) }) }) return } // make the call! const xhrPromise = this.xhr.send() if (request.method === 'GET' && typeof window !== 'undefined') { (window as any).StratusApiPrewarm = (window as any).StratusApiPrewarm || {} ;(window as any).StratusApiPrewarm[request.url] = xhrPromise } xhrPromise.then((response: LooseObject | Array | string) => handler(response, xhr)) .catch((error: any) => { // (/(.*)\sReceived/i).exec(error.message)[1] console.error(`XHR: ${request.method} ${request.url}`) this.throttleTrigger('change') this.trigger('error', error) reject(error) return }) }) } fetch(action?: string, data?: LooseObject, options?: CollectionSyncOptions) { return new Promise(async (resolve: any, reject: any) => { this.hydrateCollectionApiFromUrl() this.sync(action, data || this.meta.get('api'), options) .then(resolve) .catch(async (error: XMLHttpRequest|ErrorBase) => { console.error('FETCH:', error) if (!this.toast) { reject(error) return } const errorMessage = this.errorMessage(error) const formatMessage = errorMessage ? `: ${errorMessage}` : '.' Toastify({ text: `Unable to Fetch ${this.target}${formatMessage}`, duration: 12000, close: true, stopOnFocus: true, style: { background: '#E14D45', } }).showToast() reject(error) return }) }) } hydrateCollectionApiFromUrl(force = false) { if ((!force && this.collectionApiHydratedFromUrl) || typeof window === 'undefined') { return } const url = new URL(window.location.href) const urlApi = this.getUrlSearchObject(url) let filterApplied = false let rawPage: any = null if (typeof this.target === 'string' && this.target.length) { rawPage = this.getUrlSearchValue(url, [`page[${this.target}]`, `p[${this.target}]`]) } if (!rawPage) { rawPage = this.getUrlSearchValue(url, ['p', 'page']) } const page = this.normalizePositiveWholeNumber(rawPage) if (!isUndefined(page)) { this.meta.set('api.p', page) this.meta.set('pagination.pageCurrent', page) this.paginate = true } const query = this.getUrlSearchValue(url, ['q', 'query', 'keyword', 'search']) if (!isUndefined(query) && query !== null && query !== '') { this.meta.set('api.q', query) this.filtering = true filterApplied = true } const tags = this.getUrlSearchArray(url, ['tags', 'tag']) if (!isEmpty(tags)) { this.meta.set('api.tags', map(tags, this.parseNumberLikeValue)) filterApplied = true } const contentTypes = this.getUrlSearchArray(url, ['contentType', 'contentTypes']) if (!isEmpty(contentTypes)) { this.meta.set('api.contentType', map(contentTypes, this.parseNumberLikeValue)) filterApplied = true } forEach([ 'authorId', 'status', 'filterPublished', 'filterTimeField', 'filterTimeMin', 'filterTimeMax', 'filterTimeRange', 'sort', 'sortOrder', 'filter' ], (apiKey: string) => { if (!has(urlApi, apiKey)) { return } const value = get(urlApi, apiKey) if (isUndefined(value) || value === null || value === '') { return } this.meta.set(`api.${apiKey}`, this.parseUrlValue(value)) filterApplied = true }) if (filterApplied) { this.meta.set('filterApplied', true) } this.collectionApiHydratedFromUrl = true } private getUrlSearchValue(url: URL, keys: string[]): any { for (const key of keys) { const value = url.searchParams.get(key) if (!isUndefined(value) && value !== null && value !== '') { return this.parseUrlValue(value) } } return undefined } private getUrlSearchArray(url: URL, keys: string[]): any[] { const values: any[] = [] url.searchParams.forEach((value: string, key: string) => { if (!find(keys, (name: string) => key === name || key === `${name}[]` || new RegExp(`^${name}\\[\\d+\\]$`).test(key))) { return } const parsedValue = this.parseUrlValue(value) if (isArray(parsedValue)) { values.push(...parsedValue) return } if (typeof parsedValue === 'string' && parsedValue.indexOf(',') !== -1) { values.push(...parsedValue.split(',').map((item: string) => item.trim()).filter((item: string) => item !== '')) return } values.push(parsedValue) }) return values } private getUrlSearchObject(url: URL): LooseObject { const output: LooseObject = {} url.searchParams.forEach((value: string, key: string) => { this.assignUrlSearchValue(output, this.getUrlSearchParts(key), value) }) return output } private getUrlSearchParts(key: string): string[] { const parts: string[] = [] const pattern = /([^\[\]]+)|\[(.*?)\]/g let match: RegExpExecArray | null while ((match = pattern.exec(key)) !== null) { parts.push(match[1] || match[2] || '') } return parts } private assignUrlSearchValue(output: LooseObject, parts: string[], rawValue: string) { if (!parts.length) { return } let cursor: any = output forEach(parts, (part: string, index: number) => { const last = index === parts.length - 1 const key: any = part === '' ? this.nextArrayKey(cursor) : part if (last) { if (isUndefined(cursor[key])) { cursor[key] = rawValue } else if (isArray(cursor[key])) { cursor[key].push(rawValue) } else { cursor[key] = [cursor[key], rawValue] } return } if (!isObject(cursor[key])) { cursor[key] = parts[index + 1] === '' || /^\d+$/.test(parts[index + 1]) ? [] : {} } cursor = cursor[key] }) } private nextArrayKey(value: any): number { return isArray(value) ? value.length : Object.keys(value).length } private parseUrlValue(value: any): any { if (isArray(value)) { return map(value, (item: any) => this.parseUrlValue(item)) } if (isObject(value)) { return reduce(value, (result: LooseObject, item: any, key: string) => { result[key] = this.parseUrlValue(item) return result }, {}) } if (typeof value !== 'string') { return value } if (isJSON(value)) { return JSON.parse(value) } return this.parseNumberLikeValue(value) } private parseNumberLikeValue(value: any): any { if (typeof value !== 'string') { return value } if (/^-?\d+(\.\d+)?$/.test(value)) { return Number(value) } return value } filter(query: string) { this.filtering = !isEmpty(query) this.meta.set('api.q', !isUndefined(query) ? query : '') this.meta.set('api.p', 1) return this.fetch() } throttleFilter(query: string) { this.meta.set('api.q', !isUndefined(query) ? query : '') return new Promise((resolve: any, reject: any) => { const request = this.throttle() if (cookie('env')) { console.log('request:', request) } request.then((models: any) => { if (cookie('env')) { // TODO: Finish handling throttled data /* * console.log('throttled:', map(models, function (model: Model) { return model.domainPrimary })) /* */ } resolve(models) }).catch(reject) }) } page(page: any) { const validPage = this.normalizePositiveWholeNumber(page) this.paginate = !isUndefined(validPage) if (isUndefined(validPage)) { this.clearApiPage() return } this.meta.set('api.p', validPage) this.fetch().then() this.clearApiPage() } private clearApiPage() { const collectionApi = this.meta.get('api') if (isObject(collectionApi) && Object.prototype.hasOwnProperty.call(collectionApi, 'p')) { delete (collectionApi as LooseObject).p } } private normalizePositiveWholeNumber(value: any): number | undefined { const page = Number(value) return Number.isFinite(page) && Number.isInteger(page) && page >= 1 ? page : undefined } toJSON() { return !this.direct ? (this.models as Model[]).map((model: Model) => model.toJSON()) : this.models } add(target?: any, options?: CollectionModelOptions): Model { if (!isObject(target)) { console.error('collection.add: target object not set!') return } if (!options || typeof options !== 'object') { options = {} } if (target instanceof Model) { target.collection = this } else { options.collection = this target = new Model(options, target) target.initialize() if (options.autoSave || options.watch) { if (target.isNew()) { target.save() } else if (!target.completed) { target.fetch() } } } if (options.save) { target.save() } if (options.prepend) { this.models.unshift(target) } else { this.models.push(target) } if (options.trigger) { this.trigger('add', target) } this.throttleTrigger('change') return target } remove(target: Model) { if (!this.direct) { this.models.splice((this.models as Model[]).indexOf(target), 1) this.throttleTrigger('change') } return this } find(predicate: string|number|LooseFunction) { return find(this.models, isFunction(predicate) ? predicate : (model: Model) => model.get('id') === predicate) } map(predicate: string) { // return filter(map(this.models, model => model instanceof Model ? model.get(predicate) : null), model => !!model) return map(this.models, model => model instanceof Model ? model.get(predicate) : null) } pluck(attribute: string) { return map(this.models, model => model instanceof Model ? model.pluck(attribute) : null) } exists(attribute: string) { return !!reduce(this.pluck(attribute) || [], (memo: any, data: any) => memo || !isUndefined(data)) } errorMessage(error: XMLHttpRequest|ErrorBase): string|null { if (error instanceof ErrorBase) { console.error(`[${error.code}] ${error.message}`, error) return error.code !== 'Internal' ? error.message : null } const digest = (error.responseText && isJSON(error.responseText)) ? JSON.parse(error.responseText) : null if (!digest) { return null } const message = get(digest, 'meta.status[0].message') || get(digest, 'error.exception[0].message') || null if (!message) { return null } if (!cookie('env') && has(digest, 'error.exception[0].message')) { console.error('[xhr] server:', message) return null } return message } } // TODO: Build out the query-only structure here as a separate set // This Collection Service handles data binding for multiple objects with the // registered collections and models Stratus.Services.Collection = [ '$provide', ($provide: auto.IProvideService) => { $provide.factory('Collection', [() => Collection]) } ] Stratus.Data.Collection = Collection