In Aurelia we have the ability to dynamically compose viewmodels and views using the <compose> element. We can also supply an object of data via model.bind which is then accessible via the activate method of the provided viewmodel.
My question is, what conditions trigger a change event on the provided model data? If I change a property on the object I provide, will my activate method which gets this object as the first parameter see the change? Or does the entire object need to be replaced to trigger a change?
The activate(model) gets called once when the model is bound to the view model. When the model attributes change, those changes will be reflected in the composed view model because the model is a reference, not a copy.
For example, say I have a view / view model that is a target for a route as follows (this example is not a perfectly clean example because I was experimenting with other things as well, but it should be clear enough):
View: This view creates two sections separated by an <hr>. The top just displays the model.message for each view. The bottom creates a <compose> for each view.
<template>
<div repeat.for="view of openViews">
<p>${view.model.message}</p>
</div>
<hr>
<div repeat.for="view of openViews">
<compose view-model.bind="$parent.getViewFromType(view)" model.bind="view.model">
</compose>
</div>
</template>
View Model: note that the openViews is at a global scope. This is so that if we navigate away from the route and then return to the route, any changes made to the view.model will be retained. If the model was on the ZenStudio object, the object gets destroyed and recreated when the route moves away and returns to this view and therefore would lose the data.
var openViews = [
{ viewType: "", model: { message: "View 1"}},
{ viewType: "", model: { message: "View 2"}},
{ viewType: "", model: { message: "View 3"}}
];
export class ZenStudio {
constructor() {
}
created() {
}
get openViews() {
return openViews;
}
getViewFromType(view) {
// TOOD Load plugins and use the views defined by plugins
return "./views/editor-view";
}
}
The editor-view view and view-model are as follows:
<template>
<h1>${model.message}</h1>
<form>
<input type="text" value.bind="model.message">
</form>
</template>
View-model:
export class EditorView {
constructor() {
}
created(owningView, thisView) {
this.view = thisView;
this.parentView = owningView;
}
activate(model) {
// Keep track of this model
this.model = model;
}
}
You'll see that the ZenStudio view is displaying the same model.message as the EditorView. When the user edits the value of the message inside the <input>, the values correctly change in the top level view, as well as within the corresponding <compose> view.
While I don't have an example, if you added another item to openViews list then that would add another sub view and another line in the top level view displaying the new message. The repeat.for listens to the additions and subtractions made to the list and correctly creates / removes the composed elements.
Hopefully that answers your question.
Related
I have a custom attribute with a method to show and hide some HTML content, I've attached the attribute to an element in a view model.
How can I call a method defined in the custom attribute from the view model?
To access the custom attribute's view-model, just put the custom attribute on the element a second time, but this time put .ref="viewModelPropertyName" on to the attribute. Then, in the parent view-model, you can access methods on the attribute using viewModelPropertyName (or whatever name you gave it). You can see an example of this here: https://gist.run/?id=9819e9bf73f6bb43b07af355c5e166ad
app.html
<template>
<require from="./has-method"></require>
<div has-method="hello" has-method.ref="theAttribute"></div>
<button click.trigger="callMethod()">Call method</button>
</template>
app.js
export class App {
callMethod() {
const result = this.theAttribute.someMethod('blah');
}
}
has-method.js
export class HasMethodCustomAttribute {
someMethod(foo) {
console.log('someMethod called with foo = ' + foo + ', this.value = ' + this.value);
return `Hello ${foo}`;
}
}
There are some ways to do it, but I believe the ideal would be binding a property from your custom-attribute to your view-model. For example:
MyCustomAttribute {
#bindable showOrHide; //use this to show or hide your element
}
MyViewModel {
visible = false;
}
Usage:
<div my-custom-attribute="showOrHide.bind: visible"></div>
So, whenever you change visible you will also change showOrHide.
Nevertheless, is good to remember that Aurelia already has a show and if custom-attributes:
<div show.bind="visible" my-custom-attribute></div>
<div if.bind="visible" my-custom-attribute></div>
Make sure if you really need to create this behaviour in your custom-attribute.
This can be done without the need for a ref. Here is an example that shows how.
It calls a showNotification method on the custom attribute from the custom element using the custom attribute.
In the custom attribute:
#bindable({ defaultBindingMode: bindingMode.twoWay }) showNotificationCallback: ()=> void;
bind() {
this.showNotificationCallback = this.showNotification.bind(this);
}
showNotification() {
// Your code here
}
In the custom element view (Note the absence of parens in the value of this binding):
<div notification="show-notification-callback.bind: showSaveSuccessNotification;></div>
In the custom element view-model:
// Show the save success view to the user.
if (typeof this.showSaveSuccessNotification=== 'function') {
this.showSaveSuccessNotification();
}
I have created and registered a widget in Durandal, so now I am able to use it in other views using this markup:
<div data-bind="MyWidget: { activationData }" />
I would like to call methods on that widget from the parent view model, for example:
ParentViewModel.prototype.buttonClick = function() {
this.myWidget.doSomething();
}
Is there a neat way to access a widget composed in this way from the parent view model?
I've been working on this problem since posting the question, and the best solution I have come up with is this:
Add an observable, let's call it "myWidget", to the parent view model
Pass the empty observable to the widget upon activation, using widget binding
During activation, the widget sets the parent's observable to itself
For example, in the View Model definition:
this.myWidget = ko.observable(null);
Use widget binding in the parent view:
<DIV data-bind="MyWidget: { theirWidget : myWidget }" />
Set the parent view's reference in the widget's activate method:
MyWidget.prototype.activate = function(activationObject) {
activationObject.theirWidget(this);
}
While this is a reasonable solution, I'll wait and see whether anyone else provides an alternative before accepting this answer.
I have a question regarding aurelia. Let's say I have a product class, and this product has tags. A concrete example: A shirt is a product, and it has some tags users can query on such as Men, XL, blue etc. Their JSON representation is like
{
"id": 3,
"name": "Shirt",
"sku": "WCZR-1",
"tags": [
{
"id": 1,
"name": "XL"
},
{
"id": 2,
"name": "Blue"
}
]
}
If an administrator is looking at the detail view of this product and can edit tags, s\he will be looking at a view as follows:
<div class="form-group">
<label>SKU</label>
<input type="text" value.bind="item.sku">
</div>
<div class="form-group">
<label>Tags</label>
<tag-manager item.bind="item"></tag-manager>
</div>
Notice that I have another custom element called <tag-manager>, which is a child element of the product detail element. As you may have guessed, it exposes a bindable object in its export: #bindable item = null;
This way, the parent element passes in the product to the child element, where the administrator can add\remove tags by using this tag-manager. For the administrator to be able to edit tags, s\he needs to click the edit button.
This puts the item in the Edit Mode. The parent element (the product details element), does that by adding a InEditMode property to the product when the user clicks the edit button.
enterEditMode(){
this.item.inEditMode = true;
this.savedItem = JSON.parse(JSON.stringify( this.item ));
}
Note that the inEditMode property does not come from the web api, it is dynamically added.
Now, this mostly works, when the product is in the edit mode, the child element (tag-manager) can add\remove tags from the product, and both parent and child see that product tag collection is modified. Sample code from tag manager:
removeTag(tag){
this.item.tags = this.item.tags.filter(function(el){
return el.id !==tag.id;
})
}
addTag(tag){
this.item.tags.push(tag);
}
these functions work and modify the item's tag collection successfully, one thing that is not working is the product.InEditMode property. When the parent element (the detail view puts the product in the edit mode, the tag-manager recognizes this only for the view activation. But clicking edit | cancel at the parent after initial load and changing the inEditMode property in the parent is not reflected in the child view afterwards. So although the tag property of the product is being observed, the inEditMode property is not being observed by the child. If I import the observer locator in aurelia and watch the property, it does not make a difference. Sample code:
import {ObserverLocator} from 'aurelia-framework';
var subscription = this.observerLocator
.getObserver(this.product, 'inEditMode')
.subscribe(this.onChange);
}
onChange(newValue, oldValue) {
//this method is called only once at the activation of the child,
//no clicks in the parent can trigger this method again
}
As seen in the comments, the onChange method is called only once on activation; after activation, no clicks on the parent (edit, cancel edit) will be reflected in the child element and onChange won't be called again.
I was able to get this working by using eventAggregator but I really would like to know why I cannot observe a bool property of an object from the parent.I don't want to abuse eventAggregator without understanding what's going on behind the scenes. Any clues will be appreciated!
Edit:
The plunker for this problem is at
http://plnkr.co/edit/0gMZFhtKA2r6nec9kGUC?p=preview.
Slightly different issue than what I am seeing but the child view still does not reflect changes at the parent viewmodel.
I would recommend a slightly different approach, using BindingEngine:
#bindable product;
productChanged(newValue, oldValue) {
if (this.inEditModeSubscription) {
this.inEditModeSubscription.dispose();
this.inEditModeSubscription = null;
}
if (newValue) {
this.inEditModeSubscription = this.bindingEngine
.propertyObserver(newValue, "inEditMode")
.subscribe(this.onEditModeChanged);
}
}
inEditModeSubscription = null;
Adding the property-observer for product.inEditMode from within productChanged makes sure you don't accidentally add the observer to a null or undefined object, and it will refresh the subscription whenever your product is set to a different object.
This ensures that your onEditModeChanged is always fired when the property changes.
Hi I have a situation where I need to compose only a view and not a viewModel for this I have set this composition statement in my html:
<!-- ko compose: { view : content }-->
<!--/ko-->
Content represent an observable from my viewmodel.
The problem is that it seems the framework is also trying to download the viewmodel which does not exist and has no reason to exist.
Does anyon no how to stop Durandal from looking for the viewModel?
I have tryed setting the model : null but it did not work
You can't stop Durandal looking for the view model if you're using the compose binding, but there are a number of things you can do to prevent loading a new model:
Point Durandal to a dummy object to use as the model (e.g. create a singleton dummyModel.js);
Use a "dumb" object (for example an array) for your model:
<!-- ko compose: { view : content, model: [] }--><!--/ko-->
Use the current model, and turn off activation (to prevent activate being called on the model twice):
<!-- ko compose: { view : content, model: $data, activate: false }--><!--/ko-->
Basically, Durandal doesn't care what you give it as a model, as long as it has something to use. Note that it will still bind whatever model you specify to your view though!
try this
<div>
<div data-bind="compose:'views/content.html'"></div>
</div>
I am not sure if this will answer your question but I did come across a similar situation where I wanted to load views of my application which had no viewmodels. I created a module which given the view would load the view for me. All I had to was overwrite the getView function of my custom viewmodel which loaded my views.
//viewLoader --> it's job is to load the views which do not have any viewmodels
define(['plugins/router], function(router){
return {
getView: function() {
return "views/"+router.activeInstruction().config.file +".html";
}
}
}
I am using the Durandal Starter Template for mvc4. I have set the following simple View:
<section>
<h2 data-bind="html: displayName"></h2>
<h3 data-bind="html: posts"></h3>
<button data-bind="click: getrss">Get Posts</button>
<div id="rsstestid" ></div>
</section>
and ViewModel:
define(function (require) {
var http = require('durandal/http'),
app = require('durandal/app');
return {
displayName: 'This is my RssTest',
posts: ko.observable(),
activate: function () {
return;
},
getrss: function () {
$('#rsstestid').rssfeed('http://feeds.reuters.com/reuters/oddlyEnoughNews');
return;
}
};
});
As you can see, it is simply using the zRssReader plugin to load posts into a div when the 'Get Posts' button is clicked. Everything works fine, the display name is populated and the posts show up as expected.
Where I am having trouble is when I try to eliminate the button and try to load the posts at creation time. If I place the plugin call in the activate function, I get no results. I assume this is because the view is not fully loaded, so the element doesn't exist. I have two questions:
How do I delay the execution of the plugin call until the view is fully composed?
Even better, how do I load the plugin result into an the posts observable rather than using the query selector? I have tried many combinations but no luck
Thanks for your help.
EDIT** the below answer is for durandal 1.2. In durandal 2.0 viewAttached has changed to attached
Copy pasted directly from durandaljs.com
"Whenever Durandal composes, it also checks your model for a function called viewAttached. If it is present, it will call the function and pass the bound view as a parameter. This allows a controller or presenter to have direct access to the dom sub-tree to which it is bound at a point in time after it is injected into its parent.
Note: If you have set cacheViews:true then viewAttached will only be called the first time the view is shown, on the initial bind, since technically the view is only attached once. If you wish to override this behavior, then set alwaysAttachView:true on your composition binding."
--quoted from the site
There are many ways you can do it but here is just 1 quick and dirty way:
<section>
<h2 data-bind="html: displayName"></h2>
<h3 data-bind="html: posts"></h3>
<button data-bind="click: getRss">Get Posts</button>
<div id="rsstestid"></div>
</section>
and the code:
define(function (require) {
var http = require('durandal/http'),
app = require('durandal/app');
var $rsstest;
return {
displayName: 'This is my RssTest',
posts: ko.observable(),
viewAttached: function(view) {
$rssTest = $(view).find('#rsstestid');
},
getRss: function() {
$rssTest.rssfeed('http://feeds.reuters.com/reuters/oddlyEnoughNews');
}
};
});
In general, I think it's wise to refrain from directly touching UI elements from within your view model.
A good approach is to create a custom KO binding that can render the rss feed. That way, you're guaranteed that the view is in place when the binding executes. You probably want to have the feed url exposed as a property on your view model, then the custom binding can read that when it is being updated.
Custom bindings are pretty simple - if I can do it, then it must be :)
Here's a link to the KnockOut custom bindings quickstart: http://knockoutjs.com/documentation/custom-bindings.html
I too am having the same problem, I'm trying to set a css property directly on an element after the durandal view model and view are bound together. I too assume that it's not working because the view is not fully composed at the point I am setting the value.
Best I have come up with is using the viewAttached lifecycle event in durandal, which I think is the last event in the loading cycle of a durandal viewmodel, and then using setTimeout to delay the setting of the property still further.
It's a pretty rubbish workaround but it's working for now.
var viewAttached = function (view) {
var _this = this;
var picker = new jscolor.color($(view).children('.cp')[0], {
onImmediateChange: function() {
_updateCss.call(_this, this.toString());
}
});
picker.fromString(this.color());
setTimeout(function() {
_updateCss.call(_this, _this.color());
}, 1000);
};
var activate = function (data) {
system.log('activated: ' + this.selectors + ' ' + this.color());
};
var deactivate = function (isClose) {
system.log('deactivated, close:' + isClose);
};
return {
viewAttached: viewAttached,
deactivate: deactivate,
activate: activate,
color: this.color
};
I was having a similar issue with timing. On an initial page load, where a partial view was being loaded on the page I could call the viewAttached function and use jQuery to bind some elements within the partial view. The timing worked as expected
However, if I navigated to a new page, and then back to the initial page, the same viewAttached + jQuery method failed to find the elements on the page... they had not yet been attached to the dom.
As best as I have been able to determine (so far) this is related to the transition effects in the entrance.js file. I was using the default transition which causes an object to fade out and a new object to fade in. By eliminating the fadeOutTransition (setting it to zero in entrance.js) I was able to get the viewAttached function to actually be in sync with the partial views attachment.
Best guess is that while the first object is fading out, the incoming object has not yet been attached to the dom but the viewAttached method is triggered anyway.