I create a list of items by means of the foreach binding using Knockout.js. When all the items of the foreach bindings are rendered, I would like to search for an item of a specific class called "focused" and scroll a window to that item.
That will happen only once on page load as I do not add or remove the items.
How can I do it?
You can use property afterRender of foreach binding.
<div data-bind="foreach: { data: items, afterRender: doSomething }">
<div data-bind="text: $data">
</div>
Your viewModel:
var vm = {
doSomethingInvoked: false,
items: ko.observableArray(['apple', 'banan']),
doSomething: function (elements) {
if (!doSomethingInvoked) { // prevent invoking multiple times
var offsetTop = $(elements).filter('.focus').offset().top;
$('html,body').scrollTop(offsetTop);
doSomethingInvoked = true;
}
}
};
Related
I have deferred updates enabled.
I have two components.
The first is a list, which is simply implemented as a div with a foreach data binding:
<div class="list-people" data-bind="foreach: { data: people, afterRender: afterRenderPeople }">
<!-- ko component: { name: "listitem-person", params: { person: $data } } --><!-- /ko -->
</div>
The second is the list item:
<div class="listitem-person">
<span data-bind="text: Name"></span>
</div>
afterRender is called for each item in the foreach.
My afterRenderPerson function is simple enough:
public afterRenderPerson = (elements: any[], data: Person) => {
let top = $(element[0]).offset().top;
scrollTo(top);
};
The problem is that when afterRenderPerson is called the sub-component listitem-person hasn't yet been rendered.
Which means the element array passed to afterRenderPerson has 4 nodes:
A text node containing \n i.e. a new line.
A comment node containing <!-- ko component: { name: "listitem-person", params: { person: $data } } -->.
A comment node containing <!-- /ko -->.
A text node containing \n i.e. a new line.
None of these are suitable for getting the top pixel, and even if they were, the sub-component being rendered could affect the layout at that location changing the value of the pixel I'm trying to get.
Unfortunately it seems that the documentation for foreach doesn't take in to account the delayed nature of components.
If you need to run some further custom logic on the generated DOM elements, you can use any of the afterRender/afterAdd/beforeRemove/beforeMove/afterMove callbacks described below.
Note: These callbacks are only intended for triggering animations related to changes in a list.
There are two workarounds I've come across, neither of which are great, but that's why they're workarounds and not solutions!
user3297291 gave the suggestion in a comment of making a scrollTo binding that's placed on the child components.
Only workaround I can think of is to define a custom scrollTo binding and include it in the component template... Quite easy to implement, but still feels hacky and makes your inner component harder to reuse. You might also want to track this feature request – user3297291
This would simply be a custom binding that conditionally executes some code based on a value provided to it.
The bindings aren't called until the HTML has been inserted in to the DOM. That's not perfect, as later changes to the DOM could affect the position of the inserted HTML elements, but it should work for many situations.
I wasn't very keen on having to modify the child components though, I preferred a solution when remained encapsulated in the parent component.
The second workaround is to check to see if the child component HTML element exists in the DOM by it's ID. Since I don't know when they will come in to existence this has to be done in some sort of loop.
A while loop isn't suitable as it'll run the check far too often, in a "tight" loop, so instead setTimeout is used.
setTimeout is a horrid hack, and it makes me feel dirty to use it, but it does work for this situation.
private _scrollToOffset = -100;
private _detectScrollToDelayInMS = 200;
private _detectScrollToCountMax = 40;
private _detectScrollToCount = 0;
private _detectScrollTo = (scrollToContainerSelector: string, scrollToChildSelector: string) => {
//AJ: If we've tried too many times then give up.
if (this._detectScrollToCount >= this._detectScrollToCountMax)
return;
setTimeout(() => {
let foundElements = $(scrollToChildSelector);
if (foundElements.length > 0) {
//AJ: Scroll to it
$(scrollToContainerSelector).animate({ scrollTop: foundElements.offset().top + this._scrollToOffset });
//AJ: Give it a highlight
foundElements.addClass("highlight");
} else {
//AJ: Try again
this._detectScrollTo(scrollToContainerSelector, scrollToChildSelector);
}
}, this._detectScrollToDelayInMS);
this._detectScrollToCount++;
};
I made sure to put a limit on how long it can run for, so if something goes wrong it won't loop forever.
It should probably be noted that there is an "Ultimate" solution to this problem, and that's TKO, AKA Knockout 4.
But that's not "production ready" yet.
How to know when a component has finished updating DOM?
brianmhunt commented on Jun 20
knockout/tko (ko 4 candidate) latest master branch has this.
More specifically, the applyBindings family of functions now return a Promise that resolves when sub-children (including asynchronous ones) are bound.
The API isn't set or documented yet, but the bones have been set up.
This appears to work. I made a binding handler that runs a callback in its init (it uses tasks.schedule to allow a rendering cycle). Attaching it at the parent level does not get the children rendered in time, but attaching it to the virtual element does.
I designed it to work with a function whose signature is like afterRender. Because it runs for each of the elements, the callback function has to test that the data is for the first one of them.
ko.options.deferUpdates = true;
ko.bindingHandlers.notify = {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
// Make it asynchronous, to allow Knockout to render the child component
ko.tasks.schedule(() => {
const onMounted = valueAccessor().onMounted;
const data = valueAccessor().data;
const elements = [];
// Collect the real DOM nodes (ones with a tagName)
for(let child=ko.virtualElements.firstChild(element);
child;
child=ko.virtualElements.nextSibling(child)) {
if (child.tagName) { elements.push(child); }
}
onMounted(elements, data);
});
}
};
ko.virtualElements.allowedBindings.notify = true;
function ParentVM(params) {
this.people = params.people;
this.afterRenderPeople = (elements, data) => {
console.log("Elements:", elements.map(e => e.tagName));
if (data === this.people[0]) {
console.log("Scroll to", elements[0].outerHTML);
//let top = $(element[0]).offset().top;
//scrollTo(top);
}
};
}
ko.components.register('parent-component', {
viewModel: ParentVM,
template: {
element: 'parent-template'
}
});
function ChildVM(params) {
this.Name = params.person;
}
ko.components.register('listitem-person', {
viewModel: ChildVM,
template: {
element: 'child-template'
}
});
vm = {
names: ['One', 'Two', 'Three']
};
ko.applyBindings(vm);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>
<template id="parent-template">
<div class="list-people" data-bind="foreach: people">
<!-- ko component: { name: "listitem-person", params: { person: $data } }, notify: {onMounted: $parent.afterRenderPeople, data: $data} -->
<!-- /ko -->
</div>
</template>
<template id="child-template">
<div class="listitem-person">
<span data-bind="text: Name"></span>
</div>
</template>
<parent-component params="{ people: names }">
</parent-component>
I'm using Rivets.js to draw some components within an rv-each-* block
They all render fine when the page loads.
When I Splice the first element of the model, the model correctly removes the first element but the rv-each-* block removes the last element.
The component is just made up of a button that contains the text of the objects value it is bound to. Next to each component call I print some text that is also bound to each objects value. The two should be the same, but after the Splice they are not.
var model2 = [{ "value":"1"},{"value":"2"},{"value":"3"}];
rivets.components['control'] = {
template: function() {
return '<button>{data.option.value}</button>';
},
initialize: function(el, attributes) {
return new controlviewmodel(attributes);
}
};
function doSplice()
{
model2.splice(0,1);
}
<table id="view">
<tr rv-each-option="model">
<td>
<control option="option"/>
</td>
<td>{option.value}</td>
</tr>
</table>
I've created a fiddle to try and show my problem: http://jsfiddle.net/dko9b9k4/
Am I doing something wrong or is this a bug?
Edit: this example can be fixed by changing
return new controlviewmodel(attributes);
to return attributes;
I want to implement Master / Detail Functionality in kogrid. So that when I select any field of first grid table all the other details will be shown in next detail grid.
Can anybody have any hint how to implement this.
A create a master/detail grid set you need to use a ko.computed to select/filter the detail.
You need 3 observable arrays and 1 computed.
var model = {
parent: ko.observableArray(),
details: ko.observableArray(),
selectedParents: ko.observableArray()
};
model.selectedDetails = ko.computed( function() {
var selectedParent = this.selectedParents()[0];
if ( !selectedParent ) {
return [];
}
return this.details().filter( function(item) {
// define filter comparison here
} );
}, model );
In your markup, define 2 koGrids
<div data-bind="koGrid: {
data: parent,
multiSelect: false,
selectedItems: selectParents }"></div>
<div data-bind="koGrid: { data: selectedDetails } "></div>
koGrid will populate the selectedItems observableArray with Zero or One item. The computed will execute a filter over the children which the second grid is bound to.
I have omitted the columnDef data bind part from breivity.
If you have an AJAX query to run for the details array, then use a subscription against the "parent" observable, change selectDetails to a straight observableArray and set its contents in the AJAX callback
See this jsFiddle for a basic example
I have some problems with nested view models in knockout using the mapping plugin. I'm able to recreate the problem, and I have created a fiddle for it here: Fiddle
I have stripped down the actual view and viewmodel, so don't expect the output to look nice, but it will get the message accros. This is my view:
<div data-bind="foreach: $root.selectedArmy().Units">
<div class="unitoverview">
<!-- ko foreach: UnitMembers-->
<div class="member">
<div>
<span class="name" data-bind="text: Name, click: $parent.RemoveTest"></span>
</div>
<div data-bind="foreach: test">
<span data-bind="text:$data, click: $parent.RemoveTest"></span>
</div>
<h1 data-bind="text: test2"></h1>
</div>
<!-- /ko -->
</div>
</div>
<span data-bind="click:AddUnit">CLICK TO ADD UNIT</span>
And this is my model:
var armymaker = armymaker || {};
var unitMapping = {
'UnitMembers': {
create: function (options) {
return new UnitMemberViewModel(options.data);
}
}
};
var UnitViewModel = function (unit) {
var self = this;
self.Name = ko.observable("unitname");
self.UnitDefinitionId = ko.observable(unit.Id);
ko.mapping.fromJS(unit, {}, self);
};
var UnitMemberViewModel = function (unitmemberdefinition) {
var self = this;
self.test = ko.observableArray([ko.observable('TEST'), ko.observable('TEST2')]);
self.test2 = ko.observable('TEST1');
self.RemoveTest = function () {
self.test.splice(0,1);
self.Name('BUGFACE');
self.test2('OKI!!');
};
ko.mapping.fromJS(unitmemberdefinition, {}, self);
};
var ViewModel = function () {
var self = this;
self.showLoader = ko.observable(false);
self.newArmy = ko.observable({});
self.unitToAdd = ko.observable(null);
self.selectedArmy = ko.observable({ Template: ko.observable(''), Units: ko.observableArray() });
self.AddUnit = function () {
var data = {'Name': 'My name', 'UnitMembers': [
{ 'Name': 'Unitname1' }
] };
self.unitToAdd(new UnitViewModel((ko.mapping.fromJS(data, unitMapping))));
self.selectedArmy().Units.push(self.unitToAdd());
self.unitToAdd(null);
};
};
armymaker.viewmodel = new ViewModel();
ko.applyBindings(armymaker.viewmodel);
What happens is the following:
I click the link CLICK TO ADD UNIT, and that created a UnitViewModel, and for each element in the UnitMember array it will use the UnitMemberViewModel because of the custom binder (unitMapper) that I am using.
This all seems to work fine. However in the innermost view model, I add some field to the datamodel. I have called them test that is an observableArray, and test2 that is an ordinary observable. I have also created a method called RemoveTest that is bound in the view to both the span that represent test2, and the span in the foreach that represent each element of the array test.
However when I invoke the method, the change to the observable is reflected in the view, but no changes to the observableArray is visible in the view. Check the fiddle for details.
Are there any reasons why changes to an obsArray will not be visible in the view, but changes to an ordinary observable will?
I have made some observations:
The click event on the observable does not work, only the click event on the elements on the observableArray.
It seems that self inside the click event does not match the actual viewmodel. If I go self.test.splice(0,1) nothing happens in the view, but self.test.splice only contains one element after that command. However if I traverse the base viewmodel (armymaker.viewmodel.Units()[0].UnitMembers()[0].test) is still contains two elements.
Calling splice on the traversed viewmodel (armymaker.viewmodel.Units()[0].UnitMembers()[0].test.splice(0,1)) removes the element from the view, so it seems in some way that the element referenced by self is not the same object as what is linked inside the view. But then, why does it work for the observable that is not an array?
There is probably a flaw with my model, but I can't see it so I would appreciate some help here.
You are basically "double mapping".
First with
self.unitToAdd(new UnitViewModel((ko.mapping.fromJS(data, unitMapping))));
and the second time inside the UnitViewModel:
ko.mapping.fromJS(unit, {}, self);
where the unit is already an ko.mapping created complete "UnitViewModel", this double mapping leads to all of your problems.
To fix it you just need to remove the first mapping:
self.unitToAdd(new UnitViewModel(data));
self.selectedArmy().Units.push(self.unitToAdd());
self.unitToAdd(null);
and use the mapping option inside the UnitViewModel:
var UnitViewModel = function (unit) {
var self = this;
self.Name = ko.observable("unitname");
self.UnitDefinitionId = ko.observable(unit.Id);
ko.mapping.fromJS(unit, unitMapping, self);
};
Demo JSFiddle.
SideNote to fix the "The click event on the observable does not work" problem you just need to remove the $parent:
<span class="name" data-bind="text: Name, click: RemoveTest"></span>
because you are already in the context of one UnitMemberViewModel.
In a basic table structure, I want to be able to display a set of data from an array of objects one at a time. Clicking on a button or something similar would display the next object in the array.
The trick is, I don't want to use the visible tag and just hide the extra data.
simply you can just specify property that indicate the current element you want to display and index of that element inside your observableArray .. i have made simple demo check it out.
<div id="persons"> <span data-bind="text: selectedPerson().name"></span>
<br/>
<button data-bind="click: showNext" id="btnShowNext">Show Next</button>
<br/>
</div>
//here is the JS code
function ViewModel() {
people = ko.observableArray([{
name: "Bungle"
}, {
name: "George"
}, {
name: "Zippy"
}]);
showNext = function (person) {
selectedIndex(selectedIndex() + 1);
};
selectedIndex = ko.observable(0);
selectedPerson = ko.computed(function () {
return people()[selectedIndex()];
});
}
ko.applyBindings(new ViewModel());
kindly check this jsfiddle
Create observable property for a single object, then when clicking next just set that property to other object and UI will be updated.