Angular ui router dynamic url from server and API - javascript

I'm working on a web app which implements a wizard-like behavior. it uses an API to get the "wizard" steps. the API works in a way where you send a request with the current step and all previous answers so far > and get the next step (which also includes the step "name").
My problem is with the URL's of my app, since I need/want them to match the current step. BUT I don't know what is the "current" step until the user already routed to the page.
Example:
user clicks on <a ui-sref="wizard({step: 'second'})"> ('second' is the current step)
$stateProvider than invoke templateUrl e.g: http://whatever.com/getStep/second
server gets the "second" param and passes to the API: current step: second & answer to first step 1 (for example) than receiving the next step HTML and name - lets say: "step_three" and some HTML
Angular renders that HTML
problem with the example above: the user is now in http://myapp.com/#/wizard/second and the HTML that is shown is for the "step_three"
What I would like to do is a request to the server with does params & without routing > than according to the response set the state config: url and template and than "route" to that state. so that the user will be in http://myapp.com/#/wizard/XXX and see the HTML for XXX...
Is this possible? any ideas?

Simplistic approach (you could choose template on the fly in route if you would like)
.state('wizard', function() {
url: 'wizard/:step',
templateUrl: 'views/template/wizard.html',
controller: function($scope, $stateParams, stepData) {
$scope.step = $stateParams.step;
$scope.stepData = stepData;
},
resolve: {
stepData: function(api, $stateParams) {
return api.getdata($stateParams.step);
}
}
})
in wizard html:
<div ng-show='step == "first"'>first data content</div>
<div ng-show='step == "two"'>second data content</div>
<div ng-show='step == "three"'>third data content</div>
if you want to avoid using ng-shows and prefer a different template depending on the route, then use the templateProvider instead of templateUrl:
templateProvider: function($stateParams) {
return a valid string path to the template based on the $stateParams.step value
},

Just a note which might not directly answer your question but give you another option of what is possible to do with ui-router and ng bindings.
In your case you can specify ui-sref as such:
ui-sref="wizard({step: 'second'})"
However you can also use variable bindings inside the ui-sref.
ui-sref="wizard({step: step})"
^
this is a variable in your scope ($scope.step = 'second')
You can also use variable to modify the url-name like:
ui-sref="wizard{{step}}({step: 'second'})"
$scope.step = 'Second'; //results in: ui-sref="wizardSecond({step: 'second'})"
$scope.step = 'Foo'; //results in: ui-sref="wizardFoo({step: 'second'})"

Related

How to structure a multi step wizard with ajax calls in angularjs

I want to build a multi step wizard with ajax calls in between:
I currently use ui.router for views of the wizard steps which works fine.
On the first page the users enters some data e.g. playerid.
On the second page i want to display some data pulled from the server corresponding to that playerid.
How should i structure that? Because i read that controllers should only write to the model, but i need to read playerid the user entered to make the ajax call..?
Here is a Plunk how i do it right now:
http://plnkr.co/edit/4ZEdYHUqovn2YfkUpp2y?p=info
I personally would have done it this way (plunker):
The routing :
$stateProvider
.state('view1', {
url: "/view1",
templateUrl: "view1.html",
controller:"WizardCtrlStep1"
})
.state('view2', {
url: "/view2",
templateUrl: "view2.html",
controller:"WizardCtrlStep2",
params:{
playerId:null
},
resolve:{
player:function($http, $stateParams){
//you can use the player id here
console.log($stateParams.playerId);
return $http.get("test.json");
}
}
})
I really really like to have a single controller per state. It avoid thing to get messy.
I also use a resolve to do the ajax call before the step2 view loading.
Here is the controller of the 2nd step
//I inject the "player" resolved value
controller('WizardCtrlStep2', function($scope, player) {
$scope.name = 'World';
//to access the result of the $http call use .data
$scope.player = player.data;
})
And finally the HTML
<input type="text" ng-model="main.playerId">
<button ui-sref="view2({playerId:main.playerId})">proceed</button>
Here i give ui-sref a param for "playerId" that will be used in the resolve function.
Hope it was clear, if you have any question feel free to ask.

Angular: how to make a "search" take you to another route and display results?

I have a main page with a nav, and each nav option takes you to another route. It all looks like a single page app, but each "page" has it's own route and controller.
My problem is that I want to put a search box in the navbar. When someone uses the searchbox, I want to take the user to the "search" route and then display the results. I'm having a lot of trouble figuring out these two issues:
Where do I store this "searchbox" logic? E.g. when someone searches, they choose the type of search from a dropdown, then the search query in the inputbox. I have special logic to automatically choose which dropdown value based on the value typed in the inputbox.
How do I redirect to the
"search" route and display the results based on the input from the
previous page?
It's probably clear I'm a newby to Angular. I'm happy to work out the details, but I'm mainly looking to understand how to structure the solution to this problem. Thanks in advance for your help.
What I love about Angular the most is the amount of options you can apply.
Your goal can be reached either by using a service. A service is a singleton class which you can request from controllers. Being a singleton what ever value you store in the service is available to all controllers. You can than either $watch for value change, use $broadcast to notify data change or use $routeParams to send data with route change.
A service is built as follows :
The following assume you have a global module var named 'app'
app.service('myService', function(){
var myValue;
this.getMyValue = function(){
return myValue;
};
this.setMyValue = function(value){
myValue = value;
};
});
Then you request a service from a controller like you request an angular service such as $scope.
app.controller('myController', ['$scope', 'myServce', function($scope, myService){
$scope.myValue = myService.getMyValue();
//Example watch
$scope.$watch('myValue',function(){
//Search criteria changed!!
}, true);
}]);
Angular is terrific..have fun coding
Basically you would want an own state for your search page, so this is where we begin (I expect you to use the ui-router and not Angulars built in router):
.state('search', {
url: "/search",
templateUrl: "pages/search.html",
controller: 'SearchController as ctrl',
params: { searchString: {} }
})
As you can see, I've defined an additional parameter for the search string that is not part of the URL. Of course, if you like, you could change that and move the parameter to the URL instead:
.state('search', {
url: "/search/:searchString",
templateUrl: "pages/search.html",
controller: 'SearchController as ctrl'
})
The actual search input is pretty straight forward as well, because it's only HTML:
<input type="text" ng-model="searchString" on-key-enter="ctrl.goSearch(searchString)">
The function for the state change has to be placed in the controller for the primary template (e.g. the controller of your navigation bar if the search is located there):
var vm = this;
vm.goSearch = goSearch;
function goSearch(searchString) {
$state.go('main.search', { searchString: searchString });
}
Of interest is also the on-key-enter directive that I've added:
angular.module('your.module')
.directive('onKeyEnter', OnKeyEnter);
function OnKeyEnter() {
return function (scope, element, attrs) {
element.bind("keydown keypress", function (event) {
if(event.which === 13) {
scope.$apply(function (){
scope.$eval(attrs.onKeyEnter);
});
event.preventDefault();
}
});
};
}
On pressing the enter-key, it will call the function you supply as attribute value. Of course you could also use a button with ng-click instead of this directive, but I think it simply looks better.
Last, but not least, you need a Search Controller and a HTML template for your search page, which I won't give to you, as it is up to you what you display here. For the controller, you only need to know how you can access the search string:
angular.module('your.module')
.controller('SearchController', SearchController);
SearchController.$inject = ['$scope', '$stateParams'];
function SearchController($scope, $stateParams) {
$scope.searchString = $stateParams.searchString;
/* DO THE SEARCH LOGIC, e.g. database lookup */
}
Hope this helps to find the proper way. :)

How do I get the Back Button to work with an AngularJS ui-router state machine?

I have implemented an angularjs single page application using ui-router.
Originally I identified each state using a distinct url however this made for unfriendly, GUID packed urls.
So I have now defined my site as a much simpler state-machine. The states are not identified by urls but are simply transitioned to as required, like this:
Define Nested States
angular
.module 'app', ['ui.router']
.config ($stateProvider) ->
$stateProvider
.state 'main',
templateUrl: 'main.html'
controller: 'mainCtrl'
params: ['locationId']
.state 'folder',
templateUrl: 'folder.html'
parent: 'main'
controller: 'folderCtrl'
resolve:
folder:(apiService) -> apiService.get '#base/folder/#locationId'
Transition to a Defined State
#The ui-sref attrib transitions to the 'folder' state
a(ui-sref="folder({locationId:'{{folder.Id}}'})")
| {{ folder.Name }}
This system works very well and I love its clean syntax. However, as I am not using urls the back button does not work.
How do I keep my neat ui-router state-machine but enable the back button functionality?
Note
The answers that suggest using variations of $window.history.back() have all missed a crucial part of the question: How to restore the application's state to the correct state-location as the history jumps (back/forward/refresh). With that in mind; please, read on.
Yes, it is possible to have the browser back/forward (history) and refresh whilst running a pure ui-router state-machine but it takes a bit of doing.
You need several components:
Unique URLs. The browser only enables the back/forward buttons when you change urls, so you must generate a unique url per visited state. These urls need not contain any state information though.
A Session Service. Each generated url is correlated to a particular state so you need a way to store your url-state pairs so that you can retrieve the state information after your angular app has been restarted by back / forward or refresh clicks.
A State History. A simple dictionary of ui-router states keyed by unique url. If you can rely on HTML5 then you can use the HTML5 History API, but if, like me, you can't then you can implement it yourself in a few lines of code (see below).
A Location Service. Finally, you need to be able manage both ui-router state changes, triggered internally by your code, and normal browser url changes typically triggered by the user clicking browser buttons or typing stuff into the browser bar. This can all get a bit tricky because it is easy to get confused about what triggered what.
Here is my implementation of each of these requirements. I have bundled everything up into three services:
The Session Service
class SessionService
setStorage:(key, value) ->
json = if value is undefined then null else JSON.stringify value
sessionStorage.setItem key, json
getStorage:(key)->
JSON.parse sessionStorage.getItem key
clear: ->
#setStorage(key, null) for key of sessionStorage
stateHistory:(value=null) ->
#accessor 'stateHistory', value
# other properties goes here
accessor:(name, value)->
return #getStorage name unless value?
#setStorage name, value
angular
.module 'app.Services'
.service 'sessionService', SessionService
This is a wrapper for the javascript sessionStorage object. I have cut it down for clarity here. For a full explanation of this please see: How do I handle page refreshing with an AngularJS Single Page Application
The State History Service
class StateHistoryService
#$inject:['sessionService']
constructor:(#sessionService) ->
set:(key, state)->
history = #sessionService.stateHistory() ? {}
history[key] = state
#sessionService.stateHistory history
get:(key)->
#sessionService.stateHistory()?[key]
angular
.module 'app.Services'
.service 'stateHistoryService', StateHistoryService
The StateHistoryService looks after the storage and retrieval of historical states keyed by generated, unique urls. It is really just a convenience wrapper for a dictionary style object.
The State Location Service
class StateLocationService
preventCall:[]
#$inject:['$location','$state', 'stateHistoryService']
constructor:(#location, #state, #stateHistoryService) ->
locationChange: ->
return if #preventCall.pop('locationChange')?
entry = #stateHistoryService.get #location.url()
return unless entry?
#preventCall.push 'stateChange'
#state.go entry.name, entry.params, {location:false}
stateChange: ->
return if #preventCall.pop('stateChange')?
entry = {name: #state.current.name, params: #state.params}
#generate your site specific, unique url here
url = "/#{#state.params.subscriptionUrl}/#{Math.guid().substr(0,8)}"
#stateHistoryService.set url, entry
#preventCall.push 'locationChange'
#location.url url
angular
.module 'app.Services'
.service 'stateLocationService', StateLocationService
The StateLocationService handles two events:
locationChange. This is called when the browsers location is changed, typically when the back/forward/refresh button is pressed or when the app first starts or when the user types in a url. If a state for the current location.url exists in the StateHistoryService then it is used to restore the state via ui-router's $state.go.
stateChange. This is called when you move state internally. The current state's name and params are stored in the StateHistoryService keyed by a generated url. This generated url can be anything you want, it may or may not identify the state in a human readable way. In my case I am using a state param plus a randomly generated sequence of digits derived from a guid (see foot for the guid generator snippet). The generated url is displayed in the browser bar and, crucially, added to the browser's internal history stack using #location.url url. Its adding the url to the browser's history stack that enables the forward / back buttons.
The big problem with this technique is that calling #location.url url in the stateChange method will trigger the $locationChangeSuccess event and so call the locationChange method. Equally calling the #state.go from locationChange will trigger the $stateChangeSuccess event and so the stateChange method. This gets very confusing and messes up the browser history no end.
The solution is very simple. You can see the preventCall array being used as a stack (pop and push). Each time one of the methods is called it prevents the other method being called one-time-only. This technique does not interfere with the correct triggering of the $ events and keeps everything straight.
Now all we need to do is call the HistoryService methods at the appropriate time in the state transition life-cycle. This is done in the AngularJS Apps .run method, like this:
Angular app.run
angular
.module 'app', ['ui.router']
.run ($rootScope, stateLocationService) ->
$rootScope.$on '$stateChangeSuccess', (event, toState, toParams) ->
stateLocationService.stateChange()
$rootScope.$on '$locationChangeSuccess', ->
stateLocationService.locationChange()
Generate a Guid
Math.guid = ->
s4 = -> Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
"#{s4()}#{s4()}-#{s4()}-#{s4()}-#{s4()}-#{s4()}#{s4()}#{s4()}"
With all this in place, the forward / back buttons and the refresh button all work as expected.
app.run(['$window', '$rootScope',
function ($window , $rootScope) {
$rootScope.goBack = function(){
$window.history.back();
}
}]);
Back
After testing different proposals, I found that the easiest way is often the best.
If you use angular ui-router and that you need a button to go back best is this:
<button onclick="history.back()">Back</button>
or
<a onclick="history.back()>Back</a>
// Warning don't set the href or the path will be broken.
Explanation:
Suppose a standard management application.
Search object -> View object -> Edit object
Using the angular solutions
From this state :
Search -> View -> Edit
To :
Search -> View
Well that's what we wanted except if now you click the browser back button you'll be there again :
Search -> View -> Edit
And that is not logical
However using the simple solution
<a onclick="history.back()"> Back </a>
from :
Search -> View -> Edit
after click on button :
Search -> View
after click on browser back button :
Search
Consistency is respected. :-)
If you are looking for the simplest "back" button, then you could set up a directive like so:
.directive('back', function factory($window) {
return {
restrict : 'E',
replace : true,
transclude : true,
templateUrl: 'wherever your template is located',
link: function (scope, element, attrs) {
scope.navBack = function() {
$window.history.back();
};
}
};
});
Keep in mind this is a fairly unintelligent "back" button because it is using the browser's history. If you include it on your landing page, it will send a user back to any url they came from prior to landing on yours.
browser's back/forward button solution
I encountered the same problem and I solved it using the popstate event from the $window object and ui-router's $state object. A popstate event is dispatched to the window every time the active history entry changes.
The $stateChangeSuccess and $locationChangeSuccess events are not triggered on browser's button click even though the address bar indicates the new location.
So, assuming you've navigated from states main to folder to main again, when you hit back on the browser, you should be back to the folder route. The path is updated but the view is not and still displays whatever you have on main. try this:
angular
.module 'app', ['ui.router']
.run($state, $window) {
$window.onpopstate = function(event) {
var stateName = $state.current.name,
pathname = $window.location.pathname.split('/')[1],
routeParams = {}; // i.e.- $state.params
console.log($state.current.name, pathname); // 'main', 'folder'
if ($state.current.name.indexOf(pathname) === -1) {
// Optionally set option.notify to false if you don't want
// to retrigger another $stateChangeStart event
$state.go(
$state.current.name,
routeParams,
{reload:true, notify: false}
);
}
};
}
back/forward buttons should work smoothly after that.
note: check browser compatibility for window.onpopstate() to be sure
Can be solved using a simple directive "go-back-history", this one is also closing window in case of no previous history.
Directive usage
<a data-go-back-history>Previous State</a>
Angular directive declaration
.directive('goBackHistory', ['$window', function ($window) {
return {
restrict: 'A',
link: function (scope, elm, attrs) {
elm.on('click', function ($event) {
$event.stopPropagation();
if ($window.history.length) {
$window.history.back();
} else {
$window.close();
}
});
}
};
}])
Note: Working using ui-router or not.
The Back button wasn't working for me as well, but I figured out that the problem was that I had html content inside my main page, in the ui-view element.
i.e.
<div ui-view>
<h1> Hey Kids! </h1>
<!-- More content -->
</div>
So I moved the content into a new .html file, and marked it as a template in the .js file with the routes.
i.e.
.state("parent.mystuff", {
url: "/mystuff",
controller: 'myStuffCtrl',
templateUrl: "myStuff.html"
})
history.back() and switch to previous state often give effect not that you want. For example, if you have form with tabs and each tab has own state, this just switched previous tab selected, not return from form. In case nested states, you usually need so think about witch of parent states you want to rollback.
This directive solves problem
angular.module('app', ['ui-router-back'])
<span ui-back='defaultState'> Go back </span>
It returns to state, that was active before button has displayed. Optional defaultState is state name that used when no previous state in memory. Also it restores scroll position
Code
class UiBackData {
fromStateName: string;
fromParams: any;
fromStateScroll: number;
}
interface IRootScope1 extends ng.IScope {
uiBackData: UiBackData;
}
class UiBackDirective implements ng.IDirective {
uiBackDataSave: UiBackData;
constructor(private $state: angular.ui.IStateService,
private $rootScope: IRootScope1,
private $timeout: ng.ITimeoutService) {
}
link: ng.IDirectiveLinkFn = (scope, element, attrs) => {
this.uiBackDataSave = angular.copy(this.$rootScope.uiBackData);
function parseStateRef(ref, current) {
var preparsed = ref.match(/^\s*({[^}]*})\s*$/), parsed;
if (preparsed) ref = current + '(' + preparsed[1] + ')';
parsed = ref.replace(/\n/g, " ").match(/^([^(]+?)\s*(\((.*)\))?$/);
if (!parsed || parsed.length !== 4)
throw new Error("Invalid state ref '" + ref + "'");
let paramExpr = parsed[3] || null;
let copy = angular.copy(scope.$eval(paramExpr));
return { state: parsed[1], paramExpr: copy };
}
element.on('click', (e) => {
e.preventDefault();
if (this.uiBackDataSave.fromStateName)
this.$state.go(this.uiBackDataSave.fromStateName, this.uiBackDataSave.fromParams)
.then(state => {
// Override ui-router autoscroll
this.$timeout(() => {
$(window).scrollTop(this.uiBackDataSave.fromStateScroll);
}, 500, false);
});
else {
var r = parseStateRef((<any>attrs).uiBack, this.$state.current);
this.$state.go(r.state, r.paramExpr);
}
});
};
public static factory(): ng.IDirectiveFactory {
const directive = ($state, $rootScope, $timeout) =>
new UiBackDirective($state, $rootScope, $timeout);
directive.$inject = ['$state', '$rootScope', '$timeout'];
return directive;
}
}
angular.module('ui-router-back')
.directive('uiBack', UiBackDirective.factory())
.run(['$rootScope',
($rootScope: IRootScope1) => {
$rootScope.$on('$stateChangeSuccess',
(event, toState, toParams, fromState, fromParams) => {
if ($rootScope.uiBackData == null)
$rootScope.uiBackData = new UiBackData();
$rootScope.uiBackData.fromStateName = fromState.name;
$rootScope.uiBackData.fromStateScroll = $(window).scrollTop();
$rootScope.uiBackData.fromParams = fromParams;
});
}]);

Pass URL to as $routeParam in AngularJS app

How can I pass actual URL (with slashes, commas, etc.) as a $routeParam to AngularJS App?
this will work:
http://paprikka.github.io/le-bat/#/preview/asdadasda
this won't:
http://paprikka.github.io/le-bat/#/preview/http://page.com
neither will this:
http://paprikka.github.io/le-bat/#/preview/http%3A%2F%2Fpage.com
or this:
http://paprikka.github.io/le-bat/#/preview/?url=http%3A%2F%2Fpage.com
Details
AngularJS routing mechanism by its design does not allow to pass strings with slashes as query parameters. I can understand the reasoning behind this decision - we don't want to create a stateless server here.
However, there are still cases when using different separators or regular expressions in routes might be necessary.
I wanted to create an app that takes a url hash string parameter and loads its content to an iframe (link here). Routes are set up in pretty standard way (I'm using Coffeescript, but this snippet does not differ from pure js):
$routeProvider
.when('/preview/:src', {templateUrl: 'partials/preview.html',
controller: 'PreviewCtrl'})
.when('/preview', {templateUrl: 'partials/preview.html',
controller: 'PreviewCtrl'})
Of course, I can load url from hash before AngularJS gets bootstrapped and then pass it to the library, but it would be nice if I could also update current route parameter when changing data in scope - that's why I think it's much better not to avoid AngularJS API.
Using $routeProvider in Angular 1.2, you can pass in a url if it's at the end of the path by adding an asterik to the pattern. The following should work whether or not you URLComponentEncode the url.
The route:
angular.module('angularApp', ['ngRoute'])
.when('/frame/:picture_url*', {
templateUrl: 'views/frame.html',
controller: 'PictureFrame'
});
The controller:
.controller('PictureFrame', function($scope, $routeParams, $sce){
//whitelist the URL
$scope.picture_url = $sce.trustAsResourceUrl($routeParams.picture_url);
});
Then in your template:
<iframe ng-src="{{picture_url}}"></iframe>
Ok, I've managed to find a solution working with current stable version (#1.0.7).
Current way of handling this problem will involve $route-related events, parsing angular-incompatible urls on the fly and handling them via an additional service working in a similar way as $http interception.
You can see working code examples here: http://embed.plnkr.co/fIA2xj/preview
Main steps
pass an angular-incompatible url as usual, eg. go to site.com/url/http://site.com
listen to a $routeChangeStart event and extract correct url parameter for paths beginning with /url/
encode the correct url parameter to an angular-compatible form (in this particular case, I use base64). Don't use encodeURIComponent, because angular will treat as any other url
redirect to another route with your business logic, eg. site.com/parsed-url/BASE64_GOES_HERE
decode the URL in the controller and use it as usual :)
Code
Create angular app module as usual
angular.module('routes',[]).config([
'$routeProvider',
function($routeProvider){
$routeProvider
.when('/test', {templateUrl: 'test.html'})
// This one is important:
// We define a route that will be used internally and handle
// parameters with urls parsed by us via the URLInterceptor service
.when('/parsed-url/:url', {templateUrl: 'url.html', controller:'URLCtrl'})
.when('/', {redirectTo: '/test'})
.otherwise({templateUrl: '404.html'});
}
])
URL Interceptor service (singleton)
.service('URLInterceptor', function($rootScope, $location){
// We listen to $routeChangeStart event and intercept it if
// the path matches our url scheme. In this case, every route
// beginning with /url/ will be caught
$rootScope.$on('$routeChangeStart', function(e, next, current){
// $location.path does change BEFORE actual routing happens,
// so in this case we get parsed new location object
// for free.
// To be hones, a better way of handling this case might be using
// $locationChangeStart event instead, but it would require us to parse urls
// manually.
var path = $location.path();
// check if string begins with '/url/'
var matcher = path.slice(0,5);
var cleanPath = '';
if (matcher === '/url/'){
// Yes it does, yay!
// Remove leading '/url/' to extract the actual parameter
cleanPath = path.slice(5);
// Encode our url to a safe version. We know that encodeURIComponent won't
// work either, so a good choice might be base64.
// I'm using https://code.google.com/p/javascriptbase64/downloads
$location.path('/parsed-url/' + Base64.encode(cleanPath));
// Prevent default event execution. Note that, it won't cancel related $location Events
e.preventDefault();
}
});
return {
decode: Base64.decode,
encode: Base64.encode
}
})
Controllers
// Main application controller
// We instantiate our URLInterceptor service here
.controller('AppCtrl',function($scope, $location, URLInterceptor){
$scope.navigateTo = function (path) {
$location.path('/url/' + path);
}
})
.controller('URLCtrl', function($scope, $routeParams, URLInterceptor){
$scope.url = URLInterceptor.decode($routeParams.url);
});
Two things you should remember:
Although I tried to create a solution as clean as possible, usually passing the data this way to angular isn't considered a good practice, so try not to use it unless you really need to.
You can handle this issue with only one route. I just find it cleaner this way.
I have a solution but I don't know if it will help you. From Angular documention http://docs.angularjs.org/api/ng.$location $location has a function search(search, paramValue)
To pass the parameter:
parameter = encodeURIComponent url
$location.search({ yourURLParameter: parameter }).path('/preview')
To read the parameter:
url = decodeURIComponent $location.search().yourURLParameter
Of course you need to inject $location dependency
I have mixed search params with routes. Your search needs to come before your routes.. specifically for older browsers. I think ie7 blows up if its not url/?search/#/hash
Try this format:
domain.com/?my=params&another=param/#/my/hashes

AngularJS Restful Routing

I'm trying to structure my app using the Restful/Ruby convension /<resource>/[method]/[id]. How I've done it previously when using a server-side MVC framework like CodeIgniter was to dynamically route based on the URI:
ex.
www.foo.com/bar/baz/1
The app would then use method baz in controller/class bar and return views/bar/baz.php (populated with data from bar->baz)
I would like to do the same in Angular, but I'm not sure if it supports this (and if it does, I'm not sure exactly how to go about it). At the moment I'm using $routeProvider's when method to specify each case. $location.path() looks like it might have what I need, but I don't think I can use it in app.js (within config()).
What I'd like to do is something like this:
.config([
'$routeProvider', function($routeProvider) {
$routeProvider
.when(//<resource> controller exists
resource+'/'+method, {
"templateURL": "views/" + resource + "/" + method + ".html",
"controller": resource
}
).otherwise({ "redirectTo":"/error" });
}
]);
And the router automatically calls the appropriate method.
EDIT Also, why does $routeProvider freak out when I specify when('/foo/bar', {…}) ?
EDIT 2 Per Lee's suggestion, I'm looking into doing something like this:
$routeProvider
.when(
'/:resource/:method/:id', {
"templateUrl": function(routeParams){
var path = 'views/'+routeParams.resource+'/';
return ( typeof routeParams.method === 'undefined' ) ?
path+'index.html' : path+routeParams.method+'.html';
},
"controller": RESOURCE
})
.otherwise({redirectTo: '/error'});
I noticed the following in $routeProvider's doc:
templateUrl – {string=|function()=} – path or function that returns a
path to an html template that should be used by ngView.
If templateUrl is a function, it will be called with the following
parameters:
• {Array.<Object>} - route parameters extracted from the current
$location.path() by applying the current route
Edit: The option to set templateUrl to a function is part of the unstable 1.1.2 build: #1963 (but it doesn't work as of 2013-02-07).
There is a dicussion about adding this functionality on AngularJS's Github: #1193 #1524, but I can't tell if it was actually implemented (in the docs from Dash quoted above, it looks like it has been, and the docs on the site haven't been updated yet).
EDIT 3 To clarify what I want to happen (per lee's request), in simplest terms, I would like to go to www.foo.com/index.html#/people
Angular should use controller people, automatically call its index method, and should serve up
./views/people/index.html
./views/people/map.html
Also, if I go to www.foo.com/index.html#/people/map
Angular should use the people controller again, but this time automcatically call its map method and serve up …map.html (because map was specified in the url)
./views/people/index.html
./views/people/map.html
Then, if I go to
www.foo.com/index.html#/widgets
Angular should serve up
./views/widgets/index.html
./views/widgets/details.html
The code for the router should be very generic—I shouldn't have to specify a .when() for every route.
Thinking about this a little more. You could just have a single controller for those generic CRUD/REST type operations. Then load the templates using the resource and view parameters.
Create
#/foo/create/0
This has it's own form template "/views/foo/create.html" and the 0 os just there for a placeholder.
on submit you would call a method on the controller ng-click="save()" which would post to the server at POST "/rest/foo".
Read
#/foo/view/1
Again the template "/views/foo/view.html" is just a view of the data
You can call a service method to get the data from your server using GET "/rest/foo/1"
Update
-#/foo/edit/1
Could use the same template as create or you could use a different one "/views/foo/edit.html" if you like.
Also pull the data using GET "/rest/foo/1"
Submit the data using PUT "/rest/foo/1"
Delete
#/foo/delete/1
service method would call DELETE "/rest/foo/1"
I don't think you want a hash for this, but you could use one because the controller could actually do a verification or anything you like to confirm the deletion. Maybe have a view called "/views/foo/delete.html" that asks if you want to delete the record. Then you could have ng-click="delete(itemid)" on a button somewhere that deletes the item via ajax.
All this could be done using a single controller/service and dynamically generating the service and view urls.
Anything that's custom you would need a custom controller and custom routes and service methods for. I could probably throw together an example, but not tonight.
Here is a project on github that does something close to what you are asking
EDIT:
I discovered something interesting that had not occurred to me before. If you leave out the controller in the route it will use the controller specified in the template. So as long as all the templates that you use for a given controller have ng-controller="resource" then it will load that controller for the template as expected. Of course with the current implementation of routes there are no optional parameters, so if you have two or three parameters you would need to specify a separate route. Biggest problem is it appears to call the controller method twice. I am guessing this is because there are two views with the same controller. However one view should replace the other so there should not be two calls. This seems like a bug to me. I also found some discussion of a possible new routing system in the works that may meet your needs, but it may be pretty far off: https://github.com/angular-ui/router/issues?page=1&state=open. The sample on github is now using the following method so you can browse that if you like.
var restrouteApp = angular.module('restrouteApp', [])
.config(['$routeProvider', function($routeProvider) {
$routeProvider
.when('/:ctrl/:method', {
templateUrl: function(rp){
if(!rp.method) {rp.method = 'index';}
console.log('route one');
return 'views/'+rp.ctrl+'/'+rp.method+'.html';
}
})
.when('/:ctrl/:method/:id', {
templateUrl: function(rp){
if(!rp.method) {rp.method = 'index';}
console.log('route two');
return 'views/'+rp.ctrl+'/'+rp.method+'.html';
}
})
.otherwise({
redirectTo: '/resource1/'
});
}]);
And the templates:
<div ng-controller="resource1">
<h1> resource1/one.html </h1>
<div>{{r1data.selected}}</div>
</div>
Now in your controller you can do this to call the method dynamically.
restrouteApp.controller('resource1', function($scope,$routeParams,$log,Resource1Service) {
$log.info('new resource1');
$scope.controllername = $routeParams.ctrl;
$scope.r1data= Resource1Service.shared;
$scope.index = function(){
Resource1Service.index().then(function(){
//when the service returns
});
}
$scope.one = function(){
$scope.r1data.selected = $scope.r1data.resources[0];
}
$scope.two= function(){
$scope.r1data.selected = $scope.r1data.resources[1];
}
//call the specified method of this controller
$scope[$routeParams.method]();
});
/EDIT
To conform to existing routing systems like Rails, the ability to define the method in the route is now available.
I created a super simple solution that allows routes to call a method based on the route definition and a directive in the view. I think ui-router is not conventional and is too complicated for a such a "should be" core feature.
The project is called ngMethod and is located at: https://github.com/jzumbrun/ng-method.
An example of its use is: https://github.com/jzumbrun/chrome-apps-angularjs-bootstrap
So if I have a route like so:
$routeProvider.
when('/contacts/new', {
controller: 'ContactsController',
method: 'new',
templateUrl: $configProvider.template('contacts/form.html'),
});
$routeProvider.
when('/contacts/:id/edit', {
controller: 'ContactsController',
method: 'edit',
templateUrl: $configProvider.template('contacts/form.html'),
});
and I have ng-method in the contacts/form template:
<div class="col-lg-12" ng-method>
<form role="form">
...
Then the ng-method will call either $scope.edit() or $scope.new() in the ContactsController.
Than the contacts/form template can be shared, and depending on the route call the correct method
to load the data. This style is now more "Angularjs" and the loading the code is much like angular calling to modules and controllers.
The full directive that makes this happen is less than 20 lines of code:
app.directive('ngMethod', ['$route', function($route) {
return {
// Restrict it to be an attribute in this case
restrict: 'A',
// responsible for registering DOM listeners as well as updating the DOM
link: function(scope, element, attrs) {
// Call method without params. Use $routeParams
if(angular.isFunction(scope[attrs.ngMethod])){
scope[attrs.ngMethod]();
// default to the route method if attrs.ngMethod is empty
} else if(angular.isObject($route.current)
&& angular.isString($route.current['method'])
&& angular.isFunction(scope[$route.current['method']])){
scope[$route.current['method']]();
}
}
};
}]);
This is now possible with ui-router 0.2.8:
$stateProvider
.state('base', {
url: '/:resource/:collection/:id',
controllerProvider: function( $stateParams )
{ // assuming app.controller('FooCtrl',[…])
return $stateParams.collection + 'Ctrl';
},
templateUrl: function( $stateParams )
{
return '/partials/' + $stateParams.collection + '.html';
}
});
But in order to take advantage of $state.includes() on nav menus, this would probably be better:
$stateProvider
.state('base.RESOURCE_NAME1', {
url: '/:collection/:id',
controllerProvider: function( $stateParams )
{ // assuming the convention FooCtrl
return $stateParams.collection + 'Ctrl';
},
templateUrl: function( $stateParams )
{
return '/partials/' + $stateParams.collection + '.html';
}
}).state('base.RESOURCE_NAME2', {
url: '/:collection/:id',
controllerProvider: function( $stateParams )
{ // assuming the convention FooCtrl
return $stateParams.collection + 'Ctrl';
},
templateUrl: function( $stateParams )
{
return '/partials/' + $stateParams.collection + '.html';
}
});
The above could be simplified with a loop to build the states from an array of resources ($stateProvider supports adding states basically whenever):
var resources = [ 'r1', 'r2', '…' ];
for ( var r = resources.length-1; r >=0; r-- )
{
var name = resources[r];
$stateProvider.state('base.'+name, {
…
});
}
Caveat ui-router doesn't not really support optional state parameters (planned for v0.4)

Categories

Resources