@stratusjs/angularjs
Version:
This is the AngularJS package for StratusJS.
622 lines (552 loc) • 20.9 kB
text/typescript
// 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<any>,
}
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<CollectionOptions>()
export interface CollectionSyncOptions {
headers?: LooseObject<any>
nocache?: boolean
}
export class Collection<T = LooseObject> 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<T>()
meta = new ModelBase<T>()
model = Model
models: Model<T>[] | (Model<T>['data'])[] = []
types: Array<string> = []
xhr: XHR
withCredentials = false
headers: LooseObject<any> = {}
cacheResponse: LooseObject<LooseObject|Array<LooseObject>|string> = {}
cacheHeaders: LooseObject<LooseObject<string>> = {}
// Internals
cache = false
pending = false
error = false
completed = false
// Action Flags
filtering = false
paginate = 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 + ']'
}
str.push(this.serialize(value, key))
} 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<LooseObject>, 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<T>[]).push(new Model<T>({
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 (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
this.xhr = new XHR(request)
// TODO: Make this into an over-writable function
const handler = (response: LooseObject | Array<LooseObject> | string) => {
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<string> = 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] = this.xhr.getAllResponseHeaders()
} else {
responseHeaders = this.cacheHeaders[queryHash]
}
}
// Data
this.header.set(responseHeaders || this.xhr.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
}
// make the call!
this.xhr.send()
.then(handler)
.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.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
})
})
}
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) {
this.paginate = !isEmpty(page)
this.meta.set('api.p', page)
this.fetch().then()
delete this.meta.get('api').p
}
toJSON() {
return !this.direct ? (this.models as Model<T>[]).map((model: Model<T>) => 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<T>) {
if (!this.direct) {
this.models.splice((this.models as Model<T>[]).indexOf(target), 1)
this.throttleTrigger('change')
}
return this
}
find(predicate: string|number|LooseFunction<boolean>) {
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