angular-ui / ui-router

The de-facto solution to flexible routing with nested views in AngularJS
http://ui-router.github.io/
MIT License
13.54k stars 3k forks source link

Simplify Redirects (Alias URLs?) #185

Closed timkindberg closed 9 years ago

timkindberg commented 11 years ago

See @ksperling comment in https://github.com/angular-ui/ui-router/issues/174#issuecomment-19370489:

I like the idea of aliases, but I'd be okay with just adding a new redirect method to $stateProvider. Like this: $stateProvider.redirect("/fancypants", stateNameOrURL)

If we did allow urlAlias then I'm not too worried about the various parent/child alias combinations. If that's what the user wants, they should get it.

This also brings up the point of too many providers, see https://github.com/angular-ui/ui-router/issues/184

laurelnaiad commented 11 years ago

on simple redirects without concern for parameters... ...Personally I think a redirect that doesn't support parameters isn't enough to satisfy the "friendly URL" thing, and my experience is that customers/clients/managers will end up asking for them -- better to support it in angular apps than to have people doing crazy things in Apache (or worse) to make it happen.

on whether to reduce the number of providers/services to one... ...I absolutely wholeheartedly think one service (with its provider) is the way to go.

on the complexity of supporting parameterized aliases... I can't and won't try to speak for @ksperling, but this comment made me think about why it might be complicated and/or non-performant to support aliases within state definitions. I'm going to go out on a limb and try to start the process of getting into the nitty gritty.

Right now, there is some interplay within $state(Provider) and $urlRouter(Provider) -- witness this code in the $stateProvider:

// Register the state in the global state list and with $urlRouter if necessary.
if (!state['abstract'] && url) {
  $urlRouterProvider.when(url, ['$match', function ($match) {
    $state.transitionTo(state, $match, false);
  }]);
}

So $state leverages the functionality of $urlRouter to handle url-instigated transitions. $urlRouter ends up with a "when" rule that has a function which calls $state.transitionTo().

And here's how $urlRouterProvider assembles its rules:

this.rule =
    function (rule) {
      if (!isFunction(rule)) throw new Error("'rule' must be a function");
      rules.push(rule);
      return this;
    };

So $urlRouter is bearing an array of rules that it examines one at a time when the $location changes.

$state doesn't really care about the order in which states are defined, because in and of itself everything has specific name and to get to a state you specify the name in the transitionTo() function.

$urlRouter, on the other hand, tests things one at a time to find a match. If I understand it correctly, this comment in the source mentions the possibility of down the road snipping the decision process once a particular pattern is disqualified.

// TODO: Optimize groups of rules with non-empty prefix into some sort of decision tree

That comment begins to introduce the problem (at least I think of it as a problem) of using an array to store the rules -- namely that you have no way of skipping rules that certainly won't match because you don't known how many indices in the array to advance to get to the next rule which has a possibility of matching.

When I began to float the idea of editing states ex post facto, one of the complexities cited was (to paraphrase) "how do you manage the rules"... and given that $stateProvider farms out the url handling to $urlRouter and that its rules are in an array (but not a "disarray" ;) ), this was a completely valid challenge. I'm not going down the path of suggesting that ui-router support editing in this comment, so please bear with me... this is about aliases.

I think I cracked a few nuts that relate to aliases/otherwise, and into merging the functionality of $urlRouter and $state in one service provider in $detour, and I'd be happy to help with the retirement of $urlRouter and the addition of aliases and the otherwise() function support into $state if such changes are deemed useful...

...and I defer to the ui-router team as to whether that's the way to go with $state.

I started to go into greater detail about how $detour works and realized I was getting ahead of myself. I guess my point is that aliases aren't that hard if you merge $urlRouter with $state, but I acknowledge that doing that merging is real work.

ksperling commented 11 years ago

The reason for keeping $urlRouter and $state separate is that

I don't think the complexities of alias support are unmanageable, or really related to $state and $urlRouter being separate. Essentially what will have to happen looks something like this in pseudo code:

determine effective url set for state:
  for each pattern in [state.url + state.urlAliases]
    if (pattern is absolute) add it to state.effectiveUrls
    else for each parentUrl in parent.effectiveUrls
      combine pattern with parentUrl and add it to state.effectiveUrls
  end
verify that all effectiveUrls have the same set of parameters

I do wonder why you'd use lots of aliases in pratice though -- if you get asked to implement 'fancy' URLs, wouldn't you just change the primary URLs of the state to be fancy instead of adding aliases?

laurelnaiad commented 11 years ago

"fancy" may be a misnomer... I'd call them "marketing" urls...usually simpler and smaller than the ones that make the app actually work. It's just a way to do a redirect when you get right down to it. The url does matter for relative paths in the html (as I suspected might be what's tripping up the bootstrap modal in #180), so you may not want to rearrange your hierarchy to support the marketing urls.

ksperling commented 11 years ago

Well... once @nateabele finishes the sref directive, you shouldn't really need to hard-code state URLs in HTML anymore. The state definition would then be the only place containing the actual URL pattern, so you'd be free to change it according to marketing whims ;-)

laurelnaiad commented 11 years ago

I don't understand how @nateabele's changes are going to have an impact on the flyer the marketing team hands out at conferences or puts in a TV ad. There may be one or two links from the outside world into your app if you're lucky!

I'm not sure it's time to retire url's altogether... when you have relative paths in your html to things that aren't states, the url hierarchy matters. Redirects haven't lost their usefulness...

If the $urlRouter when and otherwise functionality goes into $state as aliases and otherwise, then you don't have two orthogonal providers, which I think @ksperling is talking about here...

It think with the '' -> '/' fix and maybe alias URLs there shouldn't be much reason to use $urlRouterProvider directly in most cases.

...though I could be mistaken.

ksperling commented 11 years ago

If there's only 1 or 2 external URLs into the average app, I'm not quite sure why aliases are important then?

In either case, I think $state should only handle URLs that either directly or indirectly (alias / redirect) refer to a state. Lumping everything together in one monolithic service is one of the main design mistakes of the existing $route service in my mind.

timkindberg commented 11 years ago

$stateProvider.redirect(stateNameOrUrl, stateNameOrUrl);

timkindberg commented 11 years ago

I don't really think alias is needed, because I think it promotes the use of redirects a bit too much. It indirectly tells the dev that every state could/should have some alias urls. Instead if we use a method like $stateProvider.redirect(stateNameOrUrl, stateNameOrUrl); then they'll only set up the redirects they really need.

laurelnaiad commented 11 years ago

Maybe by using the term redirect I'm making things confusing. When I've said redirect I was referring to the aliad feature which would be within state definitions. I wasn't indicating that I perceive the need to a separate redirect feature to go between states.

laurelnaiad commented 11 years ago

But you need to redirect from an existing state -- which defeats the purpose. The purpose is to give alias urls to a state -- and nobody's gonna die if someone decides to use the feature "too much" -- it would be questionable, but it's their choice.

ksperling commented 11 years ago

Hm, one easy way to side-step the whole "combination of parent aliases" problem would be to require alias URLs to be absolute (which in most reasonable use-cases they should be anyway). So all $state needs to do is validate that the alias has all the parameters the state needs and set up the redirect.

nateabele commented 11 years ago

I'm :thumbsdown: on this. Each state should have one and only one canonical URL. If you want to have short URLs, create an empty state with only onEnter, and use transitionTo() to redirect. The way to use a good routing system is that, once you define your routes (or states, in this case), you only ever refer to those in app code, not directly to URLs.

laurelnaiad commented 11 years ago

My $0.02...

@nateabele, I'm glad you're keeping the topic alive, even if we seem to be on opposite sides of the thinking... :)

Is it the feature's value the question (does it complicate the ui, is it going to be used, does it fit in the mission, how many lines of code and bugs saved in client apps, etc.)? Or the feature's cost? Or some combination of both?

I ask because you're indicating that the user should go through some hoops to do this -- nothing too complicated, but work all the same -- and the alternative is that the library goes through those hoops.

I don't see it as painting too far out of the lines to hope that you could deep link extra externally-shared links to your states.

Why not do this right in the library, rather than try to document use of onEnter() and transitionTo() for that use case and then have people using a bunch of deviants of the sample? If done in the library, it's really not much more complicated than the "otherwise" redirect which should be in $state if $urlRouter isn't retired. If everyone's supposed to use $urlRouterProvider to deal in urls, then aren't a lot of people going to end up using both and wonder how they're related?

If it's how the thing would be implemented -- of course all links within the app should be using hierarchical state names rather than URLs... those are the true CNs.... even the "url" isn't as canonical as the state name and its parameters. The "url" is is the second-level canon... and the one that ends up being current when you go to a state... but aliases are proven to be valuable all over the place (DNS, OOP, yadah, yadah). I see no reason why aliases in the sense we're talking about should be treated as workarounds or relegated to some deeper level of the tech stack, such as the web server, proxy, or load distributor.

Seems to me supporting aliases lets you stop publicizing $urlRouter and have no need to keep it as part of the public api... which might help a little with adoption.

ksperling commented 11 years ago

@nateabele I agree that states should have one true canonical URL. However being able to easily set up a few redirects doesn't contradict that basic premise.

And redirects should happen on the URL level; I strongly disagree with using 'dummy' / transition states that do another transition in onEnter.

@stu-salsbury I don't quite understand what you dislike about $urlRouter being part of the public API. It's true that it's a lower-level API, but it's there for when people want to do lower-level things. In fact I think that blurring the distinction between state handling and routing by shoving things like .otherwise onto $state makes it harder for people to understand that conceptually a state and a route (aka urlRouter rule) are different things.

nateabele commented 11 years ago

In fact I think that blurring the distinction between state handling and routing by shoving things like .otherwise onto $state makes it harder for people to understand that conceptually a state and a route (aka urlRouter rule) are different things.

This.

laurelnaiad commented 11 years ago

Cool with me! I think states and routing are deeply enough entwined to manage them together as one service from an application's perspective.

To me it feels like making two things out of one.

Since detour is meant to be a way to let you define both urls and states with one api, perhaps I'm biased. I wouldn't want to require people to use an extra service and provider just to support otherwise, and it's a hop skip and jump from there to aliases,

However, for ui-router, moving aliases/otherwise to $state is just an idea for how to make it easier for app developers -- if it's clearer for them if separate providers and services are exposed -- so that everyone understands their different functions -- then it's certainly worth the bytes to expose both separately. Usability is key.

No worries!

timkindberg commented 11 years ago

I'm more on @stu-salsbury side of this debate. It feels like states and routes belong together enough that adding sugar to stateProvider doesn't feel odd to me. I do think it will reduce confusion for newer users as I am still a new user and it makes sense to me. However I won't cry too much if I have to bust out urlRouter to do my URL based functions.

laurelnaiad commented 11 years ago

Not to pile on because I've said enough, but I'm still feeling like something isn't clicking. The concept of two providers and services just seems ludicrous to me. I must not be getting it and curiosity is driving me forward.

If $state relies on $urlRouter to do its url handling, then how separate are they? $state can't stand on its own without $urlRouter, and yet $urlRouter really isn't useful other than to do "otherwise" redirects to a "404-ish" state and to handle when redirects that could be better managed with aliases... and yet they should be seen as separate things? Every state with a url is leaning on $urlRouter. $state configures a $urlRouter "when" rule to get itself squared away with urls, and the $urlRouter relies on $state to clean up the urls when transitionTo is called, and it's hanging onto transitionTo functions in its when handlers.... they couldn't be more tightly linked without a circular dependency which is avoided due to the eventing mechanism of route changes.

Advantages to combining:

Advantages to keeping them separate:

I must be missing something. As @timkindberg says, I won't cry if they're kept separate. If I don't understand the logic, no hard feelings either way.

Is it at all possible that $urlRouter is separate today because of an early impulse to support something that resembles $routeProvider? If $routeProvider goes away, what good is left? This paragraph is sponsored by those who weren't around when this project first took flight...

Either way, ui-router is a heckuvah great solution to routing and state management. Don't let my pushback convince you I'm not grateful for it.

timkindberg commented 11 years ago

Yeah I'm on board with @stu-salsbury. This certainly isn't the most important thing we need, and I do see the concern of merging them too much that devs become confused or more importantly, don't learn to fully understand the power of each provider in its own right.

So I think if we create an otherwise/redirect method that redirects to a state as opposed to a url, then its a totally legal addition to $state in the same vein as having a url property in the state config. So how about this?

$stateProvider.redirect(url, stateName);

Now this method is totally in "state" world. There could be no confusion here as to this being a url specific method. It redirects from a url to a state.

nateabele commented 11 years ago

$state can't stand on its own without $urlRouter

But it can. You can wire up a whole state-driven application without ever using routing.

This has nothing to do with footprint or API surface area. This has to do with architecture design decisions that are conceptually right vs. those that are conceptually wrong. For some background, I suggest you read up on the Single Responsibility Principle.

timkindberg commented 11 years ago

@nateabele, curious if you think my idea for $stateProvider.redirect(url, stateName) would fall outside of the state responsibility.

nateabele commented 11 years ago

@timkindberg It would. You're asking $stateProvider to 'know' about URLs in a way that it shouldn't. The state machine is an abstraction on top of URLs and routing. You establish a relationship between a state and its route at the outset of setting up the system. After that, it's an implementation detail you no longer concern yourself with, because it's beneath the layer of abstraction at which you're working. You don't use your high-level abstraction reach down into lower layers and tinker with it.

timkindberg commented 11 years ago

So setting up a url is ok in the config, but dealing with them afterward in a method is not ok? If we really were splitting things out into true SRP objects, wouldn't we leave URL out of state completely and then have an additional service called something like URLStateConnector which simply connected urls to states?

laurelnaiad commented 11 years ago

$stateProvider does not stand on its own. Every state with a url is creating a $urlRouterProvider when rule.

You concern yourself with url's because they are the addresses to your whole system that the outside world can use to find and keep track of things.

laurelnaiad commented 11 years ago

$stateProvider.redirect(url, stateName);

This doesn't address parameters and is unwieldy because it lives away from the state to which it applies. I was asked why I have something against urlRouter... it's because aliases in the state keep all of the code that relates to addressing that state in one place (with the exception of an otherwise/fallback state, which is inherently its own animal).

laurelnaiad commented 11 years ago

I'd say SRP is fine and all, but at present each state's UrlMatcher gets lost in an array in a different service. It's called ui-router for a reason. I fold.

ksperling commented 11 years ago

$urlRouter is also there for people that want to have URLs that do other things that don't map to $state. Or somebody that doesn't like how $state works write their own version and still re-use $urlRouter, $resolve (about to commit that one in the next few days), $view (TBD), ...

It's perfectly possible to use $state without $uR either if you don't reflect state in the URL, or you could write your own $location processing that ends up calling $state.transitionTo(). Lumping everything into one services makes all these things impossible. SRP isn't only about the use cases we can think of now, it's also about making it easier to reuse pieces of code in other use cases and contexts that we haven't thought of.

That said, having a state.urlAliases property that is an array of absolute URLs and instructs $state to set up a $uRP.redirect to the primary URL of that state is simple and should cater for 95% of the simple cases where people don't understand why they need to use $uR separately. It also won't couple $state and $uR significantly tighter than they are now; it's the same kind of optional interaction as configured by state.url.

timkindberg commented 11 years ago

Fine by me, as long as one of the aliases can be "" or "/".

Sent from my iPad

On Jun 18, 2013, at 6:00 PM, Karsten Sperling notifications@github.com wrote:

$urlRouter is also there for people that want to have URLs that do other things that don't map to $state. Or somebody that doesn't like how $state works write their own version and still re-use $urlRouter, $resolve (about to commit that one in the next few days), $view (TBD), ...

It's perfectly possible to use $state without $uR either if you don't reflect state in the URL, or you could write your own $location processing that ends up calling $state.transitionTo(). Lumping everything into one services makes all these things impossible. SRP isn't only about the use cases we can think of now, it's also about making it easier to reuse pieces of code in other use cases and contexts that we haven't thought of.

That said, having a state.urlAliases property that is an array of absolute URLs and instructs $state to set up a $uRP.redirect to the primary URL of that state is simple and should cater for 95% of the simple cases where people don't understand why they need to use $uR separately. It also won't couple $state and $uR significantly tighter than they are now; it's the same kind of optional interaction as configured by state.url.

— Reply to this email directly or view it on GitHub.

laurelnaiad commented 11 years ago

@timkindberg: gosh I hope you're kidding :) Long story -- not meant for github -- but I needed that.

I agree $urlRouter provider's functionality is worth having access to, and I'd be psyched if aliases/otherwise (and @timkindberg's "/"<->"" feature (j/k: I imagine we all do it)) were accessible to the majority of apps that are just going to use the otherwise feature and never concern themselves with aliases.

timkindberg commented 11 years ago

whadido? I don't get it.

laurelnaiad commented 11 years ago

The "" <-> "/" feature cracked me up and I needed a lift. Nothing you did.

ksperling commented 11 years ago

@timkindberg I'm intending to automatically treat "" as "/" in urlRouter as it's really just an artifact of $location hash mode. So handling "" won't need any redirects on the users part.

nelsonpecora commented 10 years ago

@stu-salsbury are you advocating something like this (having it inside the state config)?

$stateProvider
    .state('specificthing', {
        url: '/very-long-and-convoluted-url-structure/specificthing',
        templateUrl: 'specificthingtemplate.html',
        controller: 'SpecificThingCtrl',
        alias: '/thing' // short url alias that basically does $stateProvider.redirect("/thing", specificthing)
    });
vizo commented 10 years ago

Can somebody explain to me, why one state shouldn't have multiple urls? Why this is so bad idea? A lot of other server side frameworks solved this by allowing multiple urls (first match).

I want to make multilingual SEO friendly urls. I think, this is very important for google, this days. This would be so easy to implement, if url parameter in state would be just a parameter (as it is now) or an array of url strings or an array of UrlMatchers.

For now i must create different state for each language, like you see below. But if i do it like this, i lose deep nesting of states (because of different state names for each language) and when I want to make sref or call $state.href, i always need to add locale at end of name of state, like $state.href("root.settings.en"). But this is silly, because all i want is to transit to the same state with different url.

$stateProvider
    .state('root', {
        url: '/{locale}'
    })
    .state('root.settings', {
        templateUrl: 'setting.html',
        controller: 'SettingsCtrl'
    })
    .state('root.settings.en', {
        url: '/{locale:en}/settings'
    })
    .state('root.settings.sl', {
        url: '/{locale:sl}/nastavitve'
    });

If there would be option in state to be an array of ulrs it would so much easier and done right:

$stateProvider
    .state('root', {
        url: '/{locale}'
    })
    .state('root.settings', {
        url: [
            '/{locale:en}/settings',
            '/{locale:sl}/nastavitve'
        ],
        templateUrl: 'setting.html',
        controller: 'SettingsCtrl'
    })

In this case u can call $state.href("root.settings") and u don't even need to set param "locale" explicitly, because it's inherited from root state (if current url is e.g. "domain.com/en") and automatically returns matched url "/en/settings".

As i checked source code, this would not change structure so much and it would solve a lot of wishes of multiple urls/aliases.

UPDATE: It took me 2 days but I solved it by implementing a custom urlMatcher to which u pass array or urls as pattern. It's a little bit tricky solution but it works ! :)

$stateProvider
    .state('root', {
        url: '/{locale}'
    })
    .state('root.settings', {
        url: $localeUrlMatcherFactoryProvider.compile([
            '/{locale:en}/settings',
            '/{locale:sl}/nastavitve'
        ]),
        templateUrl: 'setting.html',
        controller: 'SettingsCtrl'
    })

so: $state.href('root.settings', {locale: 'en'}) returns /en/settings $state.href('root.settings', {locale: 'sl'}) returns /sl/nastavitve

or if you are already on location "domain.com/sl/foo" you can generate links without passing "locale" parameter, it's inherited from parent state (in this case "sl"). So all "sref"-s in templates will be replaced with current locale urls.

Does anybody have better solution/suggestion?

34r7h commented 10 years ago

+1

nateabele commented 10 years ago

@vizo Sounds great. You should publish your custom UrlMatcher as an extension module to UI Router.

vizo commented 10 years ago

Ok no problem, i can publish it. But first i want to clear out how it works.

LocaleUrlMatcher is just a wrapper around UrlMatcher(s) for each url. So if you provide 2 urls, 2 UrlMatchers are created inside, and then LocaleUrlMatcher decide which one to return, depending on "locale" var.

All what I am worried about, is function "concat". When function is called, i don't know which url (for locale) to concat. For now it concats url of last "locale" provided to "format" function. Any ideas around this? Is it possible to add parameter to "concat" function, like locale or something? Is "concat" function meant to be called by user or by code?

And one more question ... I looked at source code and it seems you can't have relative paths in custom UrlMatcher. Am i wrong?

Because if it's possible to have relative urls, it should be better to use syntax like this:

url: $localeUrlMatcherFactoryProvider.compile({
    en:'/settings',
    sl:'/nastavitve'
}),
...
bdefore commented 10 years ago

@vizo I'd too would like to see your implementation of $locationUrlMatcherFactoryProvider. i can't seem to get any $urlMatcherFactory to inject properly.

vizo commented 10 years ago

@bdefore: I rewrote implementation of i18nUrlMatcherFactoryProvider so please look at post below ...

geyang commented 10 years ago

so it is Aug 04 now as we speak. Is multiple url implemented now? I like the syntax in which we pass a list to the url field.

Charkhan commented 10 years ago

@vizo : I tried your module for a multilingual site, and the multi-url is working but it brings a annoying bug : all href generated by the states which are "multi-url'ed" become "undefined" ( big trouble for the HTML snapshots -> SEO compliant).

I'm using the $locationProvider.html5Mode(true).hashPrefix('!') thing...

It's really hard to do multilingual sites with SPA :/

vizo commented 10 years ago

Example of usage of multilingual states with $i18nUrlMatcherFactoryProvider

Be careful about state's hierarchy! All multilingual states and sub-states must be children of "app.locale" state, with absolute url which must include ":locale" variable. All multilingual children states inherit ":locale" variable from parent state "app.locale", so that $i18nUrlMatcherFactoryProvider knows which multilingual url to use.

Example of $stateProvider's config

$stateProvider
  .state('app', {
     views: {
       // Here you can load layout template and all other templates that will be always present in application (contollers will not reload when you chnage locale from e.g. domain.com/en to domain.com/sl)
     }
  })

  // Must be child of "app" state
  .state('app.root', {  // When user comes to e.g. domain.com
    url: '/',
    resolve: {
      // Here you can get locale from server's session or detect browser's accepted languages
    },
    views: {
      '@': {
        controller: ['$state', function($state) {
          // Here you redirect user to resolved locale from server/browser, now is set to 'sl' as default
          $state.go('app.locale', {locale: 'sl' });
        }]
      }
    }
  })

  // Must be child of "app" state
  .state('app.locale', { // When user comes to e.g. domain.com/en
    url: '/:locale',
    resolve: {
      // Here you can send current locale ($state.params.locale) to server to save it in session and/or get some language file(s) for translation 
    },
    views: {
       // Show default (home) page
    }
  })

  /**
   * All multilingual states below MUST be children of "app.locale" state, because they inherit ":locale" variable that $i18nUrlMatcherFactoryProvider use
  **/

  .state('app.locale.login', {  // When user comes to e.g. domain.com/en/login
    url: $i18nUrlMatcherFactoryProvider.compile({
      en: '/:locale/login',
      sl: '/:locale/prijava'
    }),
    views: {
       // Show login page
    }
  })

  .state('app.locale.logout', {  // When user comes to e.g. domain.com/en/logout
    url: $i18nUrlMatcherFactoryProvider.compile({
      en: '/:locale/logout',
      sl: '/:locale/odjava'
    }),
    views: {
      '@': {
        controller: ['$state', function($state) {
          // Redirect user after logout to home url with same locale
          $state.go('app.locale');
        }]
      }
    }
  })

Source code of custom urlMatcher - $i18nUrlMatcherFactory:

function I18nUrlMatcher(patterns, useCaseInsensitiveMatch, rootLocale, $urlMatcherFactoryProvider) {
  this.urlMatchers = [];
  this.params = [];
  this.values = {};
  this.rootLocale = rootLocale;

  Object.keys(patterns).forEach(function(locale) {
    var pattern = patterns[locale];
    var urlMatcher = $urlMatcherFactoryProvider.compile(pattern, useCaseInsensitiveMatch);
    this.urlMatchers[locale] = urlMatcher;
    this.params = urlMatcher.params;
    if (this.rootLocale == locale) {
      this.params.push('rootLocale');
    }
    if (this.params.indexOf('locale') < 0) {
      if (!this.rootLocale) {
        throw new Error("Missing ':locale' parameter in url '"+locale+": "+pattern+"'. If you still want to use it, you must set default locale by $I18nUrlMatcherFactory.rootLocale(rootLocale).");
      } else if (this.rootLocale != locale) {
        throw new Error("Missing ':locale' parameter in url '"+locale+": "+pattern+"'. Only urls of default locale, which is '"+this.rootLocale+"', can be without ':locale' parameter.");
      } else {
        this.params.push('locale');
      }
    }
  }.bind(this));
}

I18nUrlMatcher.prototype.format = function (values) {
  this.values = angular.extend({}, values);
  // Remove locale from default locale url
  if (values.locale == this.rootLocale) delete values.locale;
  return this.urlMatchers[this.values.locale] ? this.urlMatchers[this.values.locale].format(values) : null;
};

I18nUrlMatcher.prototype.concat = function (pattern) {
  return this.urlMatchers[this.values.locale] ? this.urlMatchers[this.values.locale].concat(pattern) : null;
};

I18nUrlMatcher.prototype.exec = function (path, searchParams) {
  var values;
  Object.keys(this.urlMatchers).some(function(locale) {
    var urlMatcherValues = this.urlMatchers[locale].exec(path, searchParams) || {};
    // Inject default locale
    if (this.rootLocale && urlMatcherValues.hasOwnProperty('rootLocale')) urlMatcherValues.locale = this.rootLocale;
    if (urlMatcherValues.locale == locale) {
      values = urlMatcherValues;
      return true;
    }
  }.bind(this));
  return values;
};

I18nUrlMatcher.prototype.parameters = function () {
  return this.params;
};

/*
I18nUrlMatcher.prototype.toString = function () {
  return this.source;
};
*/

/**
 * @ngdoc object
 * @name ui.router.util.$i18nUrlMatcherFactory
 *
 * @description
 * Factory for {@link ui.router.util.type:I18nUrlMatcher} instances. The factory is also available to providers
 * under the name `$i18nUrlMatcherFactoryProvider`.
 */
function $I18nUrlMatcherFactory($urlMatcherFactoryProvider) {

  var useCaseInsensitiveMatch = false;
  var rootLocale;

  this.rootLocale = function(value){
    if (value) {
      rootLocale = value;
    } else {
      return rootLocale;
    }
  };

  /**
   * @ngdoc function
   * @name ui.router.util.$i18nUrlMatcherFactory#caseInsensitiveMatch
   * @methodOf ui.router.util.$i18nUrlMatcherFactory
   *
   * @description
   * Define if url matching should be case sensistive, the default behavior, or not.
   *   
   * @param {bool} value false to match URL in a case sensitive manner; otherwise true;
   */
  this.caseInsensitiveMatch = function(value){
    useCaseInsensitiveMatch = value;
  };

  /**
   * @ngdoc function
   * @name ui.router.util.$i18nUrlMatcherFactory#compile
   * @methodOf ui.router.util.$i18nUrlMatcherFactory
   *
   * @description
   * Creates a {@link ui.router.util.type:UrlMatcher} for the specified pattern.
   *   
   * @param {string} pattern  The URL pattern.
   * @returns {ui.router.util.type:I18nUrlMatcher}  The I18nUrlMatcher.
   */
  this.compile = function (patterns) {
    return new I18nUrlMatcher(patterns, useCaseInsensitiveMatch, rootLocale, $urlMatcherFactoryProvider);
  };

  /**
   * @ngdoc function
   * @name ui.router.util.$i18nUrlMatcherFactory#isMatcher
   * @methodOf ui.router.util.$i18nUrlMatcherFactory
   *
   * @description
   * Returns true if the specified object is a UrlMatcher, or false otherwise.
   *
   * @param {Object} object  The object to perform the type check against.
   * @returns {Boolean}  Returns `true` if the object has the following functions: `exec`, `format`, and `concat`.
   */
  this.isMatcher = function (o) {
    return isObject(o) && isFunction(o.exec) && isFunction(o.format) && isFunction(o.concat);
  };

  /* No need to document $get, since it returns this */
  this.$get = function () {
    return this;
  };
}

// Register as a provider so it's available to other providers
angular.module('ui.router.util').provider('$i18nUrlMatcherFactory', ['$urlMatcherFactoryProvider', $I18nUrlMatcherFactory]);

Usage in controllers

// Go to state's url in current :locale language
$state.go('app.locale.login');
// Go to state's url in Slovenian language
$state.go('app.locale.login', {locale: 'sl' });

Usage in templates

With binding:

<a ng-href="{{$state.href('app.locale.login')}}">State's url in current :locale language</a>
<a ng-href="{{$state.href('app.locale.login', { locale: 'sl' })}}">State's url in Slovenian language</a>

Without binding:

<a ui-sref="app.locale.login">State's url in current :locale language</a>
<a ui-sref="app.locale.login({ locale: 'sl' })">State's url in Slovenian language</a>
vizo commented 10 years ago

@Charkhan ... I rewrote Implementation of $i18nUrlMatcherFactory and described more details how to use it. I hope it will help you. Please let me know, if everything works for you. If it does, i will publish it as an extension module to UI Router.

You can also check my newest project i am working on, that uses $i18nUrlMatcherFactory, on http://naslovnice.si (if my local server will be up at that moment :-)

Charkhan commented 10 years ago

Wow thanks for the quick and complete answer !

I'm trying this today , but I'm kinda new to Angular so there are concepts that I don't quite understand. I'll tell you when I get too much trouble :p

Charkhan commented 10 years ago

Ok so here we are !

Angular is yelling at me that it doesn't know about a i18nUrlMatcherFactoryProvider :/

Have you some concrete exemple for the resolve part because I undestand I will have to make some AJAX request for getting and setting the session's language but I'm not sure about the syntax to get and set the locale var (I just hard-coded it for the moment).

Also do I need to make multiple ui-view in order to match the hierachical state (app -> root -> locale) ?

My app.js :

var App = angular.module('app', ['ui.router', 'ui.router.util', 'ngAnimate', 'ngProgress']);

App.config(
    ['$locationProvider', '$stateProvider', '$urlRouterProvider', '$i18nUrlMatcherFactoryProvider ',
    function($locationProvider, $stateProvider, $urlRouterProvider, $i18nUrlMatcherFactoryProvider ) {
       $locationProvider
        .html5Mode(true)
        .hashPrefix('!');

    //$urlRouterProvider.otherwise('/');
    //console.log(BlogInfo.theme);

    $stateProvider
        .state('app', {
        })

        // Must be child of app
        .state('app.root', {  // When user comes to e.g. domain.com
            url: '/',
            resolve: {
              // Here you can get locale from server's session or detect browser's accepted languages
              locale: 'en'
            },
            views: {
              '@': {
                controller: ['$state', function($state) {
                    console.log($state);
                    // Here you redirect user to resolved locale
                    $state.go('app.locale', {locale: locale});
                }]
              }
            }
        })

        // Must be child of app
        .state('app.locale', { // When user comes to e.g. domain.com/en
            url: '/:locale',
            resolve: {
              // Here you can send current locale ($state.params.locale) to server to save it in session and/or get some language file(s) for translation
            },
            views: {
               // Show default (home) page
            }
        })

        .state('app.locale.home', {
            url : $i18nUrlMatcherFactoryProvider.compile({
              fr: '/',
              en: '/:locale/'
            }),
            templateUrl : BlogInfo.theme + '/partials/home.php',
            //controller  : 'mainController'
        })
vizo commented 10 years ago

Remove space character on the end in config dependencies ...

From:

App.config(
    ['$locationProvider', '$stateProvider', '$urlRouterProvider', '$i18nUrlMatcherFactoryProvider ',

To:

App.config(
    ['$locationProvider', '$stateProvider', '$urlRouterProvider', '$i18nUrlMatcherFactoryProvider',

Use "app.locale" as home state, so it will be on domain/fr your home ... And all multilingual routes must include ":locale" variable, this is important:

        .state('app.locale', { // When user comes to e.g. domain.com/en
            url: '/:locale',
            resolve: {
              // Here you can send current locale ($state.params.locale) to server to save it in session and/or get some language file(s) for translation
            },
            templateUrl : BlogInfo.theme + '/partials/home.php',
        })

Example of resolve:

    .state('app.root', {  // When user comes to e.g. domain.com
        url: '/',
        resolve: {
          locale: ['$q', '$timeout', function($q, $timeout){
            var deferred = $q.defer();
            $timeout(function() {
              // $timeout is used just as example of your server request, when you get result, call deffered.resolve(result);
              deferred.resolve('en');
            }, 1000);
            return deferred.promise; // You must always return promise in resolve function, see https://github.com/angular-ui/ui-router/wiki#resolve for examples
          }]
        },
        views: {
          '@': {
            controller: ['$state', 'locale', function($state, locale) {
                // Here you redirect user to resolved locale
                $state.go('app.locale', {locale: locale});
            }]
          }
        }
    })
Charkhan commented 10 years ago

Holy .... I'm so dumb , didn't even see it.... sorry...

I'll try this on Monday and thanks again !

vizo commented 10 years ago

@Charkha no problem, it happens a lot when we use copy/paste:D

Charkhan commented 10 years ago

Hi again,

I could work on it today, and the fix did the trick. but I have still some issues :/

I'm using Wordpress as CMS under the hood and I force him to route every page on this index.php

<?php get_header(); ?>

<div id="main" ng-controller="mainController">
    <div class="page" id="dynamic-content" ui-view></div>
</div>

<?php get_footer(); ?>

That way, I can't put header and footer in the AJAX loaded views because my whole HTML stucture would be resumed to :

<div id="main" ng-controller="mainController">
    <div class="page" id="dynamic-content" ui-view></div>
</div>

So no scripts loaded , no metatags , no footer etc...

I'm pretty sure there might be an answer with the multiple views functionnality.... Also I'm not sure about the ui-view structure to adopt with the locale thing.

Charkhan commented 10 years ago

@vizo Update !

I managed to display correctly the homepage. But... Header still have issues with generated urls (null values) so I tried to include in a way the header to be in the ui-view, in vain ... the content disappear though the header and the content AJAX are called...

var App = angular.module('lifeds', ['ui.router', 'ui.router.util', 'ngAnimate', 'ngProgress']);

App.config(
    ['$locationProvider', '$stateProvider', '$urlRouterProvider', '$i18nUrlMatcherFactoryProvider',
    function($locationProvider, $stateProvider, $urlRouterProvider, $i18nUrlMatcherFactoryProvider) {

    $locationProvider
    .html5Mode(true)
    .hashPrefix('!');

    //$urlRouterProvider.otherwise('/');
    //console.log(BlogInfo.theme);

    $stateProvider
        .state('app', {
        })

        // Must be child of app
        .state('app.root', {  // When user comes to e.g. domain.com
            url: '/',
            resolve: {
              // Here you can get locale from server's session or detect browser's accepted languages
              locale: ['$q', '$timeout', function($q, $timeout){
                var deferred = $q.defer();
                $timeout(function() {
                  // $timeout is used just as example of your server request, when you get result, call deffered.resolve(result);
                  deferred.resolve('en');
                }, 1000);
                return deferred.promise; // You must always return promise in resolve function, see https://github.com/angular-ui/ui-router/wiki#resolve for examples
              }]
            },
            views: {
              '@': {
                controller: ['$state', 'locale', function($state, locale) {
                    console.log('$state');
                    // Here you redirect user to default EN language
                    $state.go('app.locale', {locale: locale});
                }]
              }
            }
        })

        // Must be child of app
        .state('app.locale', { // When user comes to e.g. domain.com/en
            url: '/:locale',
            resolve: {
              // Here you can send current locale ($state.params.locale) to server to save it in session and/or get some language file(s) for translation
            },
            views: {
               // Show default (home) page
               '@': {
                templateUrl : BlogInfo.theme + '/partials/home.php',
              },
              'header' : {
                templateUrl : BlogInfo.theme + '/header.php',
              }
            }
        })

        //Useless atm
        .state('app.header', {
            views: {
              '@': {
                templateUrl : BlogInfo.theme + '/header.php'
              },
            }
        })

        .state('app.locale.home', {
            url : $i18nUrlMatcherFactoryProvider.compile({
              fr: '/',
              en: '/:locale/'
            }),

            views: {

            }
            //controller  : 'mainController'
        })

        .state('app.locale.expertise', {
            url: $i18nUrlMatcherFactoryProvider.compile({
              fr: '/expertise/',
              en: '/:locale/expertise/'
            }),
            templateUrl : BlogInfo.theme + '/partials/expertise.php',
        })
}]);

Index.php

<div id="main" ng-controller="mainController">
       <div class="page {{ pageClass }}" id="dynamic-content" ui-view></div>
</div>

/partials/home.php

<?php
    //Load wordpress env...
    $parse_uri = explode( 'wp-content', $_SERVER['SCRIPT_FILENAME'] );
    require_once( $parse_uri[0] . 'wp-load.php' );

?>
<div ui-view="header"></div>

<section>
     <!-- Content of the home... -->
</section>

One more thing : if I go to /expertise, the state manager does litterally nothing , like if the URL was not detected. The routing system is only working for the / path.

I'm getting stuck right know on thoses points :/