How to use durandal router to activate dialogs? - durandal

I would love to a #signin route that would open a dialog on top of whatever page there was before.
Let's consider this example app this the following routes:
router.map([
{route: '', moduleId: 'vm/home', title: "Home"},
{route: 'about', moduleId: 'vm/about', title: "About"},
{route: 'signin', moduleId: 'vm/signin', title: 'Sign In'}
]);
Here are example use cases:
User is on # and navigates to #signin: we should see a Sign In dialog on top of Home page
User is on #about and navigates to #signin: we should see a Sign In dialog on top of About page
User navigates to http://localhost:9000/#signin: we should see a Sign In dialog on top of Home page
User is on #signin and closes dialog: we should see a page that was behind the dialog (there's always a page behind).

The dialog and router are both plugins and have no interactions between eachother.
Also having the router display dialog would ignore how the router works - it has a div which it dumps content into. Dialogs exist outside of all of this.
However if you wanted to (I may do this aswell), you could try this.
Add dialog: true to the route map.
Override router.loadUrl method. Check if the route is a dialog route as we marked before, and activate the dialog instead.
I would make the dialog a child route, so then you can know which view to display beneath the dialog. Otherwise you could just have to show the dialog over anything and ignore routing entirely.
Edit: I don't think this would entirely work actually. loadUrl returns a boolean. You could open the dialog and return false to cancel navigation.
Edit2:
My Attempt
The loadUrl method loops through all routes, and each has a callback, so ideally we need to insert our logic into this array.
for (var i = 0; i < handlers.length; i++) {
var current = handlers[i];
if (current.routePattern.test(coreFragment)) {
current.callback(coreFragment, queryString);
return true;
}
}
This array is added to using the routers route method. Durandal calls this method when you map routes, so ideally we could add some extra parameters to the route config and let Durandal handle these. However the configureRoute function is internal to the routing module, so we will need to edit that and make sure we copy changes over when updating Durandal in the future.
I created a new list of dialog routes:
{ route: 'taxcode/add(/:params)', moduleId: 'admin/taxcode/add', title: 'Add Tax Code', hash: '#taxcode/add', nav: false, dialog: true, owner: '#taxcodes' },
{ route: 'taxcode/edit/:id', moduleId: 'admin/taxcode/edit', title: 'Edit Tax Code', hash: '#taxcode/edit', nav: false, dialog: true, owner: '#taxcodes' }
The idea of an owner, is that if there is a case where the initial route is this, we need something behind the dialog.
Now replaced the router.route call in configureRoute with this:
router.route(config.routePattern, function (fragment, queryString) {
if (config.dialog) {
if (!router.activeInstruction()) {
// No current instruction, so load one to sit in the background (and go back to)
var loadBackDrop = function (hash) {
var backDropConfig = ko.utils.arrayFirst(router.routes, function (r) {
return r.hash == hash;
});
if (!backDropConfig) {
return false;
}
history.navigate(backDropConfig.hash, { trigger: false, replace: true });
history.navigate(fragment, { trigger: false, replace: false });
queueInstruction({
fragment: backDropConfig.hash,
queryString: "",
config: backDropConfig,
params: [],
queryParams: {}
});
return true;
};
if (typeof config.owner == 'string') {
if (!loadBackDrop(config.owner)) {
delete config.owner;
}
}
if (typeof config.owner != 'string') {
if (!loadBackDrop("")) {
router.navigate("");
return; // failed
}
}
}
var navigatingAway = false;
var subscription = router.activeInstruction.subscribe(function (newValue) {
subscription.dispose();
navigatingAway = true;
system.acquire(config.moduleId).then(function (dialogInstance) {
dialog.close(dialogInstance);
});
})
// Have a route. Go back to it after dialog
var paramInfo = createParams(config.routePattern, fragment, queryString);
paramInfo.params.unshift(config.moduleId);
dialog.show.apply(dialog, paramInfo.params)
.always(function () {
if (!navigatingAway) {
router.navigateBack();
}
});
} else {
var paramInfo = createParams(config.routePattern, fragment, queryString);
queueInstruction({
fragment: fragment,
queryString: queryString,
config: config,
params: paramInfo.params,
queryParams: paramInfo.queryParams
});
}
});
Make sure you import dialog into the module.

Well maybe all of that is not needed when using a trick with the activation data of your home viewmodel.
Take a look at my Github repo I created as an answer.
The idea is that the route accepts an optional activation data, which the activate method of your Home VM may check and accordingly show the desired modal.
The benefit this way is that you don't need to touch the existing Durandal plugins or core code at all.
I'm though not sure if this fully complies with your request since the requirements didn't specify anything detailed.
UPDATE:
Ok I've updated the repo now to work with the additional requirement of generalization. Essentially now we leverage the Pub/Sub mechanism of Durandal inside the shell, or place it wherever else you want. In there we listen for the router nav-complete event. When this happens inspect the instruction set and search for a given keyword. If so then fire the modal. By using the navigation-complete event we ensure additionally that the main VM is properly and fully loaded.
For those hacks where you want to navigate to #signin, just reroute them manually to wherever you want.

Expanding on my suggestion in the comments, maybe something like this would work. Simply configure an event hook on router:route:activating or one of the other similar events and intercept the activation of /#signin. Then use this hook as a way to display the dialog. Note that this example is for illustrative purposes. I am unable to provide a working example while I'm at work. :/ I can complete it when I get home, but at least this gives you an idea.
router.on('router:route:activating').then(function (instance, instruction) {
// TODO: Inspect the instruction for the sign in route, then show the sign in
// dialog and cancel route navigation.
});

Related

How to get SPA navigation working with external framework that uses innerHTML for content

In my Vue.js app, I am using a bootstrap-based framework that generates the html for my header and a menu nav with links, which is then inserted into the page by assigning innerHTML to a mount point.
But when I use the generated content to navigate, the entire page reloads since the links aren't using <router-link>.
One attempt at a fix:
In the Vue app, I assigned a method called goto on the window object that would perform programmatic router navigation.
I was then able to pass javascript:window.goto("myPageName"); as the href attribute, but this comes with many undesirable side-effects.
How can I cleanly make the links navigate without reloading the page?
(The framework needs jQuery as a dependency, so that is able to be used in a solution.)
I was able to use a MutationObserver that watches for subtree changes and adds a custom click handler when it detects the links being added via .innerHTML.
With this method, I specify vue-goto:myPageName as the href attribute, and then the handler will take care of making it an SPA link.
import { router } from "#/router";
import { store } from "#/store";
export const attrib = "vue-goto";
export const prefix = attrib + ":";
function onChange() {
// find all links matching our custom prefix to which we have not yet added our custom handler
const links = window.$(`a[href^='${prefix}']`).not(`[${attrib}]`);
// add custom attribute for us to grab later
links.attr(attrib, function() {
// jQuery doesn't like arrow functions
return window
.$(this)
.attr("href")
.substr(prefix.length)
.trim();
});
// Update href on the link to one that makes sense
links.attr("href", function() {
return router.resolve({
name: window.$(this).attr(attrib), // grab attribute we saved earlier
params: { lang: store.state.language }, // in our case, our pages are qualified by a language parameter
}).href;
});
// Override default click navigation behaviour to use vue-router programmatic navigation
links.click(function(e) {
e.preventDefault(); // prevent default click
const routeName = window.$(this).attr(attrib);
const goto = {
name: routeName,
lang: store.state.language,
};
router.push(goto).catch(ex => {
// add catch here so navigation promise errors aren't lost to the void causing headaches later
// eslint-disable-next-line no-console
console.error(
`Error occurred during navigation from injected [${prefix}${routeName}] link`,
"\n",
ex,
);
});
});
}
let observer;
export function init() {
if (observer) observer.unobserve(document.body);
observer = new MutationObserver(onChange);
observer.observe(document.body, {
characterData: false,
childList: true,
subtree: true, // important, we want to see all changes not just at toplevel
attributes: false,
});
}
init();

How to dynamically build navigation menu from routes linking to parent/child views/controllers

This question is a follow-up question for my original question Linking directly to both parent+child views/controllers from the main navigation menu
The accepted answer is great, but later when I added a 2nd "childRoute" dynamically, I noticed a problem. In order to build my navigation dynamically I had to add the multiple routes with the same "route" attribute. (see app.js in the example code below). The only difference were the "title" and "settings" attributes.
configureRouter(config, router){
config.title = 'Aurelia';
config.map([
{ route: 'shared-parent', moduleId: './shared-parent', settings: { childRoute: 'child-a' }, nav: true, title: 'Shared Parent - child-a' },
{ route: 'shared-parent', moduleId: './shared-parent', settings: { childRoute: 'child-b' }, nav: true, title: 'Shared Parent - child-b' },
...
]);
this.router = router;
}
The settings attribute I used in the view for doing this:
<a if.bind="row.settings.childRoute" href.bind="row.href + '/' + row.settings.childRoute">${row.title}</a>
I know it's not pretty but it does navigate to the right child route. The problem is that it's always the last of the 2 routes with duplicate "route" attributes that is marked as active.
The reason why I added the settings: {childRoute: 'child-a/b' } instead of giving them distinct "route" attributes like route: 'shared-parent/child-a' and route: 'shared-parent/child-b' was that the url would actually then match shared-parent/child-a/child-a and shared-parent/child-b/child-b since we're first linking to the shared-parent.
This live runnable gist should clearly display the problem (child-a route never activating): https://gist.run/?id=95469a9cb3a762d79da31e0b64248036
Ps.
If you have a better idea of what to call the title of this question please feel free to edit it.
So I took a stab at your problem using the EventAggregator in the Activate lifecycle hook in the child view models.
https://gist.run/?id=bfb5df5e39ac0bb73e9e1cae2d2496e2
in the child view models, I just published an event stating the child route was updated:
activate(params, routeConfig, navigationInstruction) {
let payload = navigationInstruction.parentInstruction.config.title;
payload = payload.substring(0, payload.length - 7);
this.aggregator.publish("child route updated", payload + "child-a");
}
In the app.js file, I updated the route titles, and added an activeChild property. Next, update the activeChild property when the event is captured:
constructor(aggregator) {
this.aggregator = aggregator;
this.aggregator.subscribe("child route updated", payload => {
this.activeChildRoute = payload;
console.log(this.activeChildRoute);
});
}
Finally, I updated the class expression on you list item to update based on that active child Flag:
<li repeat.for="row of router.navigation"
class="${row.title === activeChildRoute ? 'active' : ''}">

Aurelia-router router.navigation does not refresh when a route property is updated

The nav property of my create-team route is set by an observable property in my userService.
I have one problem though. There is no way to refresh the router.navigation array to reflect that my property isTeamMember has changed. So even if my isTeamMember property returns true then the route would not be hidden unless I do a full page refresh.
I tried with router.refreshNavigation() and it did not work, since from what I understand only routes that are set to nav: true will be added to the router.navigation array.
I'm currently looping through router.navigation array to display my menu for my app. But when the user create a team I would like to hide that route from the menu since the user is now a team owner. I handle access to that route with an AuthorizeStep which works but would also like to hide this route dynamically.
app.ts
configureRouter(config: RouterConfiguration, router: Router){
...
var appRoutes = [
{ route: '/create-team', name:'create-team', moduleId: 'routes/create-team', nav: !this.userService.isTeamMember, title: 'menu.create-team', settings: { auth: { isNotTeamMember: true } } },
]
...
}
Is there a clean way to refresh the route.navigation array so my menu is updated without having to do a full page refresh?
You can iterate over router.routes with an value converter and update the binding with the signaler.
Her is an example gist.
https://gist.run/?id=f2e02b8caa831776d8571b9c886cfdd4

Scroll event doesn't trigger

I have a problem with scroll binding. I tried to use scroll binding and event listeners without luck. Using jquery doesn't work either. Any other event I tried works as expected, but with scroll it does literally nothing. No error, no message .. What am I missing?
Edit
Ok I made a mistake and didn't put it in the app.js and app.html as mentioned in examples but one layer under so there was no scroll event to catch on the element. If I put scroll.trigger there it works.
New question:
Can scroll be triggered in children views?
Eg.: lets say you have 2 view-models on 2 routes and you want one to trigger scroll event and the other not to. Can someone point me to right direction?
Can check this ? website for detail
I found mistake causing my original problem - it is described in question.
To answer my second question I used this solution:
app.html
<div class="page-host" scroll.trigger="handleScrollEvent($event) & debounce:200">
<router-view></router-view>
</div>
app.js
export class App {
configureRouter(config, router) {
config.title = 'Title';
config.map([
{ route: ['', 'first'], name: 'first', moduleId: './first', nav: true, title: 'First route' },
{ route: ['second'], name: 'second', moduleId: './second', nav: true, title: 'Second route' }
]);
this.router = router;
}
handleScrollEvent(e) {
// if not on specified route
if(this.router.currentInstruction.config.name !== 'second') {
return;
}
// if scrolled under defined pixels
if(e.target.scrollTop > 100) {
$('body').find('#toolbar').addClass('fixed');
} else {
$('body').find('#toolbar').removeClass('fixed');
}
}
}
I don't know how clean it is but it is working. Feel free to sugest other solution.

Durandal: Conditional Start Module When Activating the Router

My Durandal application's startup logic in shell.js requires sending the user to one of two possible views depending on some conditional logic. Basically, if certain options have not previously been selected I'll send them to a Setup page, otherwise I'll send them to the normal start page.
Previously, in Durandal 1.x I would just pass a string indicating the starting module when calling activate. But in Durandal 2.x that has been deprecated.
So first, I'm wondering what is the recommended way to do this from the standpoint of the routes array? Should I just register both routes as if neither is the start module (like below) then conditionally add a another route to the array with the default route of ''?
{ route: "setup", moduleId: "setup", title: "Setup", nav: false },
{ route: "Students", moduleId: "students", title: "Students", nav: false }
The second part of my question involves how to handle the need to make a call to a web service as part of my conditional logic for determining which module is the start module. My startup logic involves checking the local storage of the browser, but sometimes I'll also need to make an ajax request to the server to get a bit of information.
My understanding is that router.activate() is a promise. Can I actually just create my own promise and resolve it by calling router.activate() after the ajax call has completed? Or is there another way I'll need to handle that? Sample code for how I was thinking I might handle this:
var deferred = $.Deferred();
//Setup and conditional logic here
var routes = setupRoutes();
$.get('/api/myStartupEndpoint')
.done(function(results) {
//check results and maybe alter routes
deferred.resolve(router.map(routes).activate());
});
return deferred.promise();
Does that make sense? I'm still converting my app over to Durandal 2.0.1 so I haven't been able to try this yet, but regardless of whether it does or not I want to find out what the recommended approach would be in this scenario.
The way I'd do it is this - actually I think it's similar to how you're already thinking, so I hope it makes sense:
In your main.js, set your application root to the same module, no matter the circumstances. Since one of the main features of D2 is child routers, I'd suggest using the module name "root" for your application root, as it makes it easier to distinguish from "shell" modules (which I use for setting up child routers):
app.start().then(function () {
app.setRoot("root", "entrance");
});
In your root module, setup the routes as you have described:
{ route: "setup", moduleId: "setup", title: "Setup", nav: false },
{ route: "students", moduleId: "students", title: "Students", nav: false }
When your root module activates, check if the user has been setup or not. Use the result of that check to work out if you want the user to be sent to the setup page, or the students page. Note however that you must activate your router before redirecting; you can use $.when to help you here:
var checkUserSetup = function () {
var deferred = $.Deferred();
// check local storage, fire off ajax request, etc.
// resolve the deferred once you know if the user is setup or not
deferred.resolve(false);
return deferred.promise();
};
root.activate = function() {
mapRoutes();
var deferred = $.Deferred();
$.when(checkUserSetup(), router.activate()).done(function(isUserSetup) {
if (isUserSetup) {
router.navigate("students");
} else {
router.navigate("setup");
}
deferred.resolve();
});
return deferred.promise();
}
Hopefully this also answers the second part of your question; but just in case - yes, you can return a promise from the activate method, and durandal will "block" until you've resolved that promise. However, note that the router's activate method also returns a promise - so your code won't quite work. You're resolving your deferred with another deferred; you'd need to do something more like this:
root.activate = function() {
var deferred = $.Deferred();
//Setup and conditional logic here
var routes = setupRoutes();
$.get('/api/myStartupEndpoint')
.done(function(results) {
//check results and maybe alter routes
//don't resolve the deferred until the router has completed activation
router.map(routes).activate().done(deferred.resolve);
});
return deferred.promise();
}
Hope that helps.