Search, List, Details
This advanced guide walks through building a Search, List, Details flow with lazy-loaded routes.
The final widget looks like:
See the Pen Search / List / Details - Final by Bitovi (@bitovi) on CodePen.
The following sections are broken down into the following parts:
- The problem — A description of what the section is trying to accomplish.
- What you need to know — Information about CanJS that is useful for solving the problem.
- How to verify it works - How to make sure the solution works (if it’s not obvious).
- The solution — The solution to the problem.
Setup
The problem
In this section, we will fork this CodePen that contains some starting code that we will modify to have a Search, List, Details flow with lazy-loaded routes.
What you need to know
This CodePen:
- Loads all of CanJS’s packages. Each package is available as a named export. For example can-component is available as
import { Component } from "can"
. - Creates a basic
<character-search-app>
component. - Includes a
<mock-url>
component for interacting with the route of the CodePen page.
The solution
START THIS TUTORIAL BY CLONING THE FOLLOWING CodePen:
Click the
EDIT ON CODEPEN
button. The CodePen will open in a new window. In that new window, clickFORK
.
See the Pen Search / List / Details - Setup by Bitovi (@bitovi) on CodePen.
Configure routing
The problem
In this section, we will:
- Create an observable key-value object.
- Cross-bind the observable with the URL.
- Set up "pretty" routing rules.
We want to support the following URL patterns:
#!
#!search
#!list/rick
#!details/rick/1
What you need to know
- Use default to create default values for properties on the
ViewModel
.
ViewModel: {
dueDate: {
default() {
return new Date();
}
}
}
- Use
new observe.Object({ /* ... */ })
to create an observable Object. - Set route.data to the object you want cross-bound to the URL.
route.register( "{abc}" );
will create a URL matching rule.
// default route - if hash is empty, default `viewModel.abc` to `"xyz"`
route.register( "", { abc: "xyz" });
// match routes like `#!xyz` - sets `viewModel.abc` to `xyz`
route.register( "{abc}" );
// match routes like `#!xyz/uvw` - sets `viewModel.abc` to `xyz`, `viewModel.def` to `uvw`
route.register( "{abc}/{def}" );
route.start()
will initialize can-route.
How to verify it works
You can access the viewModel for the app using document.querySelector("character-search-app").viewModel
from the console.
You should be able to update properties on the viewModel and see the URL update. Also, updating the URL should update the properties on the viewModel.
The solution
Update the JavaScript tab to:
import { Component, observe, route } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "character-search-app",
view: `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
`,
ViewModel: {
routeData: {
default() {
const observableRouteData = new observe.Object();
route.data = observableRouteData;
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return observableRouteData;
}
}
}
});
Lazy load components
The problem
In this section, we will load the code for each route when that route is displayed. This technique prevents loading code for routes a user may never visit.
The components we will use for each route are available as ES Modules on unpkg:
- //unpkg.com/character-search-components@5/character-search.mjs
- //unpkg.com/character-search-components@5/character-list.mjs
- //unpkg.com/character-search-components@5/character-details.mjs
What you need to know
- Use get to create virtual properties that will be re-evaluated when an observable property they depend on changes:
ViewModel: {
first: {
default() {
return "Kevin";
}
},
last: {
default() {
return "McCallister";
}
},
// The name property will update whenever `first` or `last` changes
get name() {
return this.first + " " + this.last;
}
},
// make sure to put `name` in the view so that bindings are set up correctly
view: `
{{name}}
`
- Call the
import()
keyword as a function to dynamically import a module.
How to verify it works
Changing the routeData.page
property will cause the code for the new route to be loaded in the devtools Network Tab.
The solution
Update the JavaScript tab to:
import { Component, observe, route } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "character-search-app",
view: `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
{{ routeComponent }}
`,
ViewModel: {
routeData: {
default() {
const observableRouteData = new observe.Object();
route.data = observableRouteData;
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return observableRouteData;
}
},
get routeComponent() {
const componentURL =
"//unpkg.com/character-search-components@5/character-" +
this.routeData.page + ".mjs";
return import(componentURL).then((module) => {
});
}
}
});
Display components
The problem
Now that the code is loaded for each route, we can create an instance of the loaded component and display it in the view.
What you need to know
import()
returns a promise.- Promises can be used directly in the view.
ViewModel: {
get aPromise() {
return new Promise((resolve) => {
resolve("Hello");
});
}
},
view: `
{{# if(aPromise.isPending) }}
The code is still loading
{{/ if }}
{{# if(aPromise.isRejected) }}
There was an error loading the code
{{/ if }}
{{# if(aPromise.isResolved) }}
The code is loaded: {{aPromise.value}} -> Hello
{{/ if }}
`
import()
resolves with a module object -module.default
is the component constructor.- Components can be instantiated programmatically using
new ComponentConstructor()
.
How to verify it works
You can check the devtools Elements Panel for the correct component on each page:
#!search
-><character-search-page>
#!list
-><character-list-page>
#!details
-><character-details-page>
The solution
Update the JavaScript tab to:
import { Component, observe, route } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "character-search-app",
view: `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
{{# if(routeComponent.isPending) }}
Loading…
{{/ if }}
{{# if(routeComponent.isResolved) }}
{{ routeComponent.value }}
{{/ if }}
`,
ViewModel: {
routeData: {
default() {
const observableRouteData = new observe.Object();
route.data = observableRouteData;
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return observableRouteData;
}
},
get routeComponent() {
const componentURL =
"//unpkg.com/character-search-components@5/character-" +
this.routeData.page + ".mjs";
return import(componentURL).then((module) => {
const ComponentConstructor = module.default;
return new ComponentConstructor({
});
});
}
}
});
Pass data to components
The problem
After the last step, the correct component is displayed for each route, but the components do not work correctly. To make these work, we will pass properties from the main ViewModel into each component.
What you need to know
- You can pass a
viewModel
property when instantiating components to pass values to the component’s viewModel and set up bindings. - can-value can be used to programmatically create observables that are bound to another object.
const componentInstance = new ComponentConstructor({
viewModel: {
givenName: value.from(this, "name.first"),
familyName: value.bind(this, "name.last"),
fullName: value.to(this, "name.full")
}
});
- The component for all three pages need a
query
property. The<character-details-page>
also needs anid
property.
How to verify it works
The app should be fully functional:
- Typing in the
<input>
and clicking theSearch
button should take you to the list page with a list of matching characters. - Clicking a character on the list page should take you to the details page for that character.
- Clicking the
< Characters
button should take you back to the list page. - Clicking the
< Search
button should take you back to the search page with the query still populated in the<input>
.
The solution
Update the JavaScript tab to:
import { Component, observe, route, value } from "//unpkg.com/can@5/ecosystem.mjs";
Component.extend({
tag: "character-search-app",
view: `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
{{# if(routeComponent.isPending) }}
Loading…
{{/ if }}
{{# if(routeComponent.isResolved) }}
{{ routeComponent.value }}
{{/ if }}
`,
ViewModel: {
routeData: {
default() {
const observableRouteData = new observe.Object();
route.data = observableRouteData;
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return observableRouteData;
}
},
get routeComponentData() {
const viewModelData = {
query: value.from(this.routeData, "query")
};
if(this.routeData.page === "details") {
viewModelData.id = value.from(this.routeData, "characterId");
}
return viewModelData;
},
get routeComponent() {
const componentURL =
"//unpkg.com/character-search-components@5/character-" +
this.routeData.page + ".mjs";
return import(componentURL).then((module) => {
const ComponentConstructor = module.default;
return new ComponentConstructor({
viewModel: this.routeComponentData
});
});
}
}
});
Result
When complete, you should have a working Search, List, Details flow like the following CodePen:
See the Pen Search / List / Details - Final by Bitovi (@bitovi) on CodePen.