@stratusjs/angularjs
Version:
This is the AngularJS package for StratusJS.
1,204 lines (1,076 loc) • 41.9 kB
text/typescript
// Model Service
// -------------
// Transformers
import {keys} from 'ts-transformer-keys'
// Runtime
import {
clone,
cloneDeep,
extend,
filter,
forEach,
get,
has,
head,
isArray,
isEmpty,
isEqual,
isNumber,
isObject,
isString,
isUndefined,
map,
once,
set,
throttle
} from 'lodash'
import {IRootScopeService} from 'angular'
import {Stratus} from '@stratusjs/runtime/stratus'
// Stratus Core
import {
ErrorBase
} from '@stratusjs/core/errors/errorBase'
import {
getAnchorParams,
getUrlParams,
isJSON,
LooseObject,
patch,
serializeUrlParams,
setUrlParams,
strcmp,
ucfirst
} from '@stratusjs/core/misc'
import {
ModelBase,
ModelBaseOptions
} from '@stratusjs/core/datastore/modelBase'
import {
cookie
} from '@stratusjs/core/environment'
import {
XHR,
XHRRequest
} from '@stratusjs/core/datastore/xhr'
// AngularJS Dependency Injector
import {getInjector} from '../injector'
// AngularJS Services
import {Collection} from './collection'
// Third-Party
import Toastify from 'toastify-js'
// Instantiate Injector
let injector = getInjector()
// Angular Services
// let $rootScope: IRootScopeService = injector ? injector.get('$rootScope') : null
let $rootScope: IRootScopeService
// Service Verification Function
const serviceVerify = async () => {
return new Promise(async (resolve, _reject) => {
if ($rootScope) {
resolve(true)
return
}
if (!injector) {
injector = getInjector()
}
if (injector) {
// TODO: this is only used for the watcher (find a native replacement)
$rootScope = injector.get('$rootScope')
}
if ($rootScope) {
resolve(true)
return
}
setTimeout(() => {
if (cookie('env')) {
console.log('wait for $rootScope service:', {
$rootScope
})
}
serviceVerify().then(resolve)
}, 250)
})
}
// TODO: Remove this interface (replaced by XHRRequest)
export interface HttpPrototype {
headers: LooseObject
method: string
url: string
data?: string
}
export interface ModelOptions extends ModelBaseOptions {
autoSave?: boolean,
autoSaveInterval?: number,
autoSaveHalt?: boolean,
collection?: Collection,
completed?: boolean, // Fetch/injected model can already be completed
manifest?: string,
serviceId?: number,
stagger?: boolean,
target?: string,
targetSuffix?: string,
toast?: boolean,
type?: string,
urlRoot?: string,
urlSync?: boolean,
watch?: boolean,
withCredentials?: boolean,
payload?: string,
convoy?: string,
headers?: LooseObject<any>,
}
export const ModelOptionKeys = keys<ModelOptions>()
export interface ModelSyncOptions {
headers?: LooseObject<any>
}
export class Model<T = LooseObject> extends ModelBase<T> {
// Base Information
name = 'Model'
// Environment
target?: any = null
type?: string = null
manifest = false
stagger = false
toast = true
identifier?: string | number = null
urlRoot = '/Api'
targetSuffix?: string = null
// Unsure usage
serviceId?: number = null
// Infrastructure
header = new ModelBase()
meta = new ModelBase()
route = new ModelBase()
collection?: Collection = null
xhr: XHR
withCredentials = false
headers: LooseObject<any> = {}
// XHR Flags
pending = false
error = false
completed = false
saving = false
// External Controls
changedExternal = false
// Temporarily force watching to all direct models
watch = true
// XHR Data
status?: any = null
// Auto-Save Logic
autoSave = false
autoSaveInterval = 4000
autoSaveHalt = true
autoSaveTimeout: any = null
// URL Controls
urlSync = false
// Misc
bracket = {
match: /\[[\d+]]/,
search: /\[([\d+])]/g,
attr: /(^[^[]+)/
}
// Methods
throttle = throttle(this.fetch, 1000)
initialize?: () => void = null
constructor(options: ModelOptions = {}, attributes?: LooseObject) {
// Trickle down handling for Attributes (Typically from Collection Hydration) and Basic Options
super(attributes)
// Initialize required options
options = typeof options !== 'object' ? {} : options
options.received = options.received || false
// Inject Options
extend(this, this.sanitizeOptions(options))
// 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 payload = (convoy as LooseObject).payload
if (isObject(payload)) {
extend(this.data, payload)
this.completed = true
options.received = true
} else {
console.error('malformed payload:', payload)
}
} else {
console.error('malformed convoy:', convoy)
}
}
// Handle Payload
if (options.payload) {
const payload = isJSON(options.payload) ? JSON.parse(options.payload) : options.payload
if (isObject(payload)) {
extend(this.data, payload)
this.completed = true
options.received = true
} else {
console.error('malformed payload:', payload)
}
}
// The data used to detect the data is changed.
// this.initData = {}
// Handle Collections & Meta
this.header = new ModelBase()
this.meta = new ModelBase()
this.route = new ModelBase()
if (!isEmpty(this.collection)) {
if (this.collection.target) {
this.target = this.collection.target
}
if (this.collection.meta.has('api')) {
this.meta.set('api', this.collection.meta.get('api'))
}
}
// Handle Attributes (Typically from Collection Hydration)
// Note: This is commented out because it doesn't differ from what happens in the ModelBase Constructor
// if (attributes && typeof attributes === 'object') {
// extend(this.data, attributes)
// }
// TODO: Analyze possibility for options.received to be replaced with a !this.isNew()
// Handle Data Flagged as Received from XHR
this.recv = options.received ? cloneDeep(this.data) : {}
this.sent = {}
// Handle Keys we wish to ignore in patch
this.ignoreKeys = options.ignoreKeys || ['$$hashKey']
// Generate URL
if (this.target) {
this.urlRoot += '/' + ucfirst(this.target)
}
// TODO: Enable Auto-Save
// this.throttle = throttle(this.save, 2000)
const that = this
this.initialize = once(this.initialize || function defaultInitializer() {
// Begin Watching if already completed
if (that.completed && (that.watch || that.autoSave)) {
that.watcher().then()
}
// Bubble Event + Defer
// this.on('change', function () {
// if (!this.collection) {
// return
// }
// this.collection.throttleTrigger('change')
// })
// TODO: This needs to be wrapped in a new promise!!!
if (that.manifest && !that.getIdentifier()) {
that.sync('POST', that.meta.has('api') ? {
meta: that.meta.get('api'),
payload: {}
} : {}).catch(async (error: XMLHttpRequest|ErrorBase) => {
console.error('MANIFEST:', error)
if (!that.toast) {
return
}
const errorMessage = that.errorMessage(error)
const formatMessage = errorMessage ? `: ${errorMessage}` : '.'
Toastify({
text: `Unable to Manifest ${that.target}${formatMessage}`,
duration: 12000,
close: true,
stopOnFocus: true,
style: {
background: '#E14D45',
}
}).showToast()
that.errorMessage(error)
})
}
})
if (!this.stagger) {
this.initialize()
}
}
resetXHRFlags() {
this.pending = false
this.saving = false
// Note: we do not know status of this.completed because in some cases an error would cause retrieval of bad
// data and we do not want to overwrite data
// NOTE: when we reset XHR it could happen in success, error, etc, so we don't know status of this.changed
}
sanitizeOptions(options: LooseObject): LooseObject {
const sanitizedOptions = {}
forEach(ModelOptionKeys, (key) => {
const data = get(options, key)
if (isUndefined(data)) {
return
}
set(sanitizedOptions, key, data)
})
return sanitizedOptions
}
// Watch for Data Changes
async watcher() {
// Ensure we only watch once
if (this.watching) {
return true
}
this.watching = true
// Verify AngularJS Services
if (!$rootScope) {
await serviceVerify()
}
// FIXME: The performance here is horrendous
// We utilize the AngularJS Watcher for now, because it forces a redraw
// as we change values in comparison to the native setTimeout() watcher
// in the ModelBase.
$rootScope.$watch(() => this.data, (_newData: LooseObject, _priorData: LooseObject) => this.handleChanges(), true)
}
// sanitizePatch(patchData: LooseObject) {
// forEach(keys(patchData), (key: any) => {
// if (endsWith(key, '$$hashKey')) {
// delete patchData[key]
// }
// })
// return patchData
// }
// TODO: A simpler version should exist on the ModelBase
handleChanges(changeSet?: LooseObject): LooseObject {
// Generate ChangeSet for normal triggers
const isUserChangeSet = isUndefined(changeSet)
if (isUserChangeSet) {
changeSet = super.handleChanges()
}
// Ensure ChangeSet is valid
if (!changeSet || isEmpty(changeSet)) {
return changeSet
}
// this.changed = !isEqual(this.data, this.initData)
// This ensures that we only save
if (this.error && !this.completed && this.getIdentifier()) {
const action = isUserChangeSet ? 'save' : 'sync url for'
console.warn(`Blocked attempt to ${action} a persisted model that has not been fetched successfully.`)
return
}
// Debug info
if (!isUserChangeSet) {
if (cookie('env')) {
console.info('Attempting URL Sync for non-User ChangeSet:', changeSet)
}
}
// Sync URL with Data Changes
// TODO: Check the Payload as well, as everything may not generate a changeSet upon return (i.e. Content Duplication)
if (this.urlSync) {
// TODO: Allow an option for using PushState here instead of hitting a page reload
// Handle ID Changes
if (get(changeSet, 'id')) {
// if (cookie('env')) {
// console.info('replace id:', this.getIdentifier())
// }
// NOTE: setUrlParams will automatically update the window (and I think that is a mistake!)
const newUrl = setUrlParams({
id: get(changeSet, 'id') || this.getIdentifier()
})
if (newUrl !== document.location.href) {
window.location.replace(newUrl)
}
}
// Handle Version ID Changes
const version = getAnchorParams('version')
const versionId = !isEmpty(version) ? parseInt(version, 10) : 0
if (versionId && versionId !== get(changeSet, 'version.id')) {
if (cookie('env')) {
console.warn('replacing version:', versionId)
}
}
}
// Stop Handling Data if not triggered by a User Change
if (!isUserChangeSet) {
return
}
// Trigger Queue for Auto-Save
this.saveIdle()
// Dispatch Model Events
// This hasn't been test, but is probably a better idea than what we're getting from the setAttribute
// this.throttleTrigger('change', changeSet)
this.throttleTrigger('change', this)
// Dispatch Collection Events
if (this.collection) {
this.collection.throttleTrigger('change', this)
}
// Ensure the ChangeSet bubbles
return changeSet
}
getIdentifier() {
return (this.identifier = this.get('id') || this.route.get('identifier') || this.identifier)
}
getType() {
return (this.type = this.type || this.target || 'orphan')
}
getHash() {
return this.getType() + (isNumber(this.getIdentifier()) ? this.getIdentifier().toString() : this.getIdentifier())
}
isNew() {
return !this.getIdentifier()
}
url() {
let url = this.getIdentifier() ? `${this.urlRoot}/${this.getIdentifier()}` : `${this.urlRoot}${this.targetSuffix || ''}`
// add further param to specific version
if (getUrlParams('version')) {
// TODO: Move the following version logic to a router
url += url.includes('?') ? '&' : '?'
url += 'options[version]=' + getUrlParams('version')
}
return url
}
/** @deprecated use @stratusjs/core/misc serializeUrlParams() instead */
serialize(obj: any, chain?: any) { return serializeUrlParams(obj, chain)}
// TODO: Abstract this deeper
sync(action?: string, data?: LooseObject, options?: ModelSyncOptions): Promise<any> {
// XHR Flags
this.pending = true
// Dispatch Model Change Event
this.trigger('change', this)
// XHR Flags for Collection
if (this.collection) {
// TODO: Change to a Model ID Register
this.collection.pending = true
// Dispatch Collection Change Event
this.collection.throttleTrigger('change')
}
// Diff Information
this.sent = cloneDeep(this.data)
// Execute XHR
// TODO: Get this in-line with Collection logic
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 (['GET','DELETE'].includes(action)) {
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 = data
}
}
if (cookie('env')) {
console.log('Prototype:', request)
}
if (Object.prototype.hasOwnProperty.call(options, 'headers') && typeof options.headers === 'object') {
Object.keys(options.headers).forEach((headerKey: any) => {
request.headers[headerKey] = options.headers[headerKey]
})
}
// Example XHR
this.xhr = new XHR(request)
// Call XHR
this.xhr.send().then((response: LooseObject | Array<LooseObject> | string) => {
// Data Stores
this.status = this.xhr.status
// Begin Watching (this.watcher is a singleton)
if (this.watch || this.autoSave) {
this.watcher()
}
// TODO: Remove this unnecessary hack, when new patch is confirmed as accurate
// Reset status model
// setTimeout(() => {
// this.changed = false
// this.throttleTrigger('change')
// if (this.collection) {
// this.collection.throttleTrigger('change')
// }
// }, 100)
// Set Flags & Propagate Events on Error
const propagateError = () => {
// XHR Flags
this.error = true
this.resetXHRFlags()
// Note: we do not mark a model as "complete" 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
// XHR Flags for Collection
if (this.collection) {
// TODO: Change to a Model ID Register to account for all Models in a Collection
this.collection.pending = false
}
// Events
this.trigger('error', this)
this.trigger('complete', this)
// Propagate Collection Change Event
if (this.collection instanceof Collection) {
this.collection.throttleTrigger('change')
}
}
// Evaluate Response
if (!isObject(response) && !isArray(response)) {
// Build Report
const error = new ErrorBase({
payload: response,
message: `Invalid Payload: ${request.method} ${request.url}`
}, {})
// Set Flags & Propagate Events
propagateError()
// Promise
reject(error)
return
}
// TODO: Make this into an over-writable function
// Gather Data
// FIXME: This needs to be setting as the API data...
// FIXME: The API data coming in appears to have precedence after recent changes
// FIXME: This does not have to do with recent changes, where we handle incoming
// change sets... There's something else at play.
this.header.set(this.xhr.getAllResponseHeaders() || {})
this.meta.set((response as LooseObject).meta || {})
this.route.set((response as LooseObject).route || {})
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 (isArray(payload) && payload.length) {
this.recv = head(payload)
} else if (isObject(payload) && !isArray(payload)) {
this.recv = payload
} 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}`)
}
// Report Invalid Payloads
if (this.error) {
// Build Report
const error = new ErrorBase({
payload,
message: `Invalid Payload: ${request.method} ${request.url}`
}, {})
// Set Flags & Propagate Events
propagateError()
// Promise
reject(error)
return
}
// Diff Settings
// This is the ChangeSet coming from alterations between what is sent and received (i.e. new version)
const incomingChangeSet = this.completed ? cloneDeep(
patch(this.recv, this.sent)
) : {}
if (!isEmpty(incomingChangeSet)) {
if (cookie('env')) {
console.log('Incoming ChangeSet detected:',
cookie('debug_change_set')
? JSON.stringify(incomingChangeSet)
: incomingChangeSet
)
}
// Handle Incoming ChangeSet separately from User-defined data
this.handleChanges(incomingChangeSet)
}
// This is the ChangeSet generated from what has changed during the save
const intermediateData = cloneDeep(
this.recv
)
const intermediateChangeSet = cloneDeep(
patch(this.data, this.sent)
)
if (!isEmpty(intermediateChangeSet)) {
if (cookie('env')) {
console.log('Intermediate ChangeSet detected:',
cookie('debug_change_set')
? JSON.stringify(intermediateChangeSet)
: intermediateChangeSet
)
}
forEach(intermediateChangeSet, (element: any, key: any) => {
set(intermediateData, key, element)
})
}
// Propagate Changes
this.data = cloneDeep(intermediateData) as T
// Before handling changes make sure we set to false
this.changed = false
this.changedExternal = false
this.saving = false
// FIXME: This should be finding the changed identifier...
this.handleChanges()
this.patch = {}
// TODO: Handle the remainder here, which was encapsulated after the if (!this.error) {
// XHR Flags
this.resetXHRFlags()
this.completed = true
// XHR Flags for Collection
if (this.collection) {
// TODO: Change to a Model ID Register
this.collection.pending = false
}
// Clear Meta Temps
this.meta.clearTemp()
// Events
this.trigger('success', this)
this.trigger('change', this)
this.trigger('complete', this)
// Propagate Collection Change Event
if (this.collection instanceof Collection) {
this.collection.throttleTrigger('change')
}
// Promise
// extendDeep(this.data, this.initData)
resolve(this.data)
return
})
.catch((error: XMLHttpRequest|ErrorBase) => {
// (/(.*)\sReceived/i).exec(error.message)[1]
// FIXME: The UI should be able to handle more than a status of 500...
// Treat a fatal error like 500 (our UI code relies on this distinction)
// TODO: This should not default to status 500. It should contain the actual status.
this.status = 500
this.error = true
this.resetXHRFlags()
console.error(`XHR: ${request.method} ${request.url}`, error)
// reject and close promise
reject(error)
return
})
})
}
fetch(action?: string, data?: LooseObject, options?: ModelSyncOptions) {
return new Promise(async (resolve: any, reject: any) => {
this.sync(action, data || this.meta.get('api'), options)
.then(resolve)
.catch(async (error: XMLHttpRequest|ErrorBase) => {
// TODO: This should not default to status 500. It should contain the actual status.
this.status = 500
this.error = true
this.resetXHRFlags()
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
})
})
}
save(options?: any): Promise<any> {
this.saving = true
// TODO: store the promise locally so if it's in the middle of saving it returns the pending promise instead of adding another...
options = options || {}
if (!isObject(options)) {
console.warn('invalid options supplied:', options)
options = {}
}
if (has(options, 'force') && options.force) {
options.patch = has(options, 'patch') ? options.patch : false
return this.doSave(options)
}
// Sanity Checks for Persisted Entities
if (!this.isNew() && (this.pending || !this.completed || isEmpty(this.toPatch()))) {
console.warn(
`Blocked attempt to save ${isEmpty(this.toPatch()) ? 'an empty payload' : 'a duplicate XHR'} to a persisted model.`
)
return new Promise((resolve, _reject) => {
this.saving = false
resolve(this.data)
})
}
return this.doSave(options)
}
doSave(options?: any): Promise<any> {
options = options || {}
if (!isObject(options)) {
console.warn('invalid options supplied:', options)
options = {}
}
options.patch = has(options, 'patch') ? options.patch : true
return new Promise(async (resolve: any, reject: any) => {
this.sync(this.getIdentifier() ? 'PUT' : 'POST',
this.toJSON({
patch: options.patch
}))
.then(resolve)
.catch(async (error: XMLHttpRequest|ErrorBase) => {
this.error = true
this.resetXHRFlags()
console.error('SAVE:', error)
if (!this.toast) {
reject(error)
return
}
// TODO: Detect why we're getting internal messages outside of Dev Mode!
const errorMessage = this.errorMessage(error)
const formatMessage = errorMessage ? `: ${errorMessage}` : '.'
Toastify({
text: `Unable to Save ${this.target}${formatMessage}`,
duration: 12000,
close: true,
stopOnFocus: true,
style: {
background: '#E14D45',
}
}).showToast()
reject(error)
return
})
})
}
saveIdle() {
if (this.autoSaveTimeout) {
clearTimeout(this.autoSaveTimeout)
}
if (this.pending || !this.completed || this.isNew() || isEmpty(this.toPatch())) {
return
}
if (this.autoSaveHalt && !this.autoSave) {
return
}
this.autoSaveTimeout = setTimeout(() => {
if (!this.autoSaveHalt && !this.autoSave) {
this.saveIdle()
return
}
this.save().then()
}, this.autoSaveInterval)
}
throttleSave() {
return new Promise((resolve: any, reject: any) => {
const request = this.throttle()
console.log('throttle request:', request)
request.then((data: any) => {
console.log('throttle received:', data)
resolve(data)
}).catch(reject)
})
}
// Attribute Functions
toJSON(options?: any) {
// Ensure Patch only Saves on Persistent Models
options = options || {}
if (!isObject(options)) {
options = {}
}
options.patch = (options.patch && !this.isNew())
let data = super.toJSON(options)
const metaData = this.meta.get('api')
if (metaData) {
data = {
meta: metaData,
payload: data
}
}
return data
}
buildPath(path: string): any {
const acc: any = []
if (!isString(path)) {
return acc
}
let cur
let search
forEach(path.split('.'), (link: any) => {
// handle bracket chains
if (link.match(this.bracket.match)) {
// extract attribute
cur = this.bracket.attr.exec(link)
if (cur !== null) {
acc.push(cur[1])
cur = null
} else {
cur = false
}
// extract cells
search = this.bracket.search.exec(link)
while (search !== null) {
if (cur !== false) {
cur = parseInt(search[1], 10)
if (!isNaN(cur)) {
acc.push(cur)
} else {
cur = false
}
}
search = this.bracket.search.exec(link)
}
} else {
// normal attributes
acc.push(link)
}
})
return acc
}
/**
* Use to get an attributes in the model.
*/
get(attr: string) {
// TODO: Split these out as small errors
if (typeof attr !== 'string' || !this.data || typeof this.data !== 'object') {
return undefined
}
return get(this.data, attr)
// Note: This get function below has been replaced by the get() above
/* *
return this.buildPath(attr).reduce(
(attrs: any, link: any) => attrs && attrs[link], this.data
)
/* */
}
/**
* if the attributes is an array, the function allow to find the specific object by the condition ( key - value )
*/
find(attr: any, key: any, value: any) {
if (typeof attr === 'string') {
attr = this.get(attr)
}
return !isArray(attr) ? attr : attr.find((obj: any) => obj[key] === value)
}
set(attr: string | LooseObject, value: any) {
if (!attr) {
console.warn('No attr for model.set()!')
return this
}
if (typeof attr === 'object') {
forEach(attr, (v: any, k: string) => this.setAttribute(k, v))
return this
}
this.setAttribute(attr, value)
return this
}
setAttribute(attr: string, value: any) {
if (typeof attr !== 'string') {
console.warn('Malformed attr for model.setAttribute()!')
return false
}
// @ts-ignore
set(this.data, attr, value)
// Note: This entire set has been replaced with the set() above
/* *
if (includes(attr, '.') || includes(attr, '[')) {
let future
this.buildPath(attr)
.reduce((attrs: any, link: any, index: any, chain: any) => {
future = index + 1
if (!has(attrs, link)) {
attrs[link] = has(chain, future) &&
isNumber(chain[future]) ? [] : {}
}
if (!has(chain, future)) {
attrs[link] = value
}
return attrs && attrs[link]
}, this.data)
} else {
(this.data as LooseObject)[attr] = value
}
/* */
// The issue with these triggers is they only fire if using the set() method,
// while some values will be changed via the data object directly.
this.throttleTrigger('change', this)
this.throttleTrigger(`change:${attr}`, value)
}
// FIXME: This doesn't appear to work properly anymore
toggle(attribute: any, item?: any, options?: object | any) {
if (typeof options === 'object' &&
!isUndefined(options.multiple) &&
isUndefined(options.strict)) {
options.strict = true
}
options = extend({
multiple: true
}, isObject(options) ? options : {})
/* TODO: After plucking has been tested, remove this log *
console.log('toggle:', attribute, item, options);
/* */
const request = attribute.split('[].')
let target = this.get(request.length > 1 ? request[0] : attribute)
if (isUndefined(target) ||
(options.strict && isArray(target) !==
options.multiple)) {
target = options.multiple ? [] : null
this.set(request.length > 1 ? request[0] : attribute, target)
}
if (isArray(target)) {
/* This is disabled, since hydration should not be forced by default *
const hydrate = {}
if (request.length > 1) {
hydrate[request[1]] = {
id: item
}
} else {
hydrate.id = item
}
/* */
if (isUndefined(item)) {
this.set(attribute, null)
} else if (!this.exists(attribute, item)) {
target.push(item)
} else {
forEach(target, (element: any, key: any) => {
const child = (request.length > 1 &&
typeof element === 'object' && request[1] in element)
? element[request[1]]
: element
const childId = (typeof child === 'object' && child.id)
? child.id
: child
const itemId = (typeof item === 'object' && item.id)
? item.id
: item
if (childId === itemId || (
isString(childId) && isString(itemId) && strcmp(childId, itemId) === 0
)) {
target.splice(key, 1)
}
})
}
} else if (typeof target === 'object' || typeof target === 'number') {
// (item && typeof item !== 'object') ? { id: item } : item
this.set(attribute, !this.exists(attribute, item) ? item : null)
} else if (isUndefined(item)) {
this.set(attribute, !target)
}
return this.get(attribute)
}
pluck(attr: string) {
if (typeof attr !== 'string' || attr.indexOf('[].') === -1) {
return this.get(attr)
}
const request = attr.split('[].')
if (request.length <= 1) {
return undefined
}
attr = this.get(request[0])
if (!attr || !isArray(attr)) {
return undefined
}
const list: Array<any> = filter(map(attr, (element: any) => get(element, request[1])))
return list.length ? list : undefined
}
exists(attribute: any, item: any) {
if (!item) {
attribute = this.get(attribute)
return typeof attribute !== 'undefined' && attribute
}
if (typeof attribute === 'string' && item) {
attribute = this.pluck(attribute)
if (isArray(attribute)) {
return typeof attribute.find((element: any) => element === item || (
(typeof element === 'object' && element.id && element.id === item) || isEqual(element, item)
)) !== 'undefined'
}
return attribute === item || (
typeof attribute === 'object' && attribute.id && (
isEqual(attribute, item) || attribute.id === item
)
)
}
return false
}
destroy(): Promise<any> {
// TODO: Add a delete confirmation dialog option
if (this.isNew()) {
return new Promise((resolve, _reject) => {
this.throttleTrigger('change')
if (this.collection) {
this.collection.remove(this)
}
resolve(this.data)
})
}
return new Promise(async (_resolve: any, reject: any) => {
let deleteData = {}
if (!isEmpty(this.meta.get('api'))) {
deleteData = this.meta.get('api')
}
this.sync('DELETE', deleteData)
.then((_data: any) => {
// TODO: This should not need an error check in the success portion of the Promise, but I'm going to leave it here
// until we are certain there isn't any code paths relying on this rejection.
if (this.error) {
reject(this.error)
return
}
this.throttleTrigger('change')
if (this.collection) {
this.collection.remove(this)
}
_resolve(this.data)
})
.catch(async (error: XMLHttpRequest|ErrorBase) => {
this.error = true
this.resetXHRFlags()
console.error('DESTROY:', error)
if (!this.toast) {
reject(error)
return
}
const errorMessage = this.errorMessage(error)
const formatMessage = errorMessage ? `: ${errorMessage}` : '.'
Toastify({
text: `Unable to Delete ${this.target}${formatMessage}`,
duration: 12000,
close: true,
stopOnFocus: true,
style: {
background: '#E14D45',
}
}).showToast()
reject(error)
return
})
})
}
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
}
}
// This Model Service handles data binding
// for a single object with a RESTful API.
Stratus.Services.Model = [
'$provide', ($provide: any) => {
$provide.factory('Model', [
// '$rootScope',
(
// $r: IRootScopeService
) => {
// $rootScope = $r
return Model
}
])
}
]
Stratus.Data.Model = Model