22

I'm having trouble trying to initialize a filter with asynchronous data.

The filter is very simple, it needs to translate paths to name, but to do so it needs a correspondance array, which I need to fetch from the server.

I could do things in the filter definition, before returning the function, but the asynchronous aspect prevents that

angular.module('angularApp').
  filter('pathToName', function(Service){
    // Do some things here

    return function(input){
      return input+'!'
    }
  }

Using a promise may be viable but I don't have any clear understanding on how angular loads filters. This post explains how to achieve such magic with services, but is it possible to do the same for filters?

And if anyone has a better idea on how to translate those paths, I'm all ears.

EDIT:

I tried with the promise approch, but something isn't right, and I fail to see what:

angular.module('angularApp').filter('pathToName', function($q, Service){

  var deferred = $q.defer();
  var promise = deferred.promise;

  Service.getCorresp().then(function(success){
    deferred.resolve(success.data);
  }, function(error){
    deferred.reject();
  });

  return function(input){
    return promise.then(
      function(corresp){
        if(corresp.hasOwnProperty(input))
          return corresp[input];
        else
          return input;
      }
    )
  };
});

I'm not really familliar with promises, is it the right way to use them?

Community
  • 1
  • 1
Davounet
  • 365
  • 1
  • 3
  • 7
  • For now the filter fetchs the correspondance data, and puts it in a var inside the filter. This war is then used to translate things. The thing is it's not perfect, if the server response takes to much time, the filter won't have the corresp data yet, in which case it doesn't translate anyway. – Davounet Sep 27 '13 at 09:22
  • You can return a promise of the function: http://docs.angularjs.org/api/ng.$q – Nikola Yovchev Sep 27 '13 at 09:38

2 Answers2

44

Here is an example:

app.filter("testf", function($timeout) {
    var data = null, // DATA RECEIVED ASYNCHRONOUSLY AND CACHED HERE
        serviceInvoked = false;

    function realFilter(value) { // REAL FILTER LOGIC
        return ...;
    }

    return function(value) { // FILTER WRAPPER TO COPE WITH ASYNCHRONICITY
        if( data === null ) {
            if( !serviceInvoked ) {
                serviceInvoked = true;
                // CALL THE SERVICE THAT FETCHES THE DATA HERE
                callService.then(function(result) {
                    data = result;
                });
            }
            return "-"; // PLACEHOLDER WHILE LOADING, COULD BE EMPTY
        }
        else return realFilter(value);
    }
});

This fiddle is a demonstration using timeouts instead of services.


EDIT: As per the comment of sgimeno, extra care must be taken for not calling the service more than once. See the serviceInvoked changes in the code above and the fiddles. See also forked fiddle with Angular 1.2.1 and a button to change the value and trigger digest cycles: forked fiddle


EDIT 2: As per the comment of Miha Eržen, this solution does no logner work for Angular 1.3. The solution is almost trivial though, using the $stateful filter flag, documented here under "Stateful filters", and the necessary forked fiddle.

Do note that this solution would hurt performance, as the filter is called each digest cycle. The performance degradation could be negligible or not, depending on the specific case.

Nikos Paraskevopoulos
  • 39,514
  • 12
  • 85
  • 90
  • I think this is not the best approach if you are thinking to use thefilter in your templates, filters can be called many times during render and they will trigger tons digest cycles.. no? – sgimeno May 06 '14 at 12:04
  • 2
    @sgimeno This method does not cause any more digest cycles than any other filter. It may cause extra calls to the service, if the guard condition is not set correctly, so a flag serviceInvoked in the code above may be required. I am updating the code and the fiddles to reflect that. (BTW, I fell victim to Firebug's console printing messages only once; the service is indeed called multiple times if a digest is triggered before `data` is set to non-`null`.) – Nikos Paraskevopoulos May 06 '14 at 12:36
  • Your are right, service was called multiple times, you have to ensure 1 execution. My problem was my http service was setting properties in $rootScope which caused digest cycles and triggered back the filter. Note to self: Don't ever update any scope from a service layer. – sgimeno May 06 '14 at 14:39
  • Great solution, but just know that as it is now, it will only ever return one value! I'm using this as a user look up. A user ID gets passed in and the the filter issues a request to the users profile endpoint. Since the filter only gets initialized once on app run, once a promise resolves it returns the same value no matter what user ID I pass in. I'm going to look into some array magic to work around this though. – Askdesigners Oct 22 '14 at 09:06
  • 2
    This does not work anymore in angular 1.3 unfortunately. – Miha Eržen Nov 05 '14 at 10:24
  • 1
    @MihaEržen Indeed, check the necessary modifications for it to *work* in 1.3 under "EDIT 2". – Nikos Paraskevopoulos Dec 19 '14 at 08:29
  • It works, but beware that timeout starting new digest, so if you replace it with your service, you must also start new digest when service finished! :) – Luckylooke Dec 14 '15 at 11:57
  • Why don't you need to call `return realFilter(value);` after the line to set `data = result;`? I'm not understanding how the the realFilter is called the first time through... – mellis481 Apr 14 '16 at 20:50
  • The real filter is NOT called the first time, or any time until the service completes, because it hasn't got the data needed to run. That is the point. - The function containing the `data = result` line is in the callback of the service; it has nothing to do with filtering. The service completion sets the data needed by the real filter to run then trigger a digest cycle; in the digest cycle the filter wrapper function runs again, sees that the data has finally arrived and calls the real filter. – Nikos Paraskevopoulos Apr 14 '16 at 22:23
  • @NikosParaskevopoulos So the digest handles polling the filter until data is returned. Got it. In my implementation, though, my realFilter never gets called. I suspect because my service call returns a promise and I'm missing something in my filter. Can you take a look? http://kopy.io/PYLRB – mellis481 Apr 15 '16 at 12:56
19

Let's start with understanding why the original code doesn't work. I've simplified the original question a bit to make it more clear:

angular.module('angularApp').filter('pathToName', function(Service) {

    return function(input) {
        return Service.getCorresp().then(function(response) {
            return response;
        });
    });

}

Basically, the filter calls an async function that returns the promise, then returns its value. A filter in angular expects you to return a value that can be easily printed, e.g string or number. However, in this case, even though it seems like we're returning the response of getCorresp, we are actually returning a new promise - The return value of any then() or catch() function is a promise.

Angular is trying to convert a promise object to a string via casting, getting nothing sensible in return and displays an empty string.


So what we need to do is, return a temporary string value and change it asynchroniously, like so:

JSFiddle

HTML:

<div ng-app="app" ng-controller="TestCtrl">
    <div>{{'WelcomeTo' | translate}}</div>
    <div>{{'GoodBye' | translate}}</div>
</div>

Javascript:

app.filter("translate", function($timeout, translationService) {

    var isWaiting = false;
    var translations = null;

    function myFilter(input) {

        var translationValue = "Loading...";
        if(translations)
        {
            translationValue = translations[input];
        } else {
            if(isWaiting === false) {
                isWaiting = true;
                translationService.getTranslation(input).then(function(translationData) {
                    console.log("GetTranslation done");
                    translations = translationData;
                    isWaiting = false;
                });
            }
        }

        return translationValue;
    };

    return myFilter;
});

Everytime Angular tries to execute the filter, it would check if the translations were fetched already and if they weren't, it would return the "Loading..." value. We also use the isWaiting value to prevent calling the service more than once.

The example above works fine for Angular 1.2, however, among the changes in Angular 1.3, there is a performance improvement that changes the behavior of filters. Previously the filter function was called every digest cycle. Since 1.3, however, it only calls the filter if the value was changed, in our last sample, it would never call the filter again - 'WelcomeTo' would never change.

Luckily the fix is very simple, you'd just need to add to the filter the following:

JSFiddle

myFilter.$stateful = true;

Finally, while dealing with this issue, I had another problem - I needed to use a filter to get async values that could change - Specifically, I needed to fetch translations for a single language, but once the user changed the language, I needed to fetch a new language set. Doing that, proved a bit more tricky, though the concept is the same. This is that code:

JSFiddle

var app = angular.module("app",[]);
debugger;

app.controller("TestCtrl", function($scope, translationService) {
    $scope.changeLanguage = function() {
        translationService.currentLanguage = "ru";
    }
});

app.service("translationService", function($timeout) {
    var self = this;

    var translations = {"en": {"WelcomeTo": "Welcome!!", "GoodBye": "BYE"}, 
                        "ru": {"WelcomeTo": "POZHALUSTA!!", "GoodBye": "DOSVIDANYA"} };

    this.currentLanguage = "en";
    this.getTranslation = function(placeholder) {
        return $timeout(function() {
            return translations[self.currentLanguage][placeholder];
        }, 2000);
    }
})

app.filter("translate", function($timeout, translationService) {

    // Sample object: {"en": {"WelcomeTo": {translation: "Welcome!!", processing: false } } }
    var translated = {};
    var isWaiting = false;

    myFilter.$stateful = true;
    function myFilter(input) {

        if(!translated[translationService.currentLanguage]) {
            translated[translationService.currentLanguage] = {}
        }

        var currentLanguageData = translated[translationService.currentLanguage];
        if(!currentLanguageData[input]) {
            currentLanguageData[input] = { translation: "", processing: false };
        }

        var translationData = currentLanguageData[input];
        if(!translationData.translation && translationData.processing === false)
        {
            translationData.processing = true;
            translationService.getTranslation(input).then(function(translation) {
                console.log("GetTranslation done");
                translationData.translation = translation;
                translationData.processing = false;
            });
        }

        var translation = translationData.translation;
        console.log("Translation for language: '" + translationService.currentLanguage + "'. translation = " + translation);
        return translation;
    };

    return myFilter;
});
VitalyB
  • 12,397
  • 9
  • 72
  • 94
  • There is one big issue with your approch that is if you init the filter 50 times in a ng-repeat it will call the service 50 times instead of calling it once and then parse the info through down to the filter – Simon Dragsbæk Oct 22 '15 at 12:39
  • 1
    @SimonPertersen That's not what should happen. While the service is fetching the translation `isWaiting` would be `true` so all the following filters would simply return `Loading...` – VitalyB Oct 26 '15 at 12:20
  • @VitalyB, as per https://code.angularjs.org/1.3.7/docs/guide/filter#stateful-filters, this is strongly discouraged. Is there any work around to achieve this? – Gourav Garg Dec 01 '16 at 06:29
  • 1
    @GouravGarg I'm not familiar with any alternative for filters. You could, instead of filters, use a regular binding and a controller... But that'd be pretty much the same, I think. – VitalyB Dec 03 '16 at 09:42