Variables staying null in vue.js application for shopify - javascript

I am building up a vue.js application for Shopify's JavaScript Buy SDK, but i am having problems with one variable not being updated.
Basically the shopClient variable is updated, but the shopCart stays null for some reason.
var vueApp = new Vue({
el: '#shopify-app',
created: function() {
this.setupShopAndCart();
},
data: {
shopCart: null,
shopClient: null,
},
methods: {
setupShopAndCart: function() {
this.shopClient = ShopifyBuy.buildClient({
apiKey: 'xxx',
domain: 'xxx.myshopify.com',
appId: '6'
});
if(localStorage.getItem('lastCartId')) {
this.shopClient.fetchCart(localStorage.getItem('lastCartId')).then(function(remoteCart) {
this.shopCart = remoteCart;
cartLineItemCount = this.shopCart.lineItems.length;
console.log(this.shopCart.checkoutUrl);
console.log("fetching");
});
} else {
this.shopClient.createCart().then(function (newCart) {
this.shopCart = newCart;
localStorage.setItem('lastCartId', this.shopCart.id);
cartLineItemCount = 0;
console.log(this.shopCart.checkoutUrl);
console.log("failing");
});
}
}, //setupShop end
}
});

You have a problem with scoping. this in the promise isn't the vue instance.
try this
var vueApp = new Vue({
el: '#shopify-app',
created: function() {
this.setupShopAndCart();
},
data: {
shopCart: null,
shopClient: null,
},
methods: {
setupShopAndCart: function() {
var self = this;
this.shopClient = ShopifyBuy.buildClient(
{
apiKey: 'xxx',
domain: 'xxx.myshopify.com',
appId: '6'
}
);
if(localStorage.getItem('lastCartId')) {
this.shopClient.fetchCart(localStorage.getItem('lastCartId')).then(
function(remoteCart) {
self.shopCart = remoteCart;
cartLineItemCount = self.shopCart.lineItems.length;
console.log(self.shopCart.checkoutUrl);
console.log("fetching");
}
);
} else {
this.shopClient.createCart().then(
function (newCart) {
self.shopCart = newCart;
localStorage.setItem('lastCartId', self.shopCart.id);
cartLineItemCount = 0;
console.log(self.shopCart.checkoutUrl);
console.log("failing");
}
);
}
}, //setupShop end
}
});
That stores the local vue instance in the self variable that is accessable to the promises allowing you to set the shopCart variable.
EDIT: As indicated lambda functions are correct if using ES2015 or newer
var vueApp = new Vue({
el: '#shopify-app',
created: function() {
this.setupShopAndCart();
},
data: {
shopCart: null,
shopClient: null,
},
methods: {
setupShopAndCart: function() {
this.shopClient = ShopifyBuy.buildClient(
{
apiKey: 'xxx',
domain: 'xxx.myshopify.com',
appId: '6'
}
);
if(localStorage.getItem('lastCartId')) {
this.shopClient.fetchCart(localStorage.getItem('lastCartId')).then(
(remoteCart) => {
this.shopCart = remoteCart;
cartLineItemCount = this.shopCart.lineItems.length;
console.log(this.shopCart.checkoutUrl);
console.log("fetching");
}
);
} else {
this.shopClient.createCart().then(
(newCart) => {
this.shopCart = newCart;
localStorage.setItem('lastCartId', this.shopCart.id);
cartLineItemCount = 0;
console.log(this.shopCart.checkoutUrl);
console.log("failing");
}
);
}
}, //setupShop end
}
});

Related

Vuex store creates a new instance when imported in different modules

I'm struggling for a few days with importing my Vuex store into multiple different modules. For some reason it seems that my store is creating new instances for every import. In one Vue object i am setting a value to the store, but for an unknown reason to me it is not accessible in another Vue object. I have 3 files: store.js, addresses.js, and relation.js. In the store.js file the following code is present:
const Vue = require('vue').default;
const Vuex = require("vuex");
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
addresses: {
address: 'test'
}
},
mutations: {
setAddress(state, payload) {
state.addresses.address = payload;
}
},
actions: {
setAddress(state, payload) {
state.commit('setAddress', payload);
}
},
getters: {
getAddress(state) {
return state.addresses.address;
}
},
});
export default store;
My addresses.js contains:
import agent from '../agent.js';
const Vue = require("vue").default;
import __ from '../translations/translate';
import Swal from '../../../app-assets/vendors/js/extensions/sweetalert2.all.min'
import store from "../store";
var address = new Vue({
el: '#address-form',
data: {
address: {
id: null,
postal_code: '',
house_number: '',
house_number_addition: '',
street_name: '',
place: '',
country: defaultAddressCountry[0],
latitude: null,
longitude: null,
extra_address_line: null,
safety_instructions: null,
notes: null,
po_box_number: '',
is_po_box: false
},
address_countries: addressCountries,
inputErrors: {
postal_code: '',
house_number: '',
street_name: '',
place: '',
po_box_number: '',
}
},
methods: {
completeAddress: function(){
let self = this;
if(this.address.postal_code && this.address.house_number && this.address.postal_code.length > 3 && this.address.house_number.length > 0){
$('#complete-address i').addClass('rotate');
agent.Address.completeAddress(this.address.postal_code, this.address.house_number.toString() + this.address.house_number_addition, this.address.country)
.then(function(response){
self.address.street_name = response.data.address.street;
self.address.place = response.data.address.locality;
self.address.latitude = response.data.location.latitude;
self.address.longitude = response.data.location.longitude;
$('#complete-address i').removeClass('rotate');
self.validateFields();
}).catch(function(){
$('#complete-address').addClass('text-color-red');
setTimeout(function(){
$('#complete-address').removeClass('text-color-red');
}, 1000)
$('#complete-address i').removeClass('rotate');
self.address.street_name = null;
self.address.place = null;
self.address.latitude = null;
self.address.longitude = null;
});
}else{
if(!this.address.postal_code || this.address.postal_code.length <= 4){
$('#input-postal-code').addClass('has-error');
}
if(!this.address.house_number || this.address.house_number.length == 0){
$('#input-house-number').addClass('has-error');
}
setTimeout(function(){
$('#input-postal-code, #input-house-number').removeClass('has-error');
}, 500)
}
},
gotoMaps: function () {
open('https://maps.google.com/?q=' + this.address.latitude + ', ' + this.address.longitude);
},
sanitizeFields: function(){
this.address.postal_code = this.address.postal_code.replace(/[^0-9a-z]/gi, '').toUpperCase().substr(0, 6);
this.address.house_number = this.address.house_number.replace(/[^0-9]+/g, '').substr(0, 4);
this.address.house_number_addition = this.address.house_number_addition.replace(/[^0-9a-z]/gi, '').toUpperCase().substr(0, 2);
},
validateFields: function(){
let has_errors = false;
this.inputErrors.postal_code = '';
if(this.address.postal_code.length < 5){
this.inputErrors.postal_code = __('invalid input');
has_errors = true;
}
if(this.address.postal_code.length === 0){
this.inputErrors.postal_code = __('required');
has_errors = true;
}
this.inputErrors.house_number = '';
if(this.address.house_number.length === 0 && !this.address.is_po_box){
this.inputErrors.house_number = __('required');
has_errors = true;
}
this.inputErrors.street_name = '';
if(this.address.street_name.trim().length === 0 && !this.address.is_po_box){
this.inputErrors.street_name = __('required');
has_errors = true;
}
this.inputErrors.place = '';
if(this.address.place.trim().length === 0){
this.inputErrors.place = __('required');
has_errors = true;
}
this.inputErrors.po_box_number = '';
if(this.address.po_box_number.trim().length === 0 && this.address.is_po_box){
this.inputErrors.po_box_number = __('required');
has_errors = true;
}
return !has_errors;
},
saveAddress: function(){
if(this.address.is_po_box){
this.address.street_name = '';
this.address.house_number = '';
this.address.house_number_addition = '';
this.address.safety_instructions = null;
this.address.extra_address_line = null;
this.address.notes = null;
}else{
this.address.po_box_number = '';
}
if(this.validateFields()){
let self = this;
if(this.address.id) {
agent.Address.update(this.address);
}else{
agent.Address.create(this.address).then(function (result) {
if(result.status)
self.address = result.data;
}).catch(function(error){
if(error.response.status === 409){
let house_number = [error.response.data.house_number];
if(error.response.data.house_number_addition){
house_number.push(error.response.data.house_number_addition);
}
Swal.fire({
title: __('Existing address was found'),
icon: 'info',
html: '' +
'<table style="text-align:left;" class="table table-bordered">' +
'<tr>' +
'<td>'+__('Address')+'</td>' +
'<td>'+error.response.data.street_name+' ' + house_number.join('-') + '</td>' +
'</tr>' +
'<tr>' +
'<td>'+__('Postal code')+'</td>' +
'<td>'+error.response.data.postal_code+'</td>' +
'</tr>' +
'<tr>' +
'<td>'+__('Place')+'</td>' +
'<td>'+error.response.data.place+'</td>' +
'</tr>' +
'<tr>' +
'<td>'+__('Country')+'</td>' +
'<td>'+error.response.data.country+'</td>' +
'</tr>' +
'</table>',
customClass: 'swal-wide',
showCancelButton: true,
confirmButtonText: __('Use this address'),
cancelButtonText: __('No, create a new one'),
}).then(function(result){
if(result.value === true){
store.dispatch('setAddress', self.address)
console.log(store.state.addresses.address)
}
});
}
});
}
}
}
},
watch: {
address: {
handler(){
if(this.address.is_po_box){
$('.no-po-box').hide();
$('#extra-address-info').hide();
$('#show-address-extra').attr('data-expanded', 'false');
$('#show-address-extra').find('i').removeClass('icon-chevron-up').addClass('icon-chevron-down');
$('.is-po-box').show();
}else{
$('.no-po-box').show();
$('.is-po-box').hide();
}
this.sanitizeFields();
},
deep: true
}
},
computed: {
showMapsIcon: function(){
return this.address.latitude && this.address.longitude;
}
},
created(){
$(document).on('click', '.address-selector', function(){
$('#address-modal').modal();
});
$(document).on('click', '#show-address-extra', function(){
if($(this).attr('data-expanded') == 'false') {
$('#extra-address-info').slideDown();
$(this).attr('data-expanded', 'true');
$(this).find('i').removeClass('icon-chevron-down').addClass('icon-chevron-up');
}else{
$('#extra-address-info').slideUp();
$(this).attr('data-expanded', 'false');
$(this).find('i').removeClass('icon-chevron-up').addClass('icon-chevron-down');
}
});
$('.dataTable').DataTable();
},
delimiters: ['[[' , ']]']
});
export default address;
My relation.js file:
import agent from '../../agent.js'
const Vue = require("vue").default;
import store from "../../store";
var relation = new Vue({
el: '#upsert-relation-form',
data: {
relation: {
id: null,
name: null,
is_supplier: false,
is_customer: true,
is_prospect: false,
is_debtor: true,
general_email_address: null,
general_phone_number: defaultCountryCode,
communication_language: languages.find(x => x.code == defaultLanguage[0])['code'],
account_manager: null,
tags: [],
industries: [],
},
languages: languages,
account_managers: [
{
id: 1,
name: 'Piet de Vries'
},
{
id: 2,
name: 'Willem Aardappel'
}
]
},
computed: {
visiting_address(){
console.log(store.getters.getAddress);
return store.getters.getAddress;
}
},
methods: {
completeAddress: function(address){
console.log(address);
}
},
created(){
let self = this;
$(document).on('click', '.tags-container', function(){
$(this).find('input').focus();
});
$(document).on('click', '.tags-container li a.remove', function(){
let field = $(this).closest('.tags-container').attr('data-field');
let val = $(this).closest('li').text().trim();
let index = self.relation[field].findIndex(function(tag){
return tag.value == val;
});
self.relation[field].splice(index, 1);
});
$(document).on('keyup', '.tags-container input', function(e){
let field = $(this).closest('.tags-container').attr('data-field');
if(e.keyCode === 13){
let tagValue = $(this).val().trim().toLowerCase().replace(/[^a-zA-Z 0-9]+/g, '');
if(tagValue.length < 3){
return;
}
let index = self.relation[field].findIndex(function(tag){
return tag.value == tagValue;
});
if(index !== -1){
$(this).val('');
return;
}
self.relation[field].push({
id: null,
value: tagValue
});
$(this).val('');
}
});
$(document).on('keydown', '.tags-container input', function(e){
let field = $(this).closest('.tags-container').attr('data-field');
if(e.keyCode === 8 && $(this).val().trim().length === 0){
self.relation[field].pop();
}
});
},
delimiters: ['[[' , ']]']
});
export default relation;
In addresses.js I see that the value is correctly set to the store. But in the relation.js file it still gets the original data set on initiation of the store.
It's supposed to be done the other way around, you import the module in your vuex and not the vuex in your module.
So you should have a "Master module" that looks like this:
import Vue from 'vue';
///////////Vuex et store
import Vuex from 'vuex';
/////////////////Modules
import media from "./modules/media";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
},
getters: {
},
mutations: {
}
,
actions: {
},
modules: {
media
}
})
and then your module should simply be like so:
const state = {
};
const getters = {
};
const mutations = {
};
const actions = {
};
export default {
namespaced: true,
state,
getters,
actions,
mutations
};
The namespaced part is optional and here is the link to the doc:
https://vuex.vuejs.org/fr/guide/modules.html
In the doc it's organised in a single file, the way I presented it correspond to one file for the "Master Module" which is your store really, and then a file by module.

How to access data from a component in a method

Hallo I am stuck with the following:
I am working on a Vue application that consists of multiple component. Now i have a component that uses vee-validate. Only for a custom validation I want change a data element in the component data() object. Only if I try this the following Exception pops up: cannot set property 'kenteken' of undefined
The code is as follow:
<script>
import {Validator} from 'vee-validate';
import nl from 'vee-validate/dist/locale/nl';
const isKenteken = (value) =>{
app.loader.kenteken= true;
return axios.post('/api/validate/kenteken', {kenteken: value}).then((response) => {
// Notice that we return an object containing both a valid property and a data property.
app.loader.kenteken = false;
app.voertuig.merk = response.data.Algemeen.Merk;
app.voertuig.model = response.data.Algemeen.Type;
app.voertuig.brandstof = response.data.Algemeen.Brandstof.toLowerCase();
app.voertuig.type_id = response.data.Algemeen.TypeId;
app.voertuig.model_id = response.data.Algemeen.ModelId;
app.voertuig.merk_id = response.data.Algemeen.MerkId;
console.log(response.data.Algemeen);
return {
valid: true,
data: {
message: response.data.Algemeen,
}
};
}, (error) => {
console.log(error);
app.voertuig.kenteken = '';
app.loader.kenteken_onbekend = 1;
app.loader.kenteken = false;
return false;
});
};
Validator.localize('nl', nl);
Validator.extend('kenteken', {
validate:isKenteken,
getMessage: (field, params, data) => {
loader.kenteken = false;
voertuig.merk = data.message.Merk;
}
});
export default {
name: "app",
data() {
return {
voertuig: {
kenteken: '',
model: '',
model_id: '',
type: '',
type_id: '',
merk: '',
merk_id: '',
brandstof: '',
schadevrijejaren: ''
},
bestuurder: {
geboortedatum: '',
postcode: '',
huisnummer: '',
straat: '',
woonplaats: ''
},
loader: {
kenteken_onbekend: false,
kenteken: false,
},
}
},
mounted() {
var self = this;
}
}
</script>
So how to access the load.kenteken in the isKenteken function?
You shouldn't create function that will reference to Vue component outside Vue application scope. Instead, You should place the isKenteken() function inside Vue component as following
<script>
import {Validator} from 'vee-validate';
import nl from 'vee-validate/dist/locale/nl';
Validator.localize('nl', nl);
Validator.extend('kenteken', {
validate:isKenteken,
getMessage: (field, params, data) => {
loader.kenteken = false;
voertuig.merk = data.message.Merk;
}
});
export default {
name: "app",
data() {
return {
voertuig: {
kenteken: '',
model: '',
model_id: '',
type: '',
type_id: '',
merk: '',
merk_id: '',
brandstof: '',
schadevrijejaren: ''
},
bestuurder: {
geboortedatum: '',
postcode: '',
huisnummer: '',
straat: '',
woonplaats: ''
},
loader: {
kenteken_onbekend: false,
kenteken: false,
},
}
},
methods{
isKenteken(value){
this.loader.kenteken= true;
return axios.post('/api/validate/kenteken', {kenteken: value}).then((response) => {
// Notice that we return an object containing both a valid property and a data property.
this.loader.kenteken = false;
this.voertuig.merk = response.data.Algemeen.Merk;
this.voertuig.model = response.data.Algemeen.Type;
this.voertuig.brandstof = response.data.Algemeen.Brandstof.toLowerCase();
this.voertuig.type_id = response.data.Algemeen.TypeId;
this.voertuig.model_id = response.data.Algemeen.ModelId;
this.voertuig.merk_id = response.data.Algemeen.MerkId;
console.log(response.data.Algemeen);
return {
valid: true,
data: {
message: response.data.Algemeen,
}
};
}, (error) => {
console.log(error);
this.voertuig.kenteken = '';
this.loader.kenteken_onbekend = 1;
this.loader.kenteken = false;
return false;
});
}
},
mounted() {
var self = this;
}
}
</script>
If you need to use the function from other component. You will need to find a way to make them reference to this component in order to use this methods. For example, inside other component's mounted(), you can use the root component methods by this.$root.isKenteken()

Vue Watch doesnt Get triggered when using axios

Hey guys I have this code that fetches data from database usin axios, and in the .then() function I set a data property, watch doesnt trigger. Here is some code that I currently have. And thank you in advance!
export default {
name: '..',
data() {
return {
autocompleteOn: false
}
},
watch: {
autocompleteOn(oldVal, newVal) {
console.log('autocomplet') // doesnt trigger this
}
},
methods: {
fetchAutocompleteResults: _.debounce((filter) => {
let $this = this;
let data = {
filter: filter,
page: $this.page
};
filter.resources.response = [];
filter.loading = true;
axios.post(BASE_URL + '/search/filter', data).then(function(response) {
if (response.data.length) {
filter.autocompleteOn = true;
$this.autocompleteOn = true;
filter.resources.response = filter.resources.response.concat(response.data);
$this.currentFilter = filter;
$this.page++;
console.log($this.autocompleteOn); // this is correct
}
filter.loading = false;
});
}, 300)
}
}
The debounce with an arrow function is making the this be something other than the Vue instance (e.g. window).
Instead of:
methods: {
fetchAutocompleteResults: _.debounce((filter) => {
Use:
methods: {
fetchAutocompleteResults: _.debounce(function (filter) {
// ^^^^^^^^ ^^^
Demo:
new Vue({
el: '#app',
data() {
return {
autocompleteOn: false
}
},
watch: {
autocompleteOn(oldVal, newVal) {
console.log('autocomplet') // doesnt trigger this
}
},
methods: {
fetchAutocompleteResults: _.debounce(function (filter) { // CHANGED from arrow function
let $this = this;
let data = {
filter: filter,
page: $this.page
};
filter.resources.response = [];
filter.loading = true;
// changed data for demo
data = [{title: 'foo', body: 'bar', userId: 1}];
// changed URL for demo
axios.post('https://jsonplaceholder.typicode.com/posts', data).then(function(response) {
if (response.data.length) {
filter.autocompleteOn = true;
$this.autocompleteOn = true;
filter.resources.response = filter.resources.response.concat(response.data);
$this.currentFilter = filter;
$this.page++;
console.log($this.autocompleteOn); // this is correct
}
filter.loading = false;
});
}, 300)
}
})
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lodash#4.17.5/lodash.min.js"></script>
<div id="app">
<button #click="fetchAutocompleteResults({resources: {}})">fetchAutocompleteResults</button>
</div>

how to inherit models.js in pos and make some changes?

models_extend.js
odoo.define('pos_ticket.models_extend', function (require) {
"use strict";
var x = require('point_of_sale.models');
var models = pos_model.PosModel.prototype.models;
models.push(
{
model: 'res.company',
fields: [ 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id' , 'country_id', 'tax_calculation_rounding_method','city','trn_no'],
ids: function(self){ return [self.user.company_id[0]]; },
loaded: function(self,companies){ self.company = companies[0]; },
},
{
model: 'product.product',
fields: ['display_name', 'list_price','price','pos_categ_id', 'taxes_id', 'barcode', 'default_code',
'to_weight', 'uom_id', 'description_sale', 'description',
'product_tmpl_id','tracking','arb'],
order: ['sequence','default_code','name'],
domain: [['sale_ok','=',true],['available_in_pos','=',true]],
context: function(self){ return { pricelist: self.pricelist.id, display_default_code: false }; },
loaded: function(self, products){
self.db.add_products(products);
},
},
{
model: 'product.product',
fields: ['display_name', 'list_price','price','pos_categ_id', 'taxes_id', 'barcode', 'default_code',
'to_weight', 'uom_id', 'description_sale', 'description',
'product_tmpl_id','tracking','arb'],
order: ['sequence','default_code','name'],
domain: [['sale_ok','=',true],['available_in_pos','=',true]],
context: function(self){ return { pricelist: self.pricelist.id, display_default_code: false }; },
loaded: function(self, products){
self.db.add_products(products);
},
}
);
x.Order = x.Order.extend({
export_for_printing: function(){
var self = this;
this.pos = options.pos;
var company = this.pos.company;
var receipt = {
company:{
city:company.city,
trn_no:company.trn_no,
}
}
return receipt;
},
});
I want to add city and trn_no in res.company and arb in product.product to see the arabic translation.Then only i can submit my project in time, i am literally trapped please help me .i am a trainee .
To add new field in POS modules necessary in models.js override PosModel in the parent models which we take from “point_of_sale.models”.
After some changes
odoo.define('pos_ticket.models_extend', function (require) {
"use strict";
var x = require('point_of_sale.models');
var _super = x.PosModel.prototype;
module.PosModel = x.PosModel.extend({
initialize: function (session, attributes) {
// call super to set all properties
_super.initialize.apply(this, arguments);
// here i can access the models list like this and add an element.
this.models.push(
{
// load allowed users
model: 'res.company',
fields: ['city','trn_no'],
domain: function(self){ return [['id','in',self.users.company_id]]; },
loaded: function(self,companies){
console.log(companies);
self.allowed_users = companies;
}
},{
model: 'product.product',
fields: ['arb'],
order: ['sequence','default_code','name'],
domain: [['sale_ok','=',true],['available_in_pos','=',true]],
context: function(self){ return { pricelist: self.pricelist.id, display_default_code: false }; },
loaded: function(self, products){
self.db.add_products(products);
}
},
)
return this;
}
});
});
now i need to inherit another function called "export_for_printing" and add those new fields in it so that i can print these fields.how?
Just add the modifications to the self.models array like this. This works for the version 8. Maybe it you need to adapt it:
if (typeof jQuery === 'undefined') { throw new Error('Product multi POS needs jQuery'); }
+function ($) {
'use strict';
openerp.your_module_name = function(instance, module) {
var PosModelParent = instance.point_of_sale.PosModel;
instance.point_of_sale.PosModel = instance.point_of_sale.PosModel.extend({
load_server_data: function(){
var self = this;
self.models.forEach(function(elem) {
if (elem.model == 'res.company') {
elem.fields = // make your new assignations here
elem.domain = // ...
elem.loaded = // ...
} else if (elem.model == 'product.product') {
// [...]
}
})
var loaded = PosModelParent.prototype.load_server_data.apply(this, arguments);
return loaded;
},
});
}
}(jQuery);

Meteor + polymer without blaze

I'm trying to use meteor + polymer without blaze templating.
I make this behavior:
MeteorBehavior = {
properties: {
isReady: {
type: Boolean,
value: false,
notify: true
},
currentUser: {
type: Object,
value: null,
notify: true
}
},
ready: function () {
var self = this;
self.subscriptions.forEach(function(itm){
itm = $.type(itm) == 'array' ? itm : [itm];
itm[itm.length] = function () {
self.isReady = true;
};
Meteor.subscribe.apply(null, itm);
});
Meteor.startup(function () {
Tracker.autorun(function(){
self.currentUser = Meteor.user();
});
Tracker.autorun(self.autorun.bind(self));
});
},
subscriptions: [],
autorun: function() { }
};
And i use it:
(function () {
Polymer({
is: 'posts-list',
posts: [],
behaviors: [MeteorBehavior],
autorun: function(){
this.posts = Posts.find().fetch();
},
subscriptions: ['posts']
});
})();
Is it good solution? And how i can animate data changing without blaze uihooks?

Categories

Resources