@stratusjs/angularjs
Version:
This is the AngularJS package for StratusJS.
873 lines (787 loc) • 30.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
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<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 (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<LooseObject> | 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<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] = 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<Response|LooseObject|Array<LooseObject>|string> | null = request.method === 'GET'
&& prewarmRequest
&& typeof prewarmRequest.then === 'function'
? prewarmRequest
: null
if (prewarm) {
prewarm
.then((response: Response|LooseObject|Array<LooseObject>|string) => {
if (response && typeof (response as Response).clone === 'function') {
return (response as Response).clone().json()
}
return response
})
.then((response: LooseObject|Array<LooseObject>|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<LooseObject> | 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<LooseObject> | 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<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