Skip to content

Instantly share code, notes, and snippets.

@samuelantonioli
Last active March 6, 2021 02:06
Show Gist options
  • Save samuelantonioli/50b01142ce15d8a069fc23ea8b975873 to your computer and use it in GitHub Desktop.
Save samuelantonioli/50b01142ce15d8a069fc23ea8b975873 to your computer and use it in GitHub Desktop.
Vue.js and Parse.Object
/**
*
* The problem:
* Parse SDK JS uses a Backbone.Model-inspired API with .get/.set
* Vue.js doesn't know how to interact with it because it works with POJOs.
*
* It's hard to change both codebases so they work together.
* (failed project that tried to do that: https://github.com/parse-community/ParseReact)
* So the solution has to proxy between an POJO (using Object.defineProperty) and Parse.Object.
*
* The solution:
* I've created a layer called ParseObjectPOJO which bridges attributes from a Parse.Object to
* a POJO which can be used in a normal way i.e. "var m = ModelVM(); m.title = 'something';" (or "v-model='m.title';").
* Parse.Object provides toJSON() methods for all its types. It can therefore serialize all of its
* data to POJOs which can be used normally. The bridging happens when we use the original
* Parse.Object (under "m.$p"), when we save the model ("m.$save().then(...)") and when we fetch
* it ("m.$fetch().then(...)"). The POJO and the Parse.Object get synchronized so they contain the same values.
*
*
* I love Vue.js and parse-server because they speed up the development especially for rapid prototyping.
* But I always had the problem that I had to use the REST API directly and transform all the data instead
* of using the developer-friendly Parse SDK with Parse.Query and others.
* Now I can use both tools without getting problems with Vue's two-way-binding.
*
* It's not a perfect solution (many toJSON- and defineProperty-calls and you have to know which fields
* get populated so you don't miss out on IE support - otherwise it could be done using ES6-Proxy),
* but it's definitely better than doing data transformations and using the REST API.
*
**/
// Your Parse.Object Model (example from docs.parseplatform.org)
var Monster = Parse.Object.extend("Monster", {
// Instance methods
hasSuperHumanStrength: function () {
return this.get("strength") > 18;
},
// Instance properties go in an initialize method
initialize: function (attrs, options) {
this.sound = 'Rawr';
},
}, {
// Class methods
spawn: function(strength) {
var monster = new Monster();
monster.set('strength', strength);
return monster;
},
});
// example vue instance (to see the data synchronisation)
var vue_instance = new Vue({
data: {
inst: null,
loading: true,
},
});
vue_instance.$watch('inst', function(val) {
console.log('watch triggered');
console.log(val);
});
////////////////////////////////////////////////////////////////////////////////////
var MonsterVM = ParseViewModel(Monster, [
'title', 'age', 'file',
]);
// which attributes are there? (necessary for defineProperty)
// create an instance (without id: new one, with id: existing one)
var m = MonsterVM('v5aR6P2zpO');
// sync the model instance with the vue instance
// so we can use e.g. v-model="inst.title"
m.$sync(vue_instance, 'inst');
// load the model data from the server
m.$fetch().then(function() {
vue_instance.loading = false;
});
// -> vue_instance.inst gets automatically updated (through m.$sync)
// clone the object to work safely on it (deep clone)
var working_copy = m.$clone();
working_copy.title = 'a new title';
// works with files (uploads on working_copy.$save call)
var input_file = document.querySelector('#file').files[0];
working_copy.file = new Parse.File(input_file.name, input_file);
// and with ACLs
var acl = new Parse.ACL(Parse.User.current());
acl.setRoleWriteAccess('editor', true);
acl.setRoleReadAccess('editor', true);
working_copy.$acl(acl);
// get the current acl:
// var current_acl = working_copy.$acl();
// we can also work with the original Parse.Object (under .$p)
working_copy.$p.set('age', 55);
working_copy.$p.set('title', 'another title');
// but when we use this, we have to call .$update() after those calls to tell the view model
// that we've changed some attributes on the Parse.Object:
working_copy.$update();
working_copy.age == 55;
// same works for the other direction
// it gets automatically reflected on the model while using .$p (internally it's a function)
working_copy.age = 67;
working_copy.$p.get('age') == 67;
// done with it. we can update the original object:
m.$update(working_copy);
m.$save();
// or we can continue to use the working copy
working_copy.$save().then(function() {
alert('everything\'s saved!');
});
// using Parse.Query
var query = new Parse.Query(Monster);
query.find().then(function(results) {
results = results.map(MonsterVM);
console.log(results);
});
/**
*
* Vue.js and Parse.Object
*
* Transform Parse.Object into POJO so we can use it in Vue.js
* Make it possible to use the Parse.Object and sync changes to the model data.
* Easily $fetch and $save model data.
*
* needs:
* - Lodash
* - Parse SDK JS
* - Vue.js
*
**/
function ParseObjectPOJO(obj, cloned) {
// keep track of changed attributes and attribute names to watch
// ob: use observable to proxy data
// sync: variables to set when updating data
var changed = {}, attrs = {}, data = {}, ob = {}, sync = [];
if (cloned) {
if (cloned.data) data = cloned.data;
if (cloned.attrs) attrs = cloned.attrs;
}
var update_data = function(obj) {
var _data = obj.toJSON();
delete _data['updatedAt'];
delete _data['createdAt'];
// special attributes (clone them):
_data.objectId = obj.id ? ''+obj.id : undefined;
_data.className = obj.className ? ''+obj.className : undefined;
//_data.updatedAt = obj.updatedAt;
//_data.createdAt = obj.createdAt;
Object.keys(_data).forEach(function(key) {
data[key] = _data[key];
});
//data = _data;
sync_handler();
},
sync_handler = function() {
// sync changes to variables (they use defineProperty internally)
sync.forEach(function(s) {
Vue.set(s[0], s[1], ob);
});
};
// convert json back to Parse.Object (only changed attributes)
var parse_obj;
var toParseObject = function() {
var use_parse_obj = false;
if (
parse_obj &&
parse_obj.objectId == data.objectId &&
parse_obj.className == data.className
) {
use_parse_obj = true;
}
// https://github.com/parse-community/Parse-SDK-JS/issues/161
var new_data = {
objectId: data.objectId,
className: data.className,
};
Object.keys(changed).forEach(function(changed_key) {
if (use_parse_obj) {
// reflect changes on parse_obj
parse_obj.set(changed_key, data[changed_key]);
} else {
new_data[changed_key] = data[changed_key];
}
});
changed = {};
if (use_parse_obj) {
// sync from Parse.Object to data
var dirty_keys = parse_obj.dirtyKeys();
dirty_keys.forEach(function(dirty_key) {
data[dirty_key] = parse_obj.get(dirty_key);
});
sync_handler();
return parse_obj;
} else {
parse_obj = new Parse.Object(new_data);
return parse_obj;
}
},
destroy = function() {
// reset data structures
ob = {};
data = {};
changed = {};
attrs = {};
sync = [];
};
// can't use normal assignment because it goes to the JSON structure
Object.defineProperty(ob, '$p', {
get: toParseObject,
enumerable: false,
configurable: false,
});
// watch for changes and save changes in 'changed'
var watch = function(keys) {
keys.forEach(function(key) {
if (!data.hasOwnProperty(key)) {
data[key] = null;
}
Object.defineProperty(ob, key, {
get: function() {
return data[key];
},
set: function(val) {
if (!changed[key]) {
changed[key] = true;
}
return data[key] = val;
},
enumerable: true,
configurable: true,
});
});
// comment out if you're not using Vue.js
// this line is crucial for Vue.js!
// without this line it doesn't synchronize with Vue.js
// and everything stops working
new Vue({
data: function() {
return {data: ob.$data};
},
});
},
add_attrs = function() {
// call this with all the attribute names you want to watch
var attrs_names = Array.from(arguments);
attrs_names.forEach(function(name) {
attrs[name] = true;
});
return watch(attrs_names);
},
update_watch = function() {
watch(Object.keys(data).concat(Object.keys(attrs)));
};
// operations
var self = this;
var save = function() {
var p = ob.$p, promise = new Parse.Promise();
p.save.apply(p, Array.from(arguments)).then(function(data) {
update(data);
promise.resolve(ob);
}, function(err) {
promise.reject(err);
});
return promise;
},
fetch = function() {
var p = ob.$p, promise = new Parse.Promise();
p.fetch.apply(p, Array.from(arguments)).then(function(data) {
update(data);
promise.resolve(ob);
}, function(err) {
promise.reject(err);
});
return promise;
},
_sync = function(target, key) {
sync.push([target, key]);
//variable = data;
// remove line if not using Vue.js
Vue.set(target, key, ob);
},
update = function(new_obj) {
// if vm.$p.set('somekey', val) was used, tell it here
// using vm.$update() so we can check it.
if ((!new_obj) && parse_obj) {
new_obj = parse_obj;
} else if (!new_obj) {
return;
}
if (new_obj.hasOwnProperty('$p')) {
// unfortunately we can't use instanceof bc of ob.defineProperty
// (would need a bigger refactoring)
new_obj = new_obj.$p;
}
update_data(new_obj);
update_watch();
},
set_acl = function(acl) {
if (!acl) {
return (new Parse.ACL(data['ACL'])).toJSON();
} else if (acl instanceof Parse.ACL) {
changed['ACL'] = true;
data.ACL = acl.toJSON();
}
},
clone = function() {
// use the following line if you don't use Vue.js:
// return new ParseObjectPOJO(ob.$p);
return new ParseObjectPOJO(ob.$p, {
attrs: _.cloneDeep(attrs),
data: _.cloneDeep(data),
});
// TODO: try/except for "Error: Tried to encode an unsaved file."
},
get_data = function() {
return data;
};
// internal helper functions
var add_functions = function(fns) {
Object.keys(fns).forEach(function(fn) {
var call = fns[fn];
Object.defineProperty(ob, fn, {
get: function() {
return call;
},
enumerable: false,
configurable: false,
});
});
}
// init
update_data(obj);
update_watch();
add_functions({
'$destroy': destroy,
'$attrs': add_attrs,
//
'$sync': _sync,
'$update': update,
'$acl': set_acl,
'$clone': clone,
'$data': get_data(),
//
'$save': save,
'$fetch': fetch,
});
if (cloned) {
new Vue({
data: function() {
return {data: ob.$data};
},
});
}
return ob;
}
function ParseViewModel(model, attrs) {
return function(_id) {
var m;
if (_id instanceof model) {
m = _id;
} else if (typeof(_id) === 'string') {
m = model.createWithoutData(_id);
} else {
m = new model();
}
var vm = new ParseObjectPOJO(m);
vm.$attrs.apply(vm, attrs);
return vm;
};
}
@tremendus
Copy link

tremendus commented Mar 3, 2021 via email

@tremendus
Copy link

So, this is what I came up with after some thinking and tinkering.

https://runkit.com/tremendus/parse-experiments---model-proxy

  • create a JS object that sits as a proxy (note: not a new Proxy()) between vue's data and the parse object
  • place an unfrozen pojo data object on this proxy so vue can react to it
  • proxy the parse getters/setters and methods to make sure that anytime the parse object's data is affected, so too is the proxy's data object
  • bind (mount?) the unfrozen pojo on a vue instance
  • hey presto! - reactive data

This is a minimal effort that keeps the simplicity of Api of the Parse.Object interface, while adding the missing reactivity for Vue without serialization using toJSON() and funky stuff like that.

CAVEAT: there is a little bit of work to do representing object relations and ACLs - basically on affecting a key that is a parse object (or intercepting methods that affect a relation (eg setACL), serialize the resulting change on the proxy's data object

Did I miss anything? Any gotchyas you can think of?

BTW: it's worth mentioning that you'd need to call a 'set' method on the vue-modified/reactive data object on change in order to trigger the proper update on the ParseObject, eg:

input(type='text', v-model='doc.data.label', @change='onChange("label")')

If you test this in the browser, you get immediate reactivity when you type into the input, but the Parse update only occurs onChange, which isn't shown but triggers the proxy's set method

@dblythy
Copy link

dblythy commented Mar 5, 2021

I'm currently working through this too. My main problem seems to be that Vue doesn't like Proxy objects as Vue tries to convert any data() to a Proxy.

class Monster extends Parse.Object {
    constructor() {
      super('Monster');
      return new Proxy(this, {
        get(target, property) {
          let result = target.get(property);
          if (!result) {
            result = target[property];
          }
          return result;
        },
        set(target, property, value) {
          const internal = ['id','className','createdAt','updatedAt']
          if (property.includes('_') || internal.includes(property)) {
            target[property] = value;
          } else {
            for (const sub of target.__ob__.dep.subs) {
              for (const option in sub.vm) {
                const tg = Object.assign({}, sub.vm[option]);
                if (!tg) {
                  continue;
                }
                if (tg.id === target.id || (tg._objCount === target._objCount && tg.className === target.className)) {
                  sub.vm[option] = target;
                }
              }
            }
          }
          return true;
        }
      }) 
    }
    async save() {
      const internal = ['id','className','createdAt','updatedAt', 'ACL']
      for (const key in this) {
        if (internal.includes(key)) {
          continue;
        }
        this.set(key, this[key]);
      }
      await super.save();
    }
  }

@tremendus
Copy link

Yup, I agree. Its probably always going to be a fiddle working with proxies - I noted that they don't behave as expected when working with nested paths. If your data is all flat, you wouldn't notice but if you any type of nested fields, it's a bomb.

Since posting this runkit solution this morning, I'm having really good success with that approach, FYI. Might want to look in that direction. I'll probably finish it up in the coming weeks and post it on GitHub.

But the question has to be asked - why don't the Parse guys make a reactive object available. With the popularity of React/Vue and which both need reactive objects and the ease of use of Parse, it's a doozy! I couldn't untangle the source the source code quick enough for my needs this morning, but someone familiar with it could create one in the internals pretty quick I would think.

@dblythy
Copy link

dblythy commented Mar 6, 2021

I have submitted 73 PRs to Parse as a regular contributor - so I might be able to give some insight.

I would guess:

  • The only way to convert .get(key) to .key is with a Proxy. And as discussed, because VueJS wraps all data in a Proxy, this doesn't work. I don't think there's a workaround for this.
  • Migrating the core JS to Proxy / work with Vue natively might have unwanted performance or support issues on other platforms (e.g, Proxy isn't supported on IE)
  • VueJS doesn't recommend setting class objects to data(), only flat, simple data. Parse Objects are complex and have a lot of properties and methods.

I have simplified your solution a little bit by overriding _finishFetch. Have a look if you're interested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment