I'm trying to synchronize a css transition in a directive with web socket events in it's parent controller.
For example, here is a scenario: User clicks on a product that he wants to purchase, I send a message to websocket server - upon receiving a successful response, I need to animate the product moving to users purchase cart and when this animation is complete, I have to actually add the product to users cart (I don't have access to this cart inside the directive)
Below is the code I have so far. Consider the pink box as a purchasable product, and the blue boxes inside bordered box as purchased products. User can purchase new product by clicking the pink box.
(For demo purpose, I'm manually calling $scope.purchaseSuccess, in real scenario it'll be invoked by socket.io)
angular.module('test', [])
.controller('testCtrl', function($scope) {
$scope.products = [{}, {}, {}, {}];
$scope.purchased = [{}];
$scope.purchase = function() {
//emits socket io message here
$scope.purchaseSuccess($scope.products.pop());
//--^--- this is called manually for demo purpose
};
$scope.purchaseSuccess = function(product) {
//success event handler called by socket io
$scope.$broadcast('purchase-success', product);
};
$scope.$on('transition-end', function(product) {
$scope.purchased.push(product);
$scope.$apply();
});
})
.directive('testDir', function() {
return {
scope: {},
link: function(scope, element) {
$helper = element.find('.helper');
var temp; // used to cache the product received from controllers event
scope.$on('purchase-success', function(product) {
temp = product; // cache the product to be sent back after animation
$helper.removeClass('no-transition');
$target = $('.purchased .product:last');
$helper.position({
my: 'top left',
at: 'top right',
of: $target,
using: function(pos, data) {
var val = 'translate3d(' + (pos.left + data.element.width) + 'px,' + pos.top + 'px,0px)'
$(this).css('transform', val);
}
});
});
$helper.on('transitionend MSTransitionEnd webkitTransitionEnd oTransitionEnd', function() {
$helper.addClass('no-transition');
$helper.css('transform', 'translate3d(0%,0px,0px)');
scope.$emit('transition-end', temp);
});
}
};
});
.product {
height: 50px;
width: 50px;
border: 1px solid;
}
.stock {
position: relative;
height: 50px;
background: silver;
}
.stock .product {
position: absolute;
background: hotpink;
}
.stock .product.helper {
transition: transform 2s;
}
.purchased {
height: 60px;
margin-top: 50px;
border: 2px dotted;
}
.purchased .product {
display: inline-block;
margin: 5px;
background: dodgerblue;
}
.no-transition {
transition: none !important;
}
<script src="https://code.jquery.com/jquery-2.1.3.min.js"></script>
<script src="https://code.jquery.com/ui/1.11.2/jquery-ui.js"></script>
<script src="https://code.angularjs.org/1.4.8/angular.js"></script>
<div ng-app="test" ng-controller="testCtrl">
<div class="stock" data-test-dir data-purchase="purchase">
<div class="product helper no-transition">
</div>
<div class="product" ng-click="purchase()">
</div>
</div>
<div class="purchased">
<div class="product" ng-repeat="product in purchased">
</div>
</div>
</div>
This is the tiniest demo I was able to make, the actual thing has further complications like timers running within which user has to finish all purchases
So, basically:
User's click invokes scope method which emits event to socket server
Socket.io success event handler broadcasts an angular event
Directive receives this event, caches the received data in a
temporary variable and triggers the CSS transition
When the animation is complete, directive emits an event with the
cached data
Controller receives this event and acts upon the data.
But this doesn't seem right. Is there a way to avoid sending this data back and forth..? Maybe a better way to achieve this without all the event emissions..?
Side note: those who aren't familiar with socket io, please consider purchase as a method that sends an ajax request, purchaseSuccess as it's success handler which should trigger the animation, and upon completion of this animation I need to do some action in the controller scope.
Related
I've made a mistake. I paired my functionality to .on('click', ...) events. My system installs certain items and each item is categorized. Currently, my categories are [post, image, widgets], each having its own process and they are represented on the front-end as a list. Here's how it looks:
Each one of these, as I said, is paired to a click event. When the user clicks Install a nice loader appears, the <li> itself has stylish changes and so on.
I also happen to have a button which should allow the user to install all the items:
That's neat. Except...there is absolutely no way to do this without emulating user clicks. That's fine, but then, how can I wait for each item to complete (or not) before proceeding with the next?
How can I signal to the outside world that the install process is done?
It feels that if I use new CustomEvent, this will start to become hard to understand.
Here's some code of what I'm trying to achieve:
const installComponent = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
return resolve();
}, 1500);
});
};
$('.item').on('click', (event) => {
installComponent().then(() => {
console.log('Done with item!');
});
});
$('#install-all').on('click', (event) => {
const items = $('.item');
items.each((index, element) => {
element.click();
});
});
ul,
ol {
list-style: none;
padding: 0;
margin: 0;
}
.items {
display: flex;
flex-direction: column;
width: 360px;
}
.item {
display: flex;
justify-content: space-between;
width: 100%;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
margin: 0;
}
.item h3 {
width: 80%;
}
.install-component {
width: 20%;
}
#install-all {
width: 360px;
height: 48px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<ul class="items">
<li class="item" data-component-name="widgets">
<h3>Widgets</h3>
<button class="install-component">Install </button>
</li>
<li class="item" data-component-name="post">
<h3>Posts</h3>
<button class="install-component">Install </button>
</li>
<li class="item" data-component-name="images">
<h3>Images</h3>
<button class="install-component">Install </button>
</li>
</ul>
<button id="install-all">Install All</button>
As you can see, all clicks are launched at the same time. There's no way to wait for whatever a click triggered to finish.
This is simple architectural problems with your application that can be solved by looking into a pattern that falls into MVC, Flux, etc.
I recommend flux a lot because it’s easy to understand and you can solve your issues by separating out your events and UI via a store and Actions.
In this case you would fire an action when clicking any of these buttons. The action could immediately update your store to set the UI into a loading state that disables clicking anything else and show the loader. The action would then process the loader which can be monitored with promises and upon completion the action would finalize by setting the loading state in the store to false and the UI can resolve to being normal again. The cool thing about the proper separation is the actions would be simple JS methods you can invoke to cause all elements to install if you so desire. Essentially, decoupling things now will make your life easier for all things.
This can sound very complicated and verbose for something as simple as click load wait finish but that’s what react, angular, flux, redux, mobx, etc are all trying to solve for you.
In this case I highly recommend examining React and Mobx with modern ECMaScript async/await to quickly make this issue and future design decisions much easier.
What you should do is to declare a variable which will store the installation if it's in progress. And it will be checked when you are trying to install before one installation is complete.
var inProgress = false;
const installComponent = () => {
inProgress = true;
return new Promise((resolve, reject) => {
if(inProgress) return;
else{
setTimeout(() => {
inProgress = false;
return resolve();
}, 1500);
}
});
};
I'd be looking to implement something like this:
let $items = $('.items .item');
let promises = new Array($items.length);
// trigger installation of the i'th component, remembering the state of that
function startInstallOnce(i) {
if (!promises[i]) {
let component = $items.get(i).data('component-name');
promises[i] = installComponent(component);
}
return promises[i];
}
// used when a single item is clicked
$items.on('click', function(ev) {
let i = $(this).index();
startInstallOnce(i);
});
// install all (remaining) components in turn
$('#install-all').on('click', function(ev) {
(function loop(i) { // async pseudo-recursive loop
if (i === components.length) return; // all done
startInstallOnce(i).then(() => loop(i + 1));
})(0);
});
But how do you show the interaction of clicking on an element that toggles or animates?
The meteor checkers example:
http://checkers.meteor.com/
In the example below, I would like for every browser that's connected to the Meteor server to be able to see when one of the other browsers makes the shape change.
https://jsfiddle.net/qh2jyL3b/
HTML:
<div class="square"></div>
<script src="//code.jquery.com/jquery-1.10.2.js"></script>
CSS:
.square {
width: 100px;
height: 100px;
background: white;
border: 10px solid black;
cursor: pointer;
}
JavaScript:
$(".square").click(function() {
$( this ).toggleClass("circle");
});
You can store click state in Meteor.Collection. Changes in collections propagate reactively for all connected clients. Just create separate document for each square and save state here. Then you can create helper that displays correct class name depending on db items.
For example you can do this this way:
For each chess-table you can create separate document
on server side:
ChessTableCell = new Mongo.Collection('chesstablecell');
Then you can store state of each cell in each of this document. So initially you can insert
ChessTableCell.insert({name: 'a1', state: false);
ChessTableCell.insert({name: 'a2', state: false);
...etc
And on your client side you have access to cells states like this:
ChessTableCell.findOne({name: 'a1'}).state;
And on click you just need to toggle state of clicked cell. You can do it in a following way:
Template.chessboard.events({//or whatever your template is called like
'click .cell': function(e,t) {
var cellName = $(e.target).data('name'); //if you specify cell name in your html in data-name attribute
var cellValue = ChessTableCell.findOne({name: cellName}).state;
//here you can update the value
ChessTableCell.update({name: cellValue}, {$set: { state: !cellValue}});
}
});
Then state will change reactively on every connected client.
Of course you need to reflect those changes in your html like this:
{{#each ChessTableCells}}
<div class="cell {{#if state}}active{{/if}}" data-name="{{name}}"></div>
{{/each}}
And in your client code:
Template.chessboard.helpers({
ChessTableCells: function() { return ChessTableCell.find({}); }
});
I am trying to make an paper-card element change colors based on the status of the customers data on Fire base, but for some reason the color only updates on the second click of the customer. Right now I have the paper cards ID set to the firebase data in order to make it change colors. Here's my elements style code:
<style is="custom-style">
:host {
display: block;
}
#cards {
#apply(--layout-vertical);
#apply(--center-justified);
}
.row {
padding: 20px;
margin-left: 10px;
}
paper-card {
padding: 20px;
}
#check {
float: right;
bottom: 15px;
--paper-card
}
#Done {
--paper-card-header: {
background: var(--paper-green-500);
};
--paper-card-content: {
background: var(--paper-green-300);
};
}
#Default {
/*Apply Default Style*/
/*--paper-card-content: {*/
/* background: var(--paper-red-500);*/
/*};*/
}
paper-icon-button.check{
color: var(--paper-green-500);
}
paper-icon-button.check:hover{
background: var(--paper-green-50);
border-radius: 50%;
}
#check::shadow #ripple {
color: green;
opacity: 100%;
}
.iron-selected{
color: green;
}
And here is the template:
<template>
<firebase-collection
location="https://calllistmanager.firebaseio.com/Wilson"
data="{{wilsonData}}"></firebase-collection>
<div id="cards">
<template id="cards" is="dom-repeat" items="{{wilsonData}}" as="customer">
<paper-card id="{{customer.status}}" class="{{customer.status}}" heading="[[customer.__firebaseKey__]]">
<div class="card-content">
<span>Phone: </span><span>[[customer.number]]</span>
<span>Status: </span><span>[[customer.status]]</span>
<paper-icon-button style="color: green" id="check" on-tap="checktap" icon="check">
</paper-icon-button>
</div>
</paper-card>
</template>
</div>
Here is my script:
<script>
(function() {
Polymer({
is: 'list-display',
properties: {
wilsonData: {
type: Object,
observer: '_dataObserver'
}
},
ready: function() {
var listRef = new Firebase("https://calllistmanager.firebaseio.com/Wilson");
},
checktap: function(e){
// e.model.customer.status = "Done";
console.log("Starting Status: " + e.model.customer.status);
ref = new Firebase("https://calllistmanager.firebaseio.com/Wilson")
var stat;
var store = ref.child(e.model.customer.__firebaseKey__);
store.on("value", function(snapshot){
stat = snapshot.child("status").val();
});
if(stat == "Done"){
store.update({
"status": "Default"
});
e.model.customer.status = "Default";
}
else {
store.update({
"status": "Done"
});
e.model.customer.status = "Done";
}
console.log("Ending Status: " + e.model.customer.status);
this.updateStyles()
}
});
})();
at first I thought the problem may be that the function runs updateStyles(); faster than firebase can update but it always works fine on the second click...any suggestions?
I think the problem could be caused by the call to firebase. store.on("value", is not a synchronous function. However, later in your code you assume that you already have a value, that will be set later on whenever the value event fires. You could try adding the rest of your code in the event handler. Like this:
checktap: function(e){
// e.model.customer.status = "Done";
console.log("Starting Status: " + e.model.customer.status);
ref = new Firebase("https://calllistmanager.firebaseio.com/Wilson")
var store = ref.child(e.model.customer.__firebaseKey__);
store.once("value", function(snapshot){
var stat = snapshot.child("status").val();
if(stat == "Done"){
store.update({
"status": "Default"
});
e.model.set("customer.status", "Default");
}
else {
store.update({
"status": "Done"
});
e.model.set("customer.status", "Done");
}
console.log("Ending Status: " + e.model.customer.status);
this.updateStyles();
}.bind(this));
}
Essentially, you wait until the stat variable has been set to do the rest of your tasks. Also note, the bind(this) at the end, which will allow you to update the the styles from the event handler.
Update
There are a couple of more issues. First it's better to uses classes for changing the styles and not IDs. IDs should not change. Then, to bind to the class attribute, use the $ sign. When you update the model, you should use the set API.
Have a look at this plunker. It is a small working example (only works in Chrome) that changes styles when you click the checkmark. It does not use Firebase, however.
Here's how you could to the style with classes.
.Done {
--paper-card-header: {
background: var(--paper-green-500);
};
--paper-card-content: {
background: var(--paper-green-300);
};
}
And in your template:
<paper-card class$="{{customer.status}}" heading="[[customer.__firebaseKey__]]">
I'm writing a messenger application in Meteor, and I want to set it up such that when either user types a message it scrolls down to the bottom of the div for both of them. I am storing the message in a list called messages in a conversation document in the collection Conversations. I am using cursor.observeChanges, and it seems that the callback fires before the data is rendered on the client side so it doesn't scroll all the way to the bottom.
Here is the html:
<template name="chat">
{{> chatBox}}
</template>
<template name="chatBox">
<div class="chat-box">
<div id="chat-messages">
{{#each chatMessages}}
<div class="individual-message">
{{message}}
</div>
{{/each}}
</div>
<form id="chat-input">
<input class="add-message" autocomplete="off" placeholder="Write something..." name="text" type="text">
</form>
</div>
</template>
Here's the relevant css:
#chat-messages {
overflow-y: scroll;
width: 250px;
height: 450px;
padding: 15px;
position: absolute;
}
And here's the js:
Tracker.autorun(function(){
...
Conversations.find(conversationId).observeChanges({
changed: function(id, fields){
$("#chat-messages").scrollTop($("#chat-messages").prop("scrollHeight"));
}
});
});
Whenever I run into an issue where Blaze hasn't had a chance to render something in time for a Javascript function to be evoked on it, I use Tracker.afterFlush. This waits until the render cycle is done before running some code, e.g.:
// Inside a Meteor event callback
Tracker.afterFlush(function () {
var $someItem = $('....');
$(window).scrollTop($someItem.offset().top);
});
http://docs.meteor.com/#/full/tracker_afterflush
Different Approach via tracking of Template helpers:
I took advantage of the Template helper, as it already tracks reactively all changes (and new) messages. Hence, there you can place your scroll-down command.
I assume you have something like this in your JS file:
chatBox.js
Template.chatBox.helpers({
chatMessages: function() {
return Conversations.find({conversationId: conversationId},
{sort: {d: -1}, limit: 20}).fetch().reverse();
},
});
(Being chatMessages.d the date of posting and conversationId your reactive variable for the specific chat-room, sorted by date from the end, and displayed by reverse order, so that last chat would appear at the end of your chat-messages div, limited to the last 20 messages)
Just add your scroll-down command there, hence you would have:
Template.chatBox.helpers({
chatMessages: function() {
//scroll down not animated
$('#chat-messages').scrollTop($('#chat-messages').prop('scrollHeight'));
return Conversations.find({conversationId: conversationId},
{sort: {d: -1}, limit: 20}).fetch().reverse();
}
});
Or make it animated for smooth scrolling:
Template.chatBox.helpers({
chatMessages: function() {
//scroll down with animation
$('#chat-messages').animate({scrollTop: $('#chat-messages').prop('scrollHeight')}, 500);
return Conversations.find({conversationId: conversationId},
{sort: {d: -1}, limit: 20}).fetch().reverse();
}
});
This would trigger your "scroll-down" at any change in your Conversations-Collection.
Hope this helps.
i'm using ng-animate to slide the app views, so each route slides own view , this is my simple code:
html:
<div ng-view ng-animate class="slide"></div>
css:
/*Animations*/
.slide{
left:0;
}
.slide.ng-enter{
transition:0.15s linear all;
position:fixed;
z-index:inherit;
left:-100%;
height:inherit;
}
.slide.ng-leave{
transition:0.15s linear all;
position:fixed;
z-index:9999;
right:0;
}
.slide.ng-leave-active{
transition:0.15s linear all;
position:fixed;
right:-100%;
left:100%;
}
.slide.ng-enter-active{
transition:0.15s linear all;
left:0;
position:relative;
}
Now, i'm wondering , is there anyway to exclude the home page (main "/" route) from sliding?
In other terms: Any way to exclude a route from ng-animate?
That's what ng-class is for.
You can set a application-wide variable $rootScope.path whenever path changes.
app.run(function ($rootScope, $location) {
$rootScope.$on("$locationChangeStart", function (event, next, current) {
$rootScope.path = $location.path();
});
});
Then, you decide to set your animation class by that variable
If you want to set class slide only if path is not /, do like this
<div ng-view ng-class="{slide: path !== '/' }"></div>
By doing this way, you don't need to touch any of your controller.
Full demo is here, http://plnkr.co/edit/rmu8oc7ycKzRaA2zv5JN?p=preview
By the way, this uses currant angularJS version, 1.2.7
------- Edit (animate after visit main page) ------
app.run(function ($rootScope, $location) {
$rootScope.$on("$locationChangeStart", function (event, next, current) {
if (!$location.path().match(/^\/?$/) && !$rootScope.mainVisitedOnce) {
$rootScope.mainVisitedOnce = true;
}
});
});
and
<div ng-view ng-class="{slide: mainVisitedOnce }"></div>
http://plnkr.co/edit/QpDFkdKH1kk6ZXy07G5X?p=preview
Demo http://plnkr.co/edit/sMUM48?p=preview
Explain
No need to create separate controller, directive or change any business logic. Just use .animation method to add conditional animation to .slide.
Listen to $routeChangeSuccess event on $rootScope. This is event will be triggered before animation start, so you have time to set toRoot and fromRoot flag accordingly. If target view is not a "/" view, a enable-animation class will be added to ng-view element, so css transition defined will be performed.
If target view if a "/" view, no animation will be performed.
HTML
<ng-view class="slide"></ng-view>
CSS
.slide {
position: absolute;
top: 0;
width: 100%;
}
.slide div {
margin: 10px;
background: red;
}
.slide.enable-animation.ng-enter,
.slide.enable-animation.ng-leave {
transition: all 10s;
z-index: 1;
}
.slide.enable-animation.ng-enter {
left: -100%;
opacity: 0;
}
.slide.enable-animation.ng-enter.ng-enter-active,
.slide.enable-animation.ng-leave {
left: 0;
opacity: 1;
}
.slide.enable-animation.ng-leave.ng-leave-active {
left: 100%;
opacity: 0;
}
JavaScript
app.animation('.slide', function($rootScope, $animate) {
var toRoot = false;
var fromRoot = false;
$rootScope.$on('$routeChangeSuccess', function(event, current, old) {
toRoot = (current.$$route.originalPath === '/');
fromRoot = (old.$$route.originalPath === '/');
});
return {
enter: function(element, done) {
if (!toRoot) {
element.addClass('enable-animation');
}
done();
},
leave: function(element, done) {
if (!fromRoot) {
element.addClass('enable-animation');
done();
} else {
// set 1000ms timeout to sync with CSS animation
// otherwise leaving ng-view element will be removed before entering ng-view is in position
setTimeout(done, 1000);
}
}
}
});
Update 1
If you just want to exclude a route only when the first time app loads, you basically don't have to do anything, just define your css animation like normal. The first loaded route won't trigger any animation.
Demo http://plnkr.co/edit/uRZyZA?p=preview
Borrowing from #allenhwkim, get path in your rootScope.
app.run(function ($rootScope, $location) {
$rootScope.$on("$locationChangeStart", function (event, next, current) {
$rootScope.path = $location.path();
});
});
Then have this as your element:
<div ng-view ng-animate ng-class="{slide: path !== '/' }"></div>
.slide will be added to your container element when the path being loaded isn't /.
Here's a working Plunker.
Why don't you simply add a root class like class="not-animated" and class="animated" to the controllers you don't want or want to be animated?
In this way you could use the .not-animated and .animated class to play with your animation in different controllers.
You can set your controller like this:
<body ng-controller="MainCtrl" ng-class='isAnimated'>
<div ng-controller="FirstCtrl" ng-class='isAnimated'>Foo</div>
<div ng-controller="SecondCtrl" ng-class='isAnimated'>Bar</div>
</body>
app.controller('MainCtrl', function($scope) {
$scope.name = 'World';
$scope.isAnimated = 'not-animated';
});
app.controller('SecondCtrl', function($scope) {
$scope.isAnimated = 'animated';
});
This will result in:
<body ng-controller="MainCtrl" ng-class="isAnimated" class="ng-scope not-animated">
<p class="ng-binding">Hello World!</p>
<div ng-controller="FirstCtrl" ng-class="isAnimated" class="ng-scope not-animated">Foo</div>
<div ng-controller="SecondCtrl" ng-class="isAnimated" class="ng-scope animated">Bar</div>
</body>
DEMO
You can build a controller that listens for route changes and sets a class accordingly. You will then be able to target the correct animation using CSS.
<body ng-controller="TranisitionCtrl" ng-class="currentRoute">
<div ng-view ng-animate></div>
</body>
app.controller('TranisitionCtrl', function($scope) {
$scope.$on('$routeChangeStart', function(ev, next, current) {
$scope.currentRoute = current.name;
});
});
Please note $routeChangeStart has been changed to $locationChangeStart for more recent versions of angular.
I've answered another similar question here Two different animations for route changes
One way to exclude a specific route from the animation is to use a JS animation wrapper that will take over setting of the animation class for your ng-view. You need one additional class to handle the non-animated ng-leave when you are going back to the non-animated route.
Here is a sample JS wrapper that check for a custom animate attribute in your route to decide if a route is animated or not:
app.animation('.slider', function($log, $route) {
return {
//call done when the animation is over
enter : function(element, done) {
if ($route.current.animate) {
element.addClass("slide ng-enter");
}
done();
},
leave : function(element, done) {
if ($route.current.animate) {
element.addClass("slide ng-leave");
} else {
// Need to add non-animated version of ng-leave
element.addClass("slide ng-leave-noanimation");
}
done();
}
}
});
Here is the working plunker:
http://plnkr.co/edit/ldCy9Cfz2lJWNuLzDIbp?p=preview
As far as i understand you, what you want is, that no animation happens if the user hits your site. But after that the animations should happen all the time. This is the shortest solution i know:
Use the module run method to disable all animations:
app.run(function($animate){
$animate.enabled(false);
});
In your controller that is bound to / reenable the animations, but delayed for one digest cycle:
.controller('rootController', function($timeout, $animate){
$timeout(function(){
$animate.enabled(true);
});
})