Angular Routing - A Cautionary Tale

preston

The Issue

I'm unable to edit my text fields in Safari!

The Solution

I scrapped the parent-child model in the ui-router for my login and registration forms.

A Little Backstory

So I inherited a fairly complex project built with Angular on the front end and Ruby on Rails on the back end. When I got started on the project I had zero experience with Angular and only a brief encounter with RoR. Therefore, as I ventured toward the belly of the beast I encountered several strange and wondrous bits of code. One piece in particular was an Angular plugin called ui-router. This allows you to use states to route your code rather than urls. However, there are pitfalls to using it. One of which I will explain below.

Forms and UI-Router

We use ui-router all over the project, but in this particular instance we were using it to route our login and registration forms. I’m not going to go over our entire setup because I’m sure if you’re reading this you already know how to get an Angular project setup and running.

So we had three templates that used a parent-child model to display the login, registration, and forgot-password forms.

The router was setup as such:

routes.js


app.config(function($stateProvider, $urlRouterProvider, $locationProvider) {
  $locationProvider.html5Mode(true);
  $urlRouterProvider.otherwise("/");

  $stateProvider.state("default", {
    abstract: true,
    controller: "ApplicationController",
    templateUrl: "layouts/default.html"
  }).state("home", {
    parent: "default",
    url: "/",
    //controller: "ApplicationController",
    templateUrl: "home.html",
    data : { pageTitle: 'Home' }
  })

  //A bunch of other state routes

  // authentication

   .state("auth", {
     abstract: true,
     controller: "AuthController",
     templateUrl: "layouts/auth.html"
   })
  .state("login", {
    parent: "auth",
    url: "/login",
    controller: "AuthController",
    templateUrl: "auth/login.html"
  })
  .state("register", {
    parent: "auth",
    url: "/register",
    controller: "AuthController",
    templateUrl: "auth/register.html"
  })
  .state("forgot-password", {
    parent: "auth",
    url: "/forgot-password",
    controller: "AuthController",
    templateUrl: "auth/forgot-password.html"
  })
});

So we used a parent state that was abstract so we could change out the forms without changing the rest of the page.

Our controller for the views was setup like so:

AuthController.js


app.controller("AuthController", function ($scope, Auth, $alert, $log, $http, $timeout, $state, $modal) {

  $scope.body_class = "auth";
  $scope.submitInProgress = false;
  $scope.user_edit_profile_url = null;

  var terms_modal = $modal({
    scope: $scope,
    templateUrl: "modals/terms.html",
    content: "*",
    placement: "center",
    animation: "am-fade-and-scale",
    show: false
  });
  $scope.show_terms = function() {
    terms_modal.show();
  }
  $scope.reset = {
    this.user = {
      email: '',
      password: '',
      password_confirmation: '',
      terms: 0,
      errors: {}
    };  
    $scope.submitInProgress = false;      
  };

  $scope.reset();

  $scope.login = function() {
    $log.debug("Starting login");

    if (this.auth_form.$valid && !$scope.submitInProgress) {

      $scope.submitInProgress = true;
      Auth.login(this.user)['catch'](function(request) {
        // login request failed
        $scope.submitInProgress = false;

      })['finally'](function() {
        $scope.submitInProgress = false;

        // direct to the right place somehow
        $state.go('projects');
      });

    }
  }

  // after failed login attempt
  $scope.$on('devise:unauthorized', function(event, xhr, deferred) {
    $scope.submitInProgress = false;
    $scope.user.password = '';
    $scope.user.errors = [
      'Invalid credentials. Please try again.'
    ];
  });

  $scope.register = function() {
    if (this.auth_form.$valid && !$scope.submitInProgress) {
      //console.log($scope.user.terms);
      if($scope.user.terms != 1) {
        $scope.user.errors['terms'] = 1;
        return;
      }

      $scope.submitInProgress = true;
      Auth.register(this.user).then(function(registeredUser) {
        $state.go('projects');
        $scope.submitInProgress = false;
      }, function(response) {
        // Registration failed...
        $scope.submitInProgress = false;
        $scope.user.errors = response.data.errors
      });

    }
  }

  $scope.forgot_password = function() {
    if(this.user.email && !$scope.submitInProgress) {
      $scope.submitInProgress = true;
      $http.post("/api/users/password", {"user": this.user}).
      success(function(data, status, headers, config) {
        $scope.reset();
        $scope.submitInProgress = false;
        $alert({title: 'Check your email!', content: 'Password recovery instructions sent.', placement: 'bottom-left', type: 'success', duration: 3, show: true});
      }).
      error(function(data, status, headers, config) {
        console.log(data);
        $scope.submitInProgress = false;
        angular.forEach(data.errors, function(errors_array, key) {
          angular.forEach(errors_array, function(error) {
            $scope.user.errors.push(key + " " + error);
          });
        });
      });
    }
  }

  $scope.logout = function() {
    Auth.logout().then(function(oldUser) {
      $alert({title: 'Logged out.', content: '', placement: 'bottom-left', type: 'success', duration: 3, show: true});
      $state.go('home');
    }, function(error) {
      // An error occurred logging out.
    });
  }

});

So this works fine in a few other browsers, however, Safari doesn’t like it. The problem happens when you switch from one state to the other. For example, initial loading of the Login view everything works as advertised. When a user tries to go from the Login to the Registration view the form fields are not editable. Why? I'm not entirely sure, but the scope.reset() was supposed to reset the data for the form fields when the forms were changed; which seemed to be happening. Since I get paid by the hour I found an easier way around it than digging into the guts of Safari to find out exactly why it was happening. After several hours of StackOverflow and Googling Safari text field editing issues to no avail I found the culprit. Using my favorite javascript debugger, the console.log(), I found that Safari had an issue displaying the child scope. So I scrapped the parent-child relationship in the router and moved the small piece of code in the auth.html to all three ‘child’ views.

Check it out:

routes.js


app.config(function($stateProvider, $urlRouterProvider, $locationProvider) {
  $locationProvider.html5Mode(true);
  $urlRouterProvider.otherwise("/");

  $stateProvider.state("default", {
    abstract: true,
    controller: "ApplicationController",
    templateUrl: "layouts/default.html"
  }).state("home", {
    parent: "default",
    url: "/",
    //controller: "ApplicationController",
    templateUrl: "home.html",
    data : { pageTitle: 'Home' }
  })

  //A bunch of other state routes

  // authentication

  // .state("auth", {
  //   abstract: true,
  //   controller: "AuthController",
  //   templateUrl: "layouts/auth.html"
  // })
  .state("login", {
  //  parent: "auth",
    url: "/login",
    controller: "AuthController",
    templateUrl: "auth/login.html"
  })
  .state("register", {
  //  parent: "auth",
    url: "/register",
    controller: "AuthController",
    templateUrl: "auth/register.html"
  })
  .state("forgot-password", {
  //  parent: "auth",
    url: "/forgot-password",
    controller: "AuthController",
    templateUrl: "auth/forgot-password.html"
  })
});

Now all is right with the world.

comments powered byDisqus