|
/* Copyright (C) Lineup Ninja Ltd - All Rights Reserved |
|
* Unauthorized copying of this file, via any medium is strictly prohibited |
|
* Proprietary and confidential |
|
* Written by Gordon Johnston <gord@lineup.ninja>, April 2017 |
|
*/ |
|
import Ember from 'ember'; |
|
import { task } from 'ember-concurrency'; |
|
const { Service, get, set, RSVP: { Promise }, inject: { service }, assert, getOwner, guidFor } = Ember; |
|
|
|
export default Service.extend({ |
|
|
|
firebaseApp: service(), |
|
store: service(), |
|
|
|
_transactions: {}, |
|
|
|
/** |
|
* Starts a new transaction. Use the returned ID with calls to linkRecords etc for their updates to be placed into the transaction |
|
* @param {string} transactionId A unique identifier to use for the transaction, optional |
|
* @returns {string} The id for the transaction, you must use this ID for commitTransaction |
|
*/ |
|
|
|
startTransaction(transactionId = null) { |
|
let transaction = { |
|
updates: {} |
|
}; |
|
if (transactionId === null) { |
|
transactionId = guidFor(transaction); |
|
} |
|
set(this, `_transactions.${transactionId}`, transaction); |
|
this.debug(`created transaction ${transactionId}`); |
|
return transactionId; |
|
}, |
|
|
|
/** |
|
* Commits the requested transactionId into firebase |
|
* @param {string} transactionId |
|
*/ |
|
|
|
async commitTransaction(transactionId) { |
|
this.debug(`committing transaction ${transactionId}`); |
|
let updates = get(this, `_transactions.${transactionId}.updates`); |
|
assert(`Transaction ID ${transactionId} not found.`, typeof updates === 'object'); |
|
await this._performUpdates(updates); |
|
delete get(this, '_transactions').transactionId; |
|
this.debug(`transaction performed`); |
|
}, |
|
|
|
/** |
|
* Cancels the requested transactionId |
|
* @param {string} transactionId |
|
*/ |
|
|
|
cancelTransaction(transactionId) { |
|
this.debug(`cancelling transaction ${transactionId}`); |
|
delete get(this, '_transactions').transactionId; |
|
}, |
|
|
|
/** |
|
* Cancels all pending transactions |
|
*/ |
|
|
|
cancelAllTransactions() { |
|
this.debug('cancelling all transactions'); |
|
set(this, '_transactions', {}); |
|
}, |
|
|
|
/** |
|
* Link two records, there must be a relationship with an inverse between the two records |
|
* @param {DS.Model} recordA |
|
* @param {DS.Model} recordB |
|
* @param {string} transactionId optional transaction id |
|
*/ |
|
|
|
async linkRecords(recordA, recordB, transactionId = null) { |
|
return this._relateRecords(recordA, recordB, true, transactionId); |
|
}, |
|
|
|
/** |
|
* Unlink two records, there must be a relationship with an inverse between the two records |
|
* It should be safe to call this even if the records are not currently associated, it will just set null values for paths that don't exist anyway |
|
* @param {DS.Model} recordA |
|
* @param {DS.Model} recordB |
|
* @param {string} transactionId optional transaction id |
|
*/ |
|
|
|
async unlinkRecords(recordA, recordB, transactionId = null) { |
|
return this._relateRecords(recordA, recordB, false, transactionId); |
|
}, |
|
|
|
/** |
|
* Deletes a record and any updates other records that have an inverse relationship |
|
* @param {DS.Model} record An ember data record |
|
* @param {string} transactionId optional transaction id |
|
*/ |
|
|
|
async deleteRecord(record, transactionId = null) { |
|
|
|
let relationships = await this._getRelationshipsByName(record, true); |
|
|
|
let updateRefConfig = { |
|
sourceRecord: record, |
|
relationships: relationships, |
|
link: false, |
|
updateSource: false |
|
}; |
|
|
|
let updates = this._getUpdateRefs(updateRefConfig); |
|
|
|
// Also remove the path for the record itself |
|
let recordRef = record.ref(); |
|
let recordPath = this._pathForRef(recordRef); |
|
updates[recordPath] = null; |
|
/* Mark the record as deleted before removing it in Firebase. |
|
This prevents exception where a request for the object may |
|
be made to Firebase after the record has been removed which |
|
may fail depending on your Firebase rules |
|
*/ |
|
get(this, 'store').deleteRecord(record); |
|
await this._performUpdatesWithTransaction(updates, transactionId); |
|
}, |
|
|
|
/** |
|
* Updates a key on a record. Use in a transaction to roll multiple changes into a single update |
|
* |
|
* Note that the key will have '.' replaced with '/' to convert to the firebase path |
|
* |
|
* @param {DS.model} record An ember data record |
|
* @param {string} key The key on the model to use |
|
* @param {*} value The value to set |
|
* @param {string} transactionId optional transaction id - If you're not using transactions you're probably better off just updating the record directly |
|
*/ |
|
|
|
async setRecordValue(record, key, value, transactionId = null) { |
|
let recordRef = record.ref(); |
|
let recordPath = this._pathForRef(recordRef); |
|
let keyPath = key.replace('.', '/'); |
|
let updatePath = `${recordPath}/${keyPath}`; |
|
let update = { |
|
[updatePath]: value |
|
}; |
|
await this._performUpdatesWithTransaction(update, transactionId); |
|
}, |
|
|
|
/** |
|
* An ember concurrency task that is used by the warning dialog to perform the delete |
|
* @param {DS.Model} record The record to delete |
|
* @param {string} transactionId transaction id - can be null |
|
*/ |
|
|
|
_confirmDeleteRecord: task(function* (record, transactionId) { |
|
yield this.deleteRecord(record, transactionId); |
|
let applicationModal = this._applicationModal(); |
|
get(applicationModal, 'hide').perform(); |
|
}).drop(), |
|
|
|
/** |
|
* An ember concurrency task that is used by the warning dialog to cancel the delete |
|
*/ |
|
_cancelDeleteRecord: task(function* () { |
|
let applicationModal = this._applicationModal(); |
|
get(applicationModal, 'hide').perform(); |
|
}).drop(), |
|
|
|
|
|
/** |
|
* Returns the application modal |
|
*/ |
|
|
|
_applicationModal() { |
|
let owner = getOwner(this); |
|
return owner.lookup('modal:application-modal'); |
|
}, |
|
|
|
/** |
|
* Links or unlinks two records |
|
* @param {DS.Model} recordA |
|
* @param {DS.Model} recordB |
|
* @param {boolean} link - True to link the records, false to remove the link |
|
* @param {string} transactionId transaction id - can be null |
|
*/ |
|
|
|
async _relateRecords(recordA, recordB, link, transactionId) { |
|
|
|
let relationships = await this._getRelationshipsByName(recordA, false); |
|
|
|
/* Within relationships we need to find a relationship to a model of the type of recordB that also has an inverse |
|
Then push recordB onto the (empty) relatedRecords array for the relationship |
|
*/ |
|
let foundRelationship = false; |
|
|
|
let recordAModelName = recordA.constructor.modelName; |
|
let recordBModelName = recordB.constructor.modelName; |
|
this.debug(`Linking ${recordAModelName} to ${recordBModelName}`); |
|
|
|
relationships.forEach(relationship => { |
|
if (relationship.type === recordBModelName && relationship.inverse) { |
|
this.debug(`Found relationship: ${recordAModelName}.${relationship.key}:${relationship.kind} <-> ${relationship.inverse.kind}:${recordBModelName}.${relationship.inverse.name}`); |
|
relationship.relatedRecords.pushObject(recordB); |
|
foundRelationship = true; |
|
} |
|
}); |
|
|
|
assert(`Did not find a relationship with an inverse between ${recordA.constructor.modelName} and ${recordB.constructor.modelName}`, foundRelationship); |
|
|
|
let updateRefConfig = { |
|
sourceRecord: recordA, |
|
relationships: relationships, |
|
link: link, |
|
updateSource: true |
|
}; |
|
|
|
let updates = this._getUpdateRefs(updateRefConfig); |
|
await this._performUpdatesWithTransaction(updates, transactionId); |
|
|
|
}, |
|
|
|
/** |
|
* Given the specified configuration returns the updates required |
|
* @param {object} config |
|
* @param {DS.Model} config.sourceRecord the source record |
|
* @param {result of _relatedRecords} config.relationships the relationships |
|
* @param {boolean} config.link true if linking, false otherwise |
|
* @param {boolean} config.updateSource if true the source record will have its relationship updated, if false it will not |
|
* There is no need to delete the source relationship if the record is being deleted anyway |
|
* @returns {object} Keys are firebase paths, values are the values to set, this is appropriate for passing to a firebase 'update' |
|
*/ |
|
|
|
_getUpdateRefs(config) { |
|
|
|
let sourceRecord = config.sourceRecord; |
|
let relationships = config.relationships; |
|
let updates = {}; |
|
|
|
relationships.forEach(relationship => { |
|
|
|
let relatedRecords = relationship.relatedRecords; |
|
|
|
for (let relatedRecord of relatedRecords) { |
|
let relatedRef = relatedRecord.ref(); |
|
let relatedPath = this._pathForRef(relatedRef); |
|
let inverse = get(relationship, 'inverse'); |
|
if (inverse.kind === 'belongsTo') { |
|
let keyPath = `${relatedPath}/${inverse.name}`; |
|
if (config.link) { |
|
updates[keyPath] = sourceRecord.id; |
|
} else { |
|
updates[keyPath] = null; |
|
} |
|
} else if (inverse.kind === 'hasMany') { |
|
let keyPath = `${relatedPath}/${inverse.name}/${sourceRecord.id}`; |
|
if (config.link) { |
|
updates[keyPath] = true; |
|
} else { |
|
updates[keyPath] = null; |
|
} |
|
} else { |
|
assert(`Unkown inverse relationship type found: [${inverse.kind}] expected 'hasMany' or 'belongsTo'`); |
|
} |
|
if (config.updateSource) { |
|
// Add the updates required to update the relationships on the source record |
|
let sourceRef = sourceRecord.ref(); |
|
let sourcePath = this._pathForRef(sourceRef); |
|
if (relationship.kind === 'belongsTo') { |
|
let keyPath = `${sourcePath}/${relationship.key}`; |
|
if (config.link) { |
|
updates[keyPath] = relatedRecord.id; |
|
} else { |
|
updates[keyPath] = null; |
|
} |
|
} else if (relationship.kind === 'hasMany') { |
|
let keyPath = `${sourcePath}/${relationship.key}/${relatedRecord.id}`; |
|
if (config.link) { |
|
updates[keyPath] = true; |
|
} else { |
|
updates[keyPath] = null; |
|
} |
|
} else { |
|
assert(`Unkown relationship type found: [${relationship.kind}] expected 'hasMany' or 'belongsTo'`); |
|
} |
|
|
|
} |
|
} |
|
|
|
}); |
|
return updates; |
|
|
|
}, |
|
|
|
/** |
|
* Returns the relationshipsByName for the model of the record with additional properties per name |
|
* - relatedRecords - The records that have an inverse relationship to this record (optionally) |
|
* - inverse - The details of the inverse relationship |
|
* @private |
|
* @param {DS.Model} record |
|
* @param {DS.Model} includeRecords - Whether to include the records |
|
*/ |
|
|
|
async _getRelationshipsByName(record, includeRecords) { |
|
|
|
let store = record.store; |
|
|
|
// relationships is an Ember.Map |
|
let relationships = get(record, 'constructor.relationshipsByName'); |
|
|
|
/* Add the keys for relationships to an array that we can map over |
|
The Ember.Map does not have a 'map' method. |
|
*/ |
|
let relationshipKeys = []; |
|
relationships.forEach(relationship => relationshipKeys.pushObject(relationship.key)); |
|
|
|
await Promise.all(relationshipKeys.map(async key => { |
|
|
|
/* eslint-disable ember/use-ember-get-and-set */ |
|
// Ember.get does not work on Ember.Map object (for some reason!, or I am doing it wrong) |
|
let relationship = relationships.get(key); |
|
/* eslint-enable ember/use-ember-get-and-set */ |
|
/* |
|
Relationship has |
|
key: the property name, eg 'createdBy' |
|
kind: the type of relationship, eg 'belongsTo', |
|
name: A human readable version of kind eg 'Belongs To', |
|
type: the type it is related to, eg 'user' |
|
options: The options configured ont the relationship |
|
*/ |
|
|
|
let relatedRecords = []; |
|
|
|
// Check to see if the relationship has an inverse, if not we don't need to do anything with the related records |
|
|
|
let inverse = store.modelFor(record.constructor.modelName).inverseFor(key, store); // { type: App.Message, name: 'owner', kind: 'belongsTo' } |
|
this.debug(`inverse is ${inverse}`); |
|
|
|
if (inverse && includeRecords) { |
|
/* |
|
Inverse has |
|
name: The name of the key on the other side of the relationship, eg 'locations' |
|
kind: The type of relatipnship, eg 'hasMany' |
|
*/ |
|
if (relationship.kind === 'belongsTo') { |
|
let id = get(record, `${key}.id`); |
|
if (id) { |
|
this.debug(`model has active relationship: ${id}`); |
|
let relatedRecord = await (get(record, key)); |
|
relatedRecords.pushObject(relatedRecord); |
|
this.debug(`belongsTo: key: ${key} target: ${id}`); |
|
} |
|
} else if (relationship.kind === 'hasMany') { |
|
await Promise.all(get(record, key).map(async relatedRecord => { |
|
relatedRecords.pushObject(await relatedRecord); |
|
})); |
|
} else { |
|
assert(`Unkown relationship type found: [${relationship.kind}] expected 'hasMany' or 'belongsTo'`); |
|
} |
|
|
|
this.debug(`Found and loaded ${relatedRecords.length} related records for key: ${key}`); |
|
|
|
} |
|
|
|
set(relationship, 'relatedRecords', relatedRecords); |
|
set(relationship, 'inverse', inverse); |
|
|
|
})); |
|
|
|
return relationships; |
|
}, |
|
|
|
/** |
|
* Either adds the updates to the transaction or applies them immediately if transaction is null |
|
* @param {object} updates - A hash of keys and values to push to firebase |
|
* @param {string} transactionId - A transaction id. If null the updates will be applied immediately |
|
*/ |
|
|
|
async _performUpdatesWithTransaction(updates, transactionId) { |
|
if (transactionId === null) { |
|
await this._performUpdates(updates); |
|
} else { |
|
let transactionUpdates = get(this, `_transactions.${transactionId}.updates`); |
|
assert(`Transaction ID ${transactionId} not found.`, typeof transactionUpdates === 'object'); |
|
Object.assign(transactionUpdates, updates); |
|
set(this, `_transactions.${transactionId}.updates`, transactionUpdates); |
|
this.debug('added updates to transaction'); |
|
} |
|
}, |
|
|
|
/** |
|
* Applies the requested updates to firebase |
|
* @param {object} updates - A hash of keys and values to push to firebase |
|
*/ |
|
|
|
async _performUpdates(updates) { |
|
|
|
this.debug(updates); |
|
let db = get(this, 'firebaseApp').database(); |
|
await db.ref('/').update(updates); |
|
this.debug('applied updates in firebase'); |
|
|
|
}, |
|
|
|
/** |
|
* Returns the path to a ref as a string |
|
* @param {firebase.database.Reference} ref |
|
* @returns {string} Path to the reference |
|
*/ |
|
|
|
_pathForRef(ref) { |
|
let path = ref.key; |
|
let parent = ref.parent; |
|
while (parent.key) { |
|
path = `${parent.key}/${path}`; |
|
parent = parent.parent; |
|
} |
|
return path; |
|
}, |
|
|
|
_dataForConfirmationModal() { |
|
// Take care here, 'this' will be in the context of the modal |
|
// As such warnings, confirmTask and record must be set on the modal itself |
|
return { |
|
display: false, |
|
model: get(this, 'record'), |
|
title: 'Warning!', |
|
layout: 'vertical', |
|
formElements: [ |
|
{ |
|
controlType: 'component', |
|
component: 'warning/record-delete', |
|
options: get(this, 'warnings') |
|
} |
|
], |
|
submitButtonId: 'confirm', |
|
buttons: [ |
|
{ |
|
id: 'cancel', |
|
text: { |
|
default: 'Cancel', |
|
active: 'Please Wait' |
|
}, |
|
type: 'secondary', |
|
handler: { |
|
type: 'task', |
|
action: get(this, 'cancelTask'), |
|
arguments: [], |
|
returnsPromise: true |
|
}, |
|
state: 'enabled' |
|
}, |
|
{ |
|
id: 'confirm', |
|
text: { |
|
default: 'Confirm', |
|
active: 'Please Wait' |
|
}, |
|
type: 'danger', |
|
handler: { |
|
type: 'task', |
|
action: get(this, 'confirmTask'), |
|
arguments: [get(this, 'record'), get(this, 'transactionId')], |
|
returnsPromise: true |
|
}, |
|
state: 'enabled' |
|
} |
|
] |
|
}; |
|
|
|
} |
|
|
|
}); |