Jakob Jingleheimer
Jakob Jingleheimer

Reputation: 31580

UI Router: How to validate parameter before state resolves

I've got a known list of supported values for parameter A. I need to validate the state parameter's value before any of the state's resolves are triggered, and if the value is invalid, to supply a supported value. My initial thought was to use an injectable function for the parameter's value property:

params: {
  A: {
    value: [
      '$stateParams',
      'validator',
      function validateParamA($stateParams, validator) {
        // return some value
      }
    }
  }
}

However, $stateParams is unpopulated at this point (I was hoping for a preview version like what you get in a resolve), and also this would probably set a default value, not the value of the $stateParam itself. So I'm looking for something like urlRouterProvider.when's $match.

My next idea was to just use a urlRouterProvider.when. No-dice: To my dismay, this fires after the state has resolved.

My next idea was to hijack urlMatcherFactory's encode. Same deal (fires after).

Update

Ugh! The problem is that a controller is being executed outside of UI Router via ngController. Moving it inside should fix the sequence issue (and then when should work). Will update later today.

Upvotes: 4

Views: 4095

Answers (4)

con
con

Reputation: 2408

I recently had the same problem and solved it with a $q.defer() call in the resolve callback. I had some default parameters for a calender view and wanted to validate the parameters before hand. Didn't find anything else on this topic but it seems like a quite solid solution. This is my sample state:

$stateProvider.state(
  'tasks.list',
  {
    url     : '/:type?month&year&dueDate', // optional params for filtering
    params  : {
      type    : {
        value : 'all'   // all|open|assigned|my
      },
      month   : {
        value : 1,      // current month, needs a callback, no static value
        type  : 'int'
      },
      year    : {
        value : 2016,   // current year, needs a callback, no static value
        type  : 'int'
      },
      dueDate : {
        value : undefined, // 'no default value' - parses 2016-04-23 to da js date object
        type  : 'date'
      }
    },
    resolve : {
      validParams   : ['$q', '$stateParams',
                       function($q, $stateParams) {
                         var deferred = $q.defer();

                         var allowedTypes = ['all', 'open', 'assigned', 'my'];
                         if (allowedTypes.indexOf($stateParams.type.trim().toLowerCase()) < 0) {
                           // deferred.reject(reason) also takes a simple string or nothing, you can use this information on UI.Router's $stateChangeError Event
                           deferred.reject({
                             error : 'Invalid Value',
                             param : 'type',
                             value : $stateParams.type
                           });
                         }

                         if ($stateParams.month < 1 || $stateParams.month > 12) { 
                           deferred.reject({
                             error : 'Invalid Value',
                             param : 'month',
                             value : $stateParams.month
                           });
                         }

                         if ($stateParams.year < 2014 || $stateParams.year > 2099) {
                           deferred.reject({
                             error : 'Invalid Value',
                             param : 'year',
                             value : $stateParams.month
                           });
                         }

                         // if a _deferred object was already rejected, it can't be resolved anymore, so this doesn't hurt at all
                         deferred.resolve('Valid Values');

                         return _deferred.promise;
                       }],
      taskListModel : ['TaskHttpService', '$stateParams',
                       function(TaskHttpService, $stateParams) {
                         // no matter if 'validParams' is resolved or not, this is called - so you might want to validate again or do some other check if you make an ajax call 
                         return TaskHttpService.loadTasks({
                           month   : $stateParams.month,
                           year    : $stateParams.year,
                           dueDate : $stateParams.dueDate
                         });
                       }]
    },
    views   : {
      menu : {
        templateUrl : '/menu.html',
        controller  : 'MenuController'
      },
      body : {
        templateUrl : '/body.html',
        controller  : 'BodyController'
      }
    }
  }
)

As mentioned in an inline comment, you can pass simple strings or entire objects to deferred.reject(reason) https://docs.angularjs.org/api/ng/service/$q - you might want to listen on the $stateChangeError on $rootScope to do anything with the information:

// test for failed routing access, redirect to index page
$rootScope.$on('$stateChangeError', function(event, toState, toParams, fromState, fromParams, error) {
  if(__.isObject(error)) {
    switch(error.error) {
      case 'Access Denied':
        $state.go('index');
        break;
      case 'Invalid Value':
        console.warn('Invalid URL Params to State %o %o', toState.name, error);
        break;
    }
  }
});

Update After completing the answer I recognized that you asked for a validation before the resolved parameters are called. In the title you say "before state resolves" - so with a rejected promise the state isn't resolved. Maybe this can help you anyways

Upvotes: 2

Jakob Jingleheimer
Jakob Jingleheimer

Reputation: 31580

A MarcherFactory did the trick. After I corrected that ngController nonsense and brought those controllers inside UI Router, it worked just as I expected.

// url: '/{locale:locale}'

function validateLocale(validator, CONSTANTS, value) {
    var match = validator(value);

    if (match === true) {
        return value;
    }

    if (match) { // partial match
        newLocale = match;
    } else {
        newLocale = CONSTANTS.defaultLocale;
    }

    return newLocale;
}

$urlMatcherFactoryProvider.type(
    'locale',
    {
        pattern: ROUTING.localeRegex
    },
    [
        // …
        function localeFactory(validator, CONSTANTS) {
            return {
                encode: validateLocale.bind({}, validator, CONSTANTS)
            };
        }
    ]
);

:Rage:

Upvotes: 4

jrsala
jrsala

Reputation: 1967

What about splitting the state into two: one that does the validation, one that is the actual target state.

$stateProvider
    .state('validationState', {
        // The controller below will not get instantiated without defining template
        template: '',
        controller: function ($stateParams, $state) {
            if (/* your validation */) {
                $state.go('targetState', /* simply forward the valid parameter */);
            } else {
                $state.go('targetState', /* provide your valid parameter value */);
            }
        }
    })
    .state('targetState', {
        // whatever you want to resolve, yadda yadda
    });

Not sure the controller can be replaced with onEnter in the validation state definition, but maybe.

Upvotes: 0

Hugo G.
Hugo G.

Reputation: 636

If A is resolved in the state resolves and other resolves depend on it, you'll be able to check the $stateParams and provide an alternative value if needed. Other resolves will be resolved after A.

$stateProvider
    .state('state', {
        resolve: {
            A: ['$stateParams', 'validator', function($stateParams, validator) {
                return validator.validate($stateParams.A) ? $stateParams.A : 'default';
            }],
            otherResolve: ['A', function(A) {
               ///
            }
        }
    });

Other resolves should not use the $stateParams directly, I don't know if it is a problem for you.

Upvotes: 1

Related Questions