diff --git a/firebase-firestore-mixin.html b/firebase-firestore-mixin.html
index ac0258c..3b58764 100644
--- a/firebase-firestore-mixin.html
+++ b/firebase-firestore-mixin.html
@@ -5,11 +5,10 @@
}
{
+ const CONSTRUCTOR_TOKEN = Symbol('polymerfire-firestore-mixin-constructor');
+ const CONNECTED_CALLBACK_TOKEN =
+ Symbol('polymerfire-firestore-mixin-connected-callback');
const PROPERTY_BINDING_REGEXP = /{([^{]+)}/g;
- const TRANSFORMS = {
- doc: function(snap) { return iDoc(snap); },
- collection: function(snap) { return snap.empty ? [] : snap.docs.map(doc => iDoc(doc)) }
- }
const isOdd = (x) => x & 1 === 1;
@@ -31,14 +30,14 @@
return whole;
}
- const collect = (what, which) => {
- let res = {};
- while (what) {
- res = Object.assign({}, what[which], res); // Respect prototype priority
- what = Object.getPrototypeOf(what);
- }
- return res;
- };
+ const collect = (what, which) => {
+ let res = {};
+ while (what) {
+ res = Object.assign({}, what[which], res); // Respect prototype priority
+ what = Object.getPrototypeOf(what);
+ }
+ return res;
+ };
const iDoc = (snap) => {
if (snap.exists) {
@@ -48,6 +47,11 @@
}
}
+ const TRANSFORMS = {
+ doc: iDoc,
+ collection: (snap) => snap.empty ? [] : snap.docs.map(iDoc),
+ }
+
/**
* This mixin provides bindings to documents and collections in a
* Cloud Firestore database through special property declarations.
@@ -131,82 +135,167 @@
*/
Polymer.FirestoreMixin = parent => {
return class extends parent {
+ static _assertPropertyTypeCorrectness(prop) {
+ const errorMessage = (listenerType, propertyType) =>
+ `FirestoreMixin's ${listenerType} can only be used with properties ` +
+ `of type ${propertyType}.`;
+ const assert = (listenerType, propertyType) => {
+ if (prop[listenerType] !== undefined && prop.type !== propertyType) {
+ throw new Error(errorMessage(listenerType, propertyType.name));
+ }
+ }
+
+ assert('doc', Object);
+ assert('collection', Array);
+ }
+
constructor() {
super();
+
+ if (this[CONSTRUCTOR_TOKEN] === true) {
+ return;
+ }
+ this[CONSTRUCTOR_TOKEN] = true;
+
this._firestoreProps = {};
this._firestoreListeners = {};
this.db = this.constructor.db || firebase.firestore();
}
connectedCallback() {
+ if (this[CONNECTED_CALLBACK_TOKEN] === true) {
+ return;
+ }
+ this[CONNECTED_CALLBACK_TOKEN] = true;
+
const props = collect(this.constructor, 'properties');
+ Object
+ .values(props)
+ .forEach(this.constructor._assertPropertyTypeCorrectness);
+
for (let name in props) {
- if (props[name].doc) {
- this._firestoreBind('doc', props[name].doc, name, props[name].live, props[name].observes);
- } else if (props[name].collection) {
- this._firestoreBind('collection', props[name].collection, name, props[name].live, props[name].observes);
+ const options = props[name];
+ if (options.doc || options.collection) {
+ this._firestoreBind(name, options);
}
}
super.connectedCallback();
}
- _firestoreBind(type, path, name, live = false, observes = []) {
- const config = parsePath(path);
- config.observes = observes;
- config.live = live;
+ _firestoreBind(name, options) {
+ const defaults = {
+ live: false,
+ observes: [],
+ }
+ const parsedPath = parsePath(options.doc || options.collection);
+ const config = Object.assign({}, defaults, options, parsedPath);
+ const type = config.type =
+ config.doc ? 'doc' : config.collection ? 'collection' : undefined;
this._firestoreProps[name] = config;
- // Create a method observer that will be called every time a templatized or observed property changes
- let args = config.props.concat(config.observes).join(',');
- if (args.length) { args = ',' + args; }
- this._createMethodObserver(`_firestoreUpdateBinding('${type}','${name}'${args})`);
-
- if (!config.props.length && !config.observes.length) {
- this._firestoreUpdateBinding(type,name);
+ const args = config.props.concat(config.observes);
+ if (args.length > 0) {
+ // Create a method observer that will be called every time
+ // a templatized or observed property changes
+ const observer =
+ `_firestoreUpdateBinding('${name}', ${args.join(',')})`
+ this._createMethodObserver(observer);
}
+
+ this._firestoreUpdateBinding(name, ...args.map(x => this[x]));
}
- _firestoreUpdateBinding(type, name) {
+ _firestoreUpdateBinding(name, ...args) {
this._firestoreUnlisten(name);
const config = this._firestoreProps[name];
- const propArgs = Array.prototype.slice.call(arguments, 2, config.props.length + 2).filter(arg => arg);
- const observesArgs = Array.prototype.slice.call(arguments, config.props.length + 2).filter(arg => arg);
+ const isDefined = (x) => x !== undefined;
+ const propArgs = args.slice(0, config.props.length).filter(isDefined);
+ const observesArgs = args.slice(config.props.length).filter(isDefined);
- if (propArgs.length < config.props.length || observesArgs.length < config.observes.length) {
- this[name] = null;
- this[name + 'Ref'] = null;
- this[name + 'Ready'] = false;
- return;
- }
+ const propArgsReady = propArgs.length === config.props.length;
+ const observesArgsReady =
+ observesArgs.length === config.observes.length;
- const collPath = stitch(config.literals, propArgs);
- const assigner = snap => {
- this[name] = TRANSFORMS[type](snap);
- this[name + 'Ready'] = true;
- }
+ if (propArgsReady && observesArgsReady) {
+ const collPath = stitch(config.literals, propArgs);
+ const assigner = this._firestoreAssigner(name, config.type);
- let ref = this.db[type](collPath);
- this[name + 'Ref'] = ref;
- this[name + 'Ready'] = false;
+ let ref = this.db[config.type](collPath);
+ this[name + 'Ref'] = ref;
- if (config.query) {
- ref = config.query.call(this, ref, this);
- }
+ if (config.query) {
+ ref = config.query.call(this, ref, this);
+ }
- if (config.live) {
- this._firestoreListeners[name] = ref.onSnapshot(assigner);
- } else {
- ref.get().then(assigner);
+ if (config.live) {
+ this._firestoreListeners[name] = ref.onSnapshot(assigner);
+ } else {
+ ref.get().then(assigner);
+ }
}
}
- _firestoreUnlisten(name) {
+ _firestoreUnlisten(name, type) {
if (this._firestoreListeners[name]) {
this._firestoreListeners[name]();
delete this._firestoreListeners[name];
}
+
+
+ this.setProperties({
+ [name]: type === 'collection' ? [] : null,
+ [name + 'Ref']: null,
+ [name + 'Ready']: false,
+ })
+ }
+
+ _firestoreAssigner(name, type) {
+ const makeAssigner = (assigner) => (snap) => {
+ assigner.call(this, name, snap);
+ this[name + 'Ready'] = true;
+ }
+ if (type === 'doc') {
+ return makeAssigner(this._firestoreAssignDocument);
+ } else if (type === 'collection') {
+ return makeAssigner(this._firestoreAssignCollection);
+ } else {
+ throw new Error('Unknown listener type.');
+ }
+ }
+
+ _firestoreAssignDocument(name, snap) {
+ this[name] = iDoc(snap);
+ }
+
+ _firestoreAssignCollection(name, snap) {
+ const propertyValueIsArray = Array.isArray(this[name])
+ const allDocumentsChanged = snap.docs.length === snap.docChanges.length;
+ if (propertyValueIsArray && allDocumentsChanged === false) {
+ snap.docChanges.forEach((change) => {
+ switch (change.type) {
+ case 'added':
+ this.splice(name, change.newIndex, 0, iDoc(change.doc));
+ break;
+ case 'removed':
+ this.splice(name, change.oldIndex, 1);
+ break;
+ case 'modified':
+ if (change.oldIndex === change.newIndex) {
+ this.splice(name, change.oldIndex, 1, iDoc(change.doc));
+ } else {
+ this.splice(name, change.oldIndex, 1);
+ this.splice(name, change.newIndex, 0, iDoc(change.doc));
+ }
+ break;
+ default:
+ throw new Error(`Unhandled document change: ${change.type}.`);
+ }
+ });
+ } else {
+ this[name] = snap.docs.map(iDoc);
+ }
}
}
}