I have a Durandal application, and I use router.mapUnknownRoutes to display a user-friendly error page if the URL does not correspond to a known route. This works fine -- if I go to, say /foo, and that doesn't match a route, then the module specified by mapUnknownRoutes is correctly displayed.
However I cannot find any way to display that same error page when I have a parameterised route, and the parameter does not match anything on the backend.
For example, say I have a route like people/:slug where the corresponding module's activate method looks like this:
this.activate = function (slug) {
dataService.load(slug).then(function () {
// ... set observables used by view ...
});
};
If I go to, say /people/foo, then the result depends on whether dataService.load('foo') returns data or an error:
If foo exists on the backend then no problem - the observables are set and the composition continues.
If foo doesn't exist, then the error is thrown (because there is no catch). This results in an unhandled error which causes the navigation to be cancelled and the router to stop working.
I know that I can return false from canActivate and the navigation will be cancelled in a cleaner way without borking the router. However this isn't what I want; I want an invalid URL to tell the user that something went wrong.
I know that I can return { redirect: 'not-found' } or something similar from canActivate. However this is terrible because it breaks the back button -- after the redirect happens, if the user presses back they go back to /people/foo which causes another error and therefore another redirect back to not-found.
I've tried a few different approaches, mostly involving adding a catch call to the promise definition:
this.activate = function (slug) {
dataService.load(slug).then(function () {
// ... set observables used by view ...
}).catch(function (err) {
// ... do something to indicate the error ...
});
};
Can the activate (or canActivate) notify the router that the route is in fact invalid, just as though it never matched in the first place?
Can the activate (or canActivate) issue a rewrite (as opposed to a redirect) so that the router will display a different module without changing the URL?
Can I directly compose some other module in place of the current module (and cancel the current module's composition)?
I've also tried an empty catch block, which swallows the error (and I can add a toast here to notify the user, which is better than nothing). However this causes a lot of binding errors because the observables expected by the view are never set. Potentially I can wrap the whole view in an if binding to prevent the errors, but this results in a blank page rather than an error message; or I have to put the error message into every single view that might fail to retrieve its data. Either way this is view pollution and not DRY because I should write the "not found" error message only once.
I just want an invalid URL (specifically a URL that matches a route but contains an invalid parameter value) to display a page that says "page not found". Surely this is something that other people want as well? Is there any way to achieve this?
I think you should be able to use the following from the activate or canActivate method.
router.navigate('not-found', {replace: true});
It turns out that Nathan's answer, while not quite right, has put me on the right track. What I have done seems a bit hacky but it does work.
There are two options that can be passed to router.navigate() - replace and trigger. Passing replace (which defaults to false) toggles between the history plugin using pushState and replaceState (or simulating the same using hash change events). Passing trigger (which defaults to true) toggles between actually loading the view (and changing the URL) vs only changing the URL in the address bar. This looks like what I want, only the wrong way around - I want to load a different view without changing the URL.
(There is some information about this in the docs, but it is not very thorough: http://durandaljs.com/documentation/Using-The-Router.html)
My solution is to navigate to the not-found module and activate it, then navigate back to the original URL without triggering activation.
So in my module that does the database lookup, in its activate, if the record is not found I call:
router.navigate('not-found?realUrl=' + document.location.pathname + document.location.hash, { replace: true, trigger: true });
(I realise the trigger: true is redundant but it makes it explicit).
Then in the not-found module, it has an activate that looks like:
if (params.realUrl) {
router.navigate(params.realUrl, { replace: true, trigger: false });
}
What the user sees is, it redirects to not-found?realUrl=people/joe and then immediately the URL changes back to people/joe while the not-found module is still displayed. Because these are both replace style navigations, if the user navigates back, they go to the previous entry, which is the page they came from before clicking the broken link (i.e. what the back button is supposed to do).
Like I said, this seems hacky and I don't like the URL flicker, but it seems like the best I can do, and most people won't notice the address bar.
Working repo that demonstrates this solution
Related
I have a component listing-table which takes a number of properties, like this:
{{listing-table model=model.devices type='user' exclude='customerName'}}
This works as intended, and the integration tests also work just fine. However, my acceptance tests fail, because apparently my exclude property is not being taken into account while running an acceptance test.
I have tested this by printing to console the value of this.get('exclude') in the component's javascript file and getting undefined. However, printing e.g. this.get('type') yields the expected results.
I have then, for testing purposes, removed exclude and replaced type's value with it, i.e. type='endpointName,typeName', however, I would get the previous value in the console, e.g. user.
This is all way beyond puzzling, and I'd really like to know what's the matter with acceptance test. Any sort of hints are more than welcome, and thanks for your time!
EDIT:
I have now edited my acceptance test to exclude clicking through various elements to get to the route that contains my listing-table component:
From:
visit('/users/1')
click('a:contains("Devices")')
To:
visit('/users/1/devices')
And the test passes. I still don't understand why clicking through makes my component's properties disappear, whereas visiting the page directly works just fine.
EDIT 2:
So, here is some sample code. This is what my test looks like:
test('/customers/1/devices should display 5 devices', function (assert) {
let type = server.create('endpoint-type')
let user = server.create('user')
let endpoint = server.create('endpoint', { type })
server.createList('device', 5, { user })
visit('/customers');
click('a:contains("Customer 0")')
click('a:contains("Devices")')
andThen(function () {
assert.equal(find('.device-listing').length, 5, 'should see 5 listings')
assert.equal(find('th').text().trim(), 'IDModelManufacturerMACExtensionLocation', 'should only contain ID, Model, Manufacturer, MAC, Extension, and Location columns')
})
Now, my Devices table should, in this case, omit the 'Customer' column, however, the column does appear in there, even though my component in devices.show.customers has been invoked with:
{{listing-table model=model.devices type='user' exclude='customerName'}}
My listing-table.js file basically uses this.get('exclude') inside the init () function to process the excludes, but as I said, if I add a console.log(this.get('exclude') in that file, I get undefined.
EDIT 3:
After more testing, I have made some progress, and the resulting question needs its own page, here.
Just a few thoughts:
I assume this one has been done since you got green on your second attempt... are you using andThen to handle your assertions to make sure all of your async events are settled?
Is the model hook being triggered? Depending on how you enter the route, the model hook will sometimes not get triggred: Why isn't my ember.js route model being called?
Might be helpful to have some code to look at.
Here's my scenario:
I have a layout template that needs to check to see if a User belongs to at least one Team. If not, then display a div across the entire site. A user can only see the teams they belong to, so I created a simple publication that works: (code samples are CoffeeScript)
Meteor.publish 'teams', ->
return null if !#userId
Teams.find {'members._id': #userId}
This works great and Teams.find().fetch() in console gives expected results.
However, if I put this code in, say, the Template.layout.rendered, it doesn't work.
Template.layout.rendered = ->
teams = Teams.find().fetch()
hasTeams = teams.length > 0
if !hasTeams
...do stuff..
Obviously this doesn't work because the Teams find is async and not loaded when it needs to make the decision. With a normal template / page I would just use the IronRouter waitOn() but what do I do on the layout?
I could do a waitOn in my router, but since the data is "global" and going to be used everywhere, and because a user could deep link into the site all over the place, I don't want to add that waitOn to EVERY single route.
So what is the proper pattern? How do I get the meteor client to load global data and wait for the data before running the route?
More thinking and searching found the answer right here on SO: struggling to wait on subscriptions and multiple subscriptions
I changed my Router.configure to this:
Router.configure
layoutTemplate: 'layout'
waitOn: ->
return [
Meteor.subscribe('teams')
]
Multiple subscriptions can be added to the return array, and I believe it will wait on all of them.
I had a similar issue with iron router subscribing to a chat in two different publications with waitOn.
Meteor.subscribe('chats')
Only when I switched positions of the following publish blocks, would it let me see any data on the route. The following is the correct order for the two.
Meteor.publish("chats", function () {
return Chats.find();
});
Meteor.publish("chats", function(id) {
return Chats.find({eventRoom: id});
});
i am following Discover meteor book and in one of the chapter author taught me to use Meteor.logginIn() method.
The goal is Simple, there is a submit page for new post and if there are no users logged in, it should display access denied template, else it should display post submit template. But if the user is loggin in or in wait state it should display loading template. I followed the tutorial and did what was told, the code looks exactly the same as the book. But, when user is logged in it rightfully display the submit page and when user is logged out it should display the access denied page but instead shows the loading template, while showing loading template if i log in, then it also display the submit page. The only trouble is while it should display the access denied, it is showing loading template.
Here is the routing code
Router.route('/submit', {name: 'postSubmit'});
var requireLogin = function(){
if(!Meteor.user()){
if(Meteor.logginIn()){
this.render(this.loadingTemplate);
}else{
this.render('accessDenied');
}
}else {
this.next();
}
}
Router.onBeforeAction('dataNotFound', {only:'postPage'});
Router.onBeforeAction(requireLogin, {only:'postSubmit'});
Eh, that code could use a little help. Like for this instance, we are going to separate concerns and call them in different hooks
First, keep in mind that every time you call onBeforeAction it adds that function to an array of and called sequentially. Although it's always a good practice not to actually depend on the sequential order, but it's great to keep in mind.
var userLoggingIn = function() {
if(Meteor.loggingIn()) {
this.render(this.loadingTemplate);
} else {
this.next(); // Done logging in? You may pass.
}
}
var requireLogin = function() {
if(!Meteor.user()) {
this.render('accessDenied');
} else {
this.next(); // User is logged in. I'll allow it.
}
}
// finally, use it.
Router.onBeforeAction([userLoggingIn, requireLogin], {only: 'postSubmit'});
Router.onBeforeAction('loading'); // built-in hook, just like dataNotFound. use in Routes with subscriptions.
Router.onBeforeAction('dataNotFound', {only:'postPage'});
Isn't that way better, and manageable? I'm not sure which order it's going to execute as I'm travelling and just typing this out at the airport.. but you can try swapping the hooks order around if it's not behaving accordingly.
This is a simple question, but I am new to routing and haven't been able to find an answer to this.
I have a Marionette Router (if I intimidate you, its really the same thing as a Backbone Router. Very simple).
sys.routes = {
"app/:id": "onAppRoute",
};
sys.Router = new Marionette.AppRouter({
controller: {
onAppRoute: function(route) {
console.log('Called app route!');
},
},
appRoutes: sys.routes,
});
Backbone.history.start({pushState: true})
This works - if you hit the back button my browser, the url will change within my Single Page Application and will call the onAppRoute function.
However, let's say I open a new browser window and paste in my page url to a certain 'app':
http://localhost/app/myApplication
This doesn't call the onAppRoute function. It doesn't even seem like it should, though, but I really don't know.
I want it to.
I don't know if I am doing it wrong, or if I should just manually fire it by grabbing my page url on page load, parsing it, then 'navigating' to that route. Seems hacky.
Contrary to your intuition, backbone's default behaviour is to trigger matching routes on page load! cf. http://backbonejs.org/#Router - look for the option silent: true. You'd have to specify that for the router to IGNORE your matching routes on page load, i.e. not trigger the corresponding callbacks.
So your problem lies somewhere else: your routes do NOT match the url you have stated as an example. Clearly, you require an :id parameter, trailing http://localhost/app/myApplication. Therefore, http://localhost/app/myApplication/213 would cause your callback to be triggered on page load, given you didn't pass silent: true as an option to backbone.history.start().
If you want to match the 'root' url, i.e. no params, you would define the following route:
routes: {
'/': someFunction
}
The :id part is a parameter, which will be extracted by Backbone.Router and sent as an argument to onAppRoute.
But in your URL you don't have any parameters /localhost/app/myApplication
Using Backbone.js, is it possible to make the router navigate to the page where it came from? I'd like to use that for the case where I change my URL when a popup appears, and I want go change it back, when I hide the popup. I don't want to simply go back, because I want to keep the background page in precisely the same position as I left it before I showed the popup
You can solve this problem by extending Backbone.Router and storing all the routes during navigation.
class MyRouter extends Backbone.Router
constructor: (options) ->
#on "all", #storeRoute
#history = []
super options
storeRoute: ->
#history.push Backbone.history.fragment
previous: ->
if #history.length > 1
#navigate #history[#history.length-2], true
Then, when you have to dismiss your modal, simply call the MyRouter.previous() method that it will redirect you back to the last "hash".
I think mateusmaso's answer is mostly right but requires some tweaks to guarentee that you always get the right URL you are looking for.
First you need to override the route method to have a beforeRoute method fire:
route: (route, name, callback) =>
Backbone.Router.prototype.route.call(this, route, name, =>
#trigger('beforeRoute')
callback.apply(this, arguments)
)
Then you bind the event and initialize the history instance variable:
initialize: (options) ->
#history = []
#on "beforeRoute", #storeRoute
Next create helper methods for storing and retrieving the fragment:
storeRoute: =>
#history.push Backbone.history.fragment
previousFragment: =>
#history[#history.length-2]
Finally, you need one final helper method that can be used to change the URL without reloading and store the resulting fragment. You need to use this when closing the pop up or you won't have the expected fragment in your history if the user gets the pop up again without navigating anywhere else. This is because calling navigate without "trigger: true" won't trigger the event handler to store the fragment.
changeAndStoreFragment: (fragment) =>
#navigate(fragment)
#storeRoute()
This answer may not address the question, but it's preety much the same issue. I couldn't navigate silently to a previous route, or a custom route because of the trailing slash. In order for route functions to NOT be triggered, use {trigger:false}, or don't use trigger at all since false is the default behaviour, and make sure your route begins with #something instead of '#/something' (notice the slash), or change the regEx inside Backbone.js, the router part.
You can trigger a route in the onCloseEvent of your popup or overlay with:
router.navigate('/lasturl/');
This will set the url. If you pass true as the second param, you also will execute the routes action. Otherwise the page will be left unchanged.
http://documentcloud.github.com/backbone/#Router-navigate