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-stache-element is available as
import { StacheElement } from "can";
. - Creates a basic
<character-search-app>
element. - Includes a
<mock-url>
element 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:
- Import can-route.
- Define a
routeData
property whose value is route.data. - Set up "pretty" routing rules.
- Initialize can-route.
We want to support the following URL patterns:
#!
#!search
#!list/rick
#!details/rick/1
What you need to know
- Use default to create element properties that default to objects:
class MyElement extends StacheElement {
static props = {
dueDate: {
get default() {
return new Date();
}
}
};
}
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 app’s properties using document.querySelector("character-search-app")
from the console.
You should be able to update the element properties and see the URL update. Also, updating the URL should update the properties on the element.
The solution
Update the JavaScript tab to:
import { StacheElement, route } from "//unpkg.com/can@6/ecosystem.mjs";
class CharacterSearchApp extends StacheElement {
static view = `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
`;
static props = {
routeData: {
get default() {
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return route.data;
}
}
};
}
customElements.define("character-search-app", CharacterSearchApp);
Lazy load elements
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 elements we will use for each route are available as ES Modules on unpkg:
- //unpkg.com/character-search-components@6/character-search.mjs
- //unpkg.com/character-search-components@6/character-list.mjs
- //unpkg.com/character-search-components@6/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:
class Person extends ObservableObject {
static props = {
first: {
default: "Kevin"
},
last: {
default: "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
static 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 { StacheElement, route } from "//unpkg.com/can@6/ecosystem.mjs";
class CharacterSearchApp extends StacheElement {
static view = `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
{{ this.routeComponent }}
`;
static props = {
routeData: {
get default() {
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return route.data;
}
},
get routeComponent() {
const componentURL =
"//unpkg.com/character-search-components@6/character-" +
this.routeData.page +
".mjs";
return import(componentURL).then(module => {});
}
};
}
customElements.define("character-search-app", CharacterSearchApp);
Display elements
The problem
Now that the code is loaded for each route, we can create an instance of the loaded element and display it in the view.
What you need to know
import()
returns a promise.- Promises can be used directly in the view.
class MyElement extends StacheElement {
static view = `
{{# if(this.aPromise.isPending) }}
The code is still loading
{{/ if }}
{{# if(this.aPromise.isRejected) }}
There was an error loading the code
{{/ if }}
{{# if(this.aPromise.isResolved) }}
The code is loaded: {{ this.aPromise.value }} -> Hello
{{/ if }}
`;
static props = {
aPromise: {
get default() {
return new Promise((resolve) => {
resolve("Hello");
});
}
}
};
}
import()
resolves with a module object -module.default
is the element constructor.- Elements can be instantiated programmatically using
new ElementConstructor()
.
How to verify it works
You can check the devtools Elements Panel for the correct element on each page:
#!search
-><character-search-page>
#!list
-><character-list-page>
#!details
-><character-details-page>
The solution
Update the JavaScript tab to:
import { StacheElement, route } from "//unpkg.com/can@6/ecosystem.mjs";
class CharacterSearchApp extends StacheElement {
static view = `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="400" height="151">
</div>
{{# if(this.routeComponent.isPending) }}
Loading…
{{/ if }}
{{# if(this.routeComponent.isResolved) }}
{{ this.routeComponent.value }}
{{/ if }}
`;
static props = {
routeData: {
get default() {
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return route.data;
}
},
get routeComponent() {
const componentURL =
"//unpkg.com/character-search-components@6/character-" +
this.routeData.page +
".mjs";
return import(componentURL).then(module => {
const ElementConstructor = module.default;
return new ElementConstructor();
});
}
};
}
customElements.define("character-search-app", CharacterSearchApp);
Pass data to elements
The problem
After the last step, the correct element is displayed for each route, but the elements do not work correctly. To make these work, we will pass properties from the character-search-app
element into each element.
What you need to know
- The bindings lifecycle method can be used to create bindings between an element’s properties and parent observables.
- can-value can be used to programmatically create observables that are bound to another object.
const elementInstance = new ElementConstructor().bindings({
givenName: value.from(this, "name.first"),
familyName: value.bind(this, "name.last"),
fullName: value.to(this, "name.full")
});
- The element 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 { StacheElement, route, value } from "//unpkg.com/can@6/ecosystem.mjs";
class CharacterSearchApp extends StacheElement {
static view = `
<div class="header">
<img src="https://image.ibb.co/nzProU/rick_morty.png" width="300" height="113">
</div>
{{# if(this.routeComponent.isPending) }}
Loading...
{{/ if }}
{{# if(this.routeComponent.isResolved) }}
{{ this.routeComponent.value }}
{{/ if }}
`;
static props = {
routeData: {
get default() {
route.register("", { page: "search" });
route.register("{page}");
route.register("{page}/{query}");
route.register("{page}/{query}/{characterId}");
route.start();
return route.data;
}
},
get routeComponentData() {
switch (this.routeData.page) {
case "search":
case "list":
return {
query: value.from(this.routeData, "query")
};
case "details":
return {
query: value.from(this.routeData, "query"),
id: value.from(this.routeData, "characterId")
};
}
},
get routeComponent() {
const componentURL =
"//unpkg.com/character-search-components@6/character-" +
this.routeData.page +
".mjs";
return import(componentURL).then(module => {
const ElementConstructor = module.default;
return new ElementConstructor().bindings(this.routeComponentData);
});
}
};
}
customElements.define("character-search-app", CharacterSearchApp);
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.