I'm wondering how to handle the "no data" scenario with the Meteor's Flow Router. Let's say I want to have a route /article/:slug on which I want to render an Article (fetched by the slug field) along with related comments (separate collection). So I'll define a route like this:
FlowRouter.route('/article/:slug', {
subscriptions: function(params) {
this.register('article', Meteor.subscribe('articleSlug', params.slug));
this.register('comments', Meteor.subscribe('commentsByArticleSlug', params.slug));
},
action: function() {
FlowLayout.render('appLayout', { main: "articleDetail" });
}
});
In the template I'll display the article details & comments when subs are ready, until then I'll just display a "loading..." text.
But what if the :slug param in the URL doesn't match any article in the DB? I don't want to keep displaying the "loading..." text, I want to display some sort of "Article not found" message. How to do this?
Thanks for any advice.
UPDATE: I'd also like to point to the github discussion related to this question: https://github.com/meteorhacks/flow-router/issues/70
This could be done by your template.
Template.articleDetail.helpers ({
articleFound: articles.find().count() > 0
});
In your view, render the right stuff depends on result of articleFound.
This isn't an official answer, but it's what I'd do. Let's say this is for a blog (looks that way) you can just throw a conditional there, similar to a 404.
So
subscriptions: function(params) {
//check that this slug has an article assigned to it
var slugCheck = Meteor.subscribe('articleSlug', params.slug);
if (slugCheck != ''){
this.register('article', Meteor.subscribe('articleSlug', params.slug));
this.register('comments', Meteor.subscribe('commentsByArticleSlug', params.slug));
}
else{
this.register('article', Meteor.subscribe('articleSlug', 'missing'));
}
}
Then you'd just need to show the missing article entry.
Thanks for both answers! While testing them I came up with a third way: leave the subs in the subscriptions section and put the check to the action section - there we can do redirect if no article fit the slug.
FlowRouter.route('/article/:slug', {
subscriptions: function(params) {
this.register('article', Meteor.subscribe('articleSlug', params.slug));
this.register('comments', Meteor.subscribe('commentsByArticleSlug', params.slug));
},
action: function() {
if (Articles.find().count() > 0) {
FlowLayout.render('appLayout', { main: "articleDetail" });
}
else {
FlowRouter.go('/articlenotfound');
}
}
});
Related
What is the correct way to set properties on an Ember route? I have a title message that I would like to be displayed on page load and then I would like to change that title as the user interacts with the page.
import Ember from 'ember';
export default Ember.Route.extend({
title: "Welcome!",
model(thing1) {
return Ember.RSVP.hash({
thing1: this.store.findRecord('thing1', thing1.thing_id),
thing2: this.store.findAll('thing2'),
thing3: this.store.findAll('thing3')
});
},
actions: {
changeTitle() {
this.set("title", "I changed!")
}
}
});
In my template I load another component and pass in the value for title
{{title-tile title=title}}
And in my component title, I print out (theoretically) the title:
{{title}}
I have also tried removing the intermediary step and just printing out the title directly but that doesn't work.
In the console I have no errors, and I am otherwise able to print out the model data from the RSVP hash. There is no (obvious) documentation on this. All documentation focuses on printing out model properties.
What am I missing?
Edit
It appears to me that routes are just meant to handle models and that components are supposed to take care of everything else.
I've actually tried explicitly calling the title to be set on route load and it still doesn't work.
...
init() {
title: "Welcome!"
}
...
You could use a computed property in hash passed to template:
export default Ember.Route.extend({
title: 'Welcome!',
model(thing1) {
return Ember.RSVP.hash({
// ... omitted for brevity
title: Ember.computed(() => this.get('title')) // computed property
});
},
actions: {
changeTitle() {
this.set("title", "I changed!")
this.refresh(); // it is required to refresh model
}
}
});
Working demo.
Full code behind demo.
Considering something similar to the example outlined here:
App.Router.map(function() {
this.resource("posts", function() {
this.resource("post", { path: "/posts/:post_id" }, function() {
this.resource("comments", { path: "/comments" });
});
});
});
using the DS.RESTAdapter. The Router would load all the posts when I access the PostsRoute with a call to the API URL /posts.
When I then access PostRoute, for example for the post with id=1, it doesn't hit the API again, i.e. it doesn't hit /post/1. It loads the post from the store.
I want then to access CommentsRoute for post with id=1. How do I load the comments?
Should I have sideloaded all the comments in the first place, when I loaded the post list? In this case though, I would need to load all the comments for all the posts. Is it possible to load the comments only when needed, i.e. when I access CommentsRoute?
In this case, how do I actually load the comments from the backend?
Specifically, how do I write the CommentsRoute to load the comments from the RESTful API when I access the page that actually displays them?
I guess one needs to have the following:
App.Post = DS.Model.extend({
comments: DS.hasMany('comment')
});
App.Comment = DS.Model.extend({
post: DS.belongsTo('post')
});
App.CommentsRoute = Ember.Route.extend({
model: function() {
/*
* How do I inject params.post_id here
* to make a request to the RESTful API?
* Which URL would be called?
* /comments?post_id=ID
* /post/post_id/comments
* ...
*/
// Doesn't work, hits /comments
return this.store.find('comment', { post_id: params.post_id });
}
});
Why does
return this.store.find('comment', { post_id: params.post_id });
hit /comments?
You just need to declare your CommentsRoute like this:
App.CommentsRoute = Ember.Route.extend({
model: function() {
return this.modelFor('post').get('comments');
}
});
What it does is, it gets the model of the PostRoute and fetches its comments.
Ember-data handles the logic behind it. If comments were sideloaded, it will just return these. Otherwise it will issue a GET request to fetch them.
For this to work, you need to include the links property on a serialized post. The links property needs to include the URL you wish ember-data to use in order to fetch the comments.
E.g. your serialized post may look like this:
{
"post": {
"id": 1,
"title": "Rails is omakase",
"links": { "comments": "/posts/1/comments" }
}
}
See DS.RESTAdapter#findHasMany.
The hasMany relationship probably needs to be declared async for this to work properly:
App.Post = DS.Model.extend({
comments: DS.hasMany('comment', { async: true })
});
You can use Ember's sideloaded relationships to make the posts API endpoint also return the relevant comments and Ember will figure it out.
http://emberjs.com/guides/models/the-rest-adapter/#toc_sideloaded-relationships
{
"post": {
"id": 1,
"title": "Node is not omakase",
"comments": [1, 2, 3]
},
"comments": [{
"id": 1,
"body": "But is it _lightweight_ omakase?"
},
{
"id": 2,
"body": "I for one welcome our new omakase overlords"
},
{
"id": 3,
"body": "Put me on the fast track to a delicious dinner"
}]
}
You'd then be able to pass the already loaded comments to the comments route.
It may be in the docs but it's quite a specific term! Some of the concepts like that can be a bit tricky to search for.
The following forces a call to the backend /comments?post_id=ID
App.CommentsController = Ember.ArrayController.extend({
needs: 'post'
});
App.CommentsRoute = Ember.Route.extend({
model: function(params) {
return this.store.find('comment', { post_id: this.modelFor('post').get('id') });
}
});
One key component to web applications is breadcrumbs/navigation. With Angular UI Router, it would make sense to put the breadcrumb metadata with the individual states, rather than in your controllers. Manually creating the breadcrumbs object for each controller where it's needed is a straight-forward task, but it's also a very messy one.
I have seen some solutions for automated Breadcrumbs with Angular, but to be honest, they are rather primitive. Some states, like dialog boxes or side panels should not update the breadcrumbs, but with current addons to angular, there is no way to express that.
Another problem is that titles of breadcrumbs are not static. For example, if you go to a User Detail page, the breadcrumb title should probably be the user's Full Name, and not a generic "User Detail".
The last problem that needs to be solved is using all of the correct state parameter values for parent links. For example, if you're looking at a User detail page from a Company, obviously you'll want to know that the parent state requires a :companyId.
Are there any addons to angular that provide this level of breadcrumbs support? If not, what is the best way to go about it? I don't want to clutter up my controllers - I will have a lot of them - and I want to make it as automated and painless as possible.
Thanks!
I did solve this myself awhile back, because nothing was available. I decided to not use the data object, because we don't actually want our breadcrumb titles to be inherited by children. Sometimes there are modal dialogs and right panels that slide in that are technically "children views", but they shouldn't affect the breadcrumb. By using a breadcrumb object instead, we can avoid the automatic inheritance.
For the actual title property, I am using $interpolate. We can combine our breadcrumb data with the resolve scope without having to do resolves in a different place. In all of the cases I had, I just wanted to use the resolve scope anyway, so this works very well.
My solution also handles i18n too.
$stateProvider
.state('courses', {
url: '/courses',
template: Templates.viewsContainer(),
controller: function(Translation) {
Translation.load('courses');
},
breadcrumb: {
title: 'COURSES.TITLE'
}
})
.state('courses.list', {
url: "/list",
templateUrl: 'app/courses/courses.list.html',
resolve: {
coursesData: function(Model) {
return Model.getAll('/courses');
}
},
controller: 'CoursesController'
})
// this child is just a slide-out view to add/edit the selected course.
// It should not add to the breadcrumb - it's technically the same screen.
.state('courses.list.edit', {
url: "/:courseId/edit",
templateUrl: 'app/courses/courses.list.edit.html',
resolve: {
course: function(Model, $stateParams) {
return Model.getOne("/courses", $stateParams.courseId);
}
},
controller: 'CourseFormController'
})
// this is a brand new screen, so it should change the breadcrumb
.state('courses.detail', {
url: '/:courseId',
templateUrl: 'app/courses/courses.detail.html',
controller: 'CourseDetailController',
resolve: {
course: function(Model, $stateParams) {
return Model.getOne('/courses', $stateParams.courseId);
}
},
breadcrumb: {
title: '{{course.name}}'
}
})
// lots more screens.
I didn't want to tie the breadcrumbs to a directive, because I thought there might be multiple ways of showing the breadcrumb visually in my application. So, I put it into a service:
.factory("Breadcrumbs", function($state, $translate, $interpolate) {
var list = [], title;
function getProperty(object, path) {
function index(obj, i) {
return obj[i];
}
return path.split('.').reduce(index, object);
}
function addBreadcrumb(title, state) {
list.push({
title: title,
state: state
});
}
function generateBreadcrumbs(state) {
if(angular.isDefined(state.parent)) {
generateBreadcrumbs(state.parent);
}
if(angular.isDefined(state.breadcrumb)) {
if(angular.isDefined(state.breadcrumb.title)) {
addBreadcrumb($interpolate(state.breadcrumb.title)(state.locals.globals), state.name);
}
}
}
function appendTitle(translation, index) {
var title = translation;
if(index < list.length - 1) {
title += ' > ';
}
return title;
}
function generateTitle() {
title = '';
angular.forEach(list, function(breadcrumb, index) {
$translate(breadcrumb.title).then(
function(translation) {
title += appendTitle(translation, index);
}, function(translation) {
title += appendTitle(translation, index);
}
);
});
}
return {
generate: function() {
list = [];
generateBreadcrumbs($state.$current);
generateTitle();
},
title: function() {
return title;
},
list: function() {
return list;
}
};
})
The actual breadcrumb directive then becomes very simple:
.directive("breadcrumbs", function() {
return {
restrict: 'E',
replace: true,
priority: 100,
templateUrl: 'common/directives/breadcrumbs/breadcrumbs.html'
};
});
And the template:
<h2 translate-cloak>
<ul class="breadcrumbs">
<li ng-repeat="breadcrumb in Breadcrumbs.list()">
<a ng-if="breadcrumb.state && !$last" ui-sref="{{breadcrumb.state}}">{{breadcrumb.title | translate}}</a>
<span class="active" ng-show="$last">{{breadcrumb.title | translate}}</span>
<span ng-hide="$last" class="divider"></span>
</li>
</ul>
</h2>
From the screenshot here, you can see it works perfectly in both the navigation:
As well as the html <title> tag:
PS to Angular UI Team: Please add something like this out of the box!
I'd like to share my solution to this. It has the advantage of not requiring anything to be injected into your controllers, and supports named breadcrumb labels, as well as using resolve: functions to name your breadcrumbs.
Example state config:
$stateProvider
.state('home', {
url: '/',
...
data: {
displayName: 'Home'
}
})
.state('home.usersList', {
url: 'users/',
...
data: {
displayName: 'Users'
}
})
.state('home.userList.detail', {
url: ':id',
...
data: {
displayName: '{{ user.name | uppercase }}'
}
resolve: {
user : function($stateParams, userService) {
return userService.getUser($stateParams.id);
}
}
})
Then you need to specify the location of the breadcrumb label (displayname) in an attribute on the directive:
<ui-breadcrumbs displayname-property="data.displayName"></ui-breadcrumbs>
In this way, the directive will know to look at the value of $state.$current.data.displayName to find the text to use.
$interpolate-able breadcrumb names
Notice that in the last state (home.userList.detail), the displayName uses the usual Angular interpolation syntax {{ value }}. This allows you to reference any values defined in the resolve object in the state config. Typically this would be used to get data from the server, as in the example above of the user name. Note that, since this is just a regular Angular string, you can include any type of valid Angular expression in the displayName field - as in the above example where we are applying a filter to it.
Demo
Here is a working demo on Plunker: http://plnkr.co/edit/bBgdxgB91Z6323HLWCzF?p=preview
Code
I thought it was a bit much to put all the code here, so here it is on GitHub: https://github.com/michaelbromley/angularUtils/tree/master/src/directives/uiBreadcrumbs
I made a Angular module which generate a breadcrumb based on ui-router's states. All the features you speak about are included (I recently add the possibility to ignore a state in the breadcrumb while reading this post :-) ) :
Here is the github repo
It allows dynamic labels interpolated against the controller scope (the "deepest" in case of nested/multiple views).
The chain of states is customizable by state options (See API reference)
The module comes with pre-defined templates and allows user-defined templates.
I do not believe there is built in functionality, but all the tools are there for you, take a look at the LocationProvider. You could simply have navigation elements use this and whatever else you want to know just inject it.
Documentation
After digging deep into the internals of ui-router I understood how I could create a breadcrumb using resolved resources.
Here is a plunker to my directive.
NOTE: I couldn't get this code to work properly within the plunker, but the directive works in my project. routes.js is provided merely for example of how to you can set titles for your breadcrumbs.
Thanks for the solution provided by #egervari. For those who need add some $stateParams properties into custom data of breadcrumbs. I've extended the syntax {:id} for the value of key 'title'.
.state('courses.detail', {
url: '/:courseId',
templateUrl: 'app/courses/courses.detail.html',
controller: 'CourseDetailController',
resolve: {
course: function(Model, $stateParams) {
return Model.getOne('/courses', $stateParams.courseId);
}
},
breadcrumb: {
title: 'course {:courseId}'
}
})
Here is an Plunker example. FYI.
I am using Ember.js with local-storage-adapter. I have a weird problem while updating records.
I have a post and comments model with hasMany relationships:
App.Post = DS.Model.extend({
title: DS.attr('string'),
comments: DS.hasMany('comment', {
async: true
})
});
App.Comment = DS.Model.extend({
message: DS.attr('string')
});
These are my post and comments controllers:
App.PostsController = Ember.ArrayController.extend({
newTitle: '',
actions: {
create: function() {
var title = this.get('newTitle');
var post = this.store.createRecord('post', {
title: title
});
this.set('newTitle', '');
post.save();
}
}
});
App.CommentsController = Ember.ArrayController.extend({
needs: "post",
post: Ember.computed.alias("controllers.post.model"),
newMessage: '',
actions: {
create: function() {
var message = this.get('newMessage');
var comment = this.store.createRecord('comment', {
message: message
});
var post = this.get('post');
var comments = post.get('comments');
if (comments.get('content') == null) comments.set('content', []);
comments.pushObject(comment);
comment.save();
post.save();
}
}
});
While creating records hasMany relations updated correctly.
{
"App.Post": {
"records": {
"0v66j": {
"id": "0v66j",
"title": "post1",
"comments": ["p31al", "tgjtj"]
}
}
},
"App.Comment": {
"records": {
"p31al": {
"id": "p31al",
"message": "comment 1"
},
"tgjtj": {
"id": "tgjtj",
"message": "comment 2"
}
}
}
}
The problem occured while editing post. The relationships are gone after editing the post record. I did some searching and found this code:
DS.JSONSerializer.reopen({
serializeHasMany: function(record, json, relationship) {
var key = relationship.key;
var relationshipType = DS.RelationshipChange.determineRelationshipType(record.constructor, relationship);
// alert(relationshipType);
if (relationshipType === 'manyToNone' || relationshipType === 'manyToMany' || relationshipType === 'manyToOne') {
json[key] = Ember.get(record, key).mapBy('id');
// TODO support for polymorphic manyToNone and manyToMany
// relationships
}
}
});
This did the trick and it worked fine. But now I have another problem. If I edit any other record, all the id references are replaced by whole object like this:
{"App.Post":{"records":{"0v66j":{"id":"0v66j","title":"post2","comments":[**{"message":"comment 1"},
{"message":"comment 2"}**]},"8nihs":{"id":"8nihs","title":"post3","comments":["b4v2b","dbki4"]}}},
"App.Comment":{"records":{"p31al":{"id":"p31al","message":"comment 1"},"tgjtj":{"id":"tgjtj","message":"comment 2"},
"b4v2b":{"id":"b4v2b","message":"comments3"},"dbki4":{"id":"dbki4",
"message":"comments4"}}}}
Comment refrences should be comments":["p31al","tgjtj"] like this. but the ids are replaced as "comments":[{"message":"comment 1"},{"message":"comment 2"}]
When using ApplicationSerializer which extends LSSerializer, it seems to work.
Maybe it got fixed since asked?
I've noticed a few things in my path with Ember... and especially Ember-Data.
One of them is when dealing with associations I've had to manually re-add in the associations saving and having to re-save, and use addObject to in-memory associations as you're using a bit here. :)
Note that this usually only happens when I'm updating more than one new object at once. For example, if your post is new, and your comment is also new.
I'm a little worried to see the following code in your codebase, because it shouldn't need to be there. You shouldn't ever have null or non-array objects in your associations. I'm not sure what hackery you did with the Adapter and why it was necessary, but I hope that wasn't the reason:
if(comments.get('content') == null)
comments.set('content', []);
Anyway, the following code is how I would probably write your create action. It might help. I hope it does.
create: function() {
// get the post for association on the new comment
var post = this.get('post');
// get the message to store on the new comment
var message = this.get('newMessage');
var comment = this.store.createRecord('comment', {
message : message,
post : post
});
comment.save().then(function(savedComment) {
post.get('comments').addObject(savedComment);
});
}
Note that it's a lot simpler. Generally if you're doing tricky complicated things, something's amiss and it's time to go back to basics and add one thing at a time, testing thoroughly between additions. :)
Good luck!
How do I pass data between two different routes and templates?
I have a javascript file on the front end (client folder) that simply calls Router.go() passing in the post ID as one of my parameters.
Below are the three main culprits (I believe). I've removed most of the code to make it easier to read. I can change to the PostDetail page with no problems. I can also retrieve the PostId on the PostDetail page from the Router. My problem is, the database entry (POLL) that is retrieved does not get rendered on the template. Hence {{Question}} is always blank even though the database entry is being returned.
Let me know if I should post more information.
FrontEnd.js
Template.PostTiles.events({
// When a choice is selected
'click .pin' : function(event, template) {
Router.go('Post', {_PostId: this.PostId});
}
});
post-detail.html
<template name="PostDetail">
<h3>{{Question}}</p>
</template>
Shared.js
Router.map( function() {
this.route('Home', {
path: '/',
template: 'PostTiles',
data: {
// Here we can return DB data instead of attaching
// a helper method to the Template object
QuestionsList: function() {
return POLL.find().fetch();
}
}
});
this.route('Post', {
template: 'PostDetail',
path: '/Post/:_PostId',
data: function() {
return POLL.findOne(this.params._PostId);
},
renderTemplates: {
'disqus': {to: 'comments'}
}
});
});
----- Update -----
I think I've narrowed down the issue to simply being able to render only one Database entry, instead of a list of them using the {{#each SomeList}} syntax.
Looks like you found the answer / resolved this, but just in case, I think it's in your findOne statement:
data: function() {
return POLL.findOne(this.params._PostId);
},
should read:
data: function() {
return POLL.findOne({_id:this.params._PostId});
},
(assuming that POLL has your posts listed by _id.
Hope that helps.
Could you pass the info in the Session? the docs for that are here http://docs.meteor.com/#session. That's what I'm planning on doing.