I am using Knockout.js to populate a set of HTML5 <details> elements. Here is the structure:
<div class="items" data-bind="foreach: Playlists">
<details class="playlist-details" data-bind="attr: {id: 'playlist-details-' + $index()}">
<summary>
<span data-bind="text: name"></span> - <span data-bind="text: count"></span> item(s)
<div class="pull-right">
<button data-bind="click: $parent.play, css: {disabled: count() == 0}, attr: {title: playbtn_title}" class="btn"><i class="icon-play"></i> Play</button>
<button data-bind="click: $parent.deleteList" class="btn btn-danger"><i class="icon-trash"></i> Delete</button>
</div>
</summary>
<div class="list" data-bind="with: items" style="padding-top: 2px;">
...
</div>
</details>
</div>
The data in the ViewModel looks something like this:
var VM = {
Playlists: [
{
name: "My Playlist1",
count: 3,
items: [<LIST OF SONG ID'S>],
playbtn_title: "Play this playlist"
},
{
name: "My Playlist2",
count: 5,
items: [<LIST OF SONG ID'S>],
playbtn_title: "Play this playlist"
},
{
name: "My Playlist3",
count: 0,
items: [],
playbtn_title: "You need to add items to this list before you can play it!"
}
]
};
I want to add the ability to remember the open or closed state of the details view. I have implemented this behavior previously using jQuery and localStorage1, but for this project I want to use Knockout natively instead of using jQuery.
I have added an isOpen property to the playlists in the ViewModel which is retrieved from localStorage when the page loads. However, it seems that I can't use the attr binding in Knockout because the HTML5 spec says to look only for the presence or absence of the open attribute, not for a value.
How would I get Knockout to add and remove the open property of the <details> element as the isOpen property of the ViewModel changes?
1: Like this:
// On the initial page load.
contents += '<details ' + ((localStorage['tl_open_playlist-details-' + counter] == 1) ? 'open' : '') ' class="playlist-details" id="playlist-details-' + counter + '" data-name="' + escape(listname) + '">'
...
// Update storage when things are clicked.
$(document).on('DOMSubtreeModified', 'details.playlist-details', function() {
if ($(this).prop('open')) {
localStorage['tl_open_' + this.id] = 1;
} else {
delete localStorage['tl_open_' + this.id];
}
});
You can modify the attr binding to take into account another binding option (named attrRemoveWhenFalse here) and remove the attribute for you:
<input class='testInput' type="text"
data-bind="attr: { disabled: isDisabled }, attrRemoveWhenFalse: true" />
var originalAttr = { init: ko.bindingHandlers.attr.init,
update: ko.bindingHandlers.attr.update }
ko.bindingHandlers.attr.update = function (element, valueAccessor,
allBindingAccessor, viewModel,
bindingContext) {
if (typeof originalAttr.update === 'function')
originalAttr.update(element, valueAccessor, allBindingAccessor,
viewModel, bindingContext);
if (allBindingAccessor().attrRemoveWhenFalse) {
for (var prop in valueAccessor()) {
if (!ko.utils.unwrapObservable(valueAccessor()[prop])) {
element.removeAttribute(prop);
}
}
}
}
Demo
Related
I used the foreach method to create markup foreach item in an observable array to create a treeview.
output example
category name1
content
content
category name 2
content
content
when I click on the category name I want just its content to show/hide, currently when I click on the category name it shows and hides all the categories.
var reportFilters = [
{ Text: "Campaign", Value: primaryCategories.Campaign },
{ Text: "Team", Value: primaryCategories.Team },
{ Text: "Agent", Value: primaryCategories.Agent },
{ Text: "List", Value: primaryCategories.List },
{ Text: "Inbound", Value: primaryCategories.Inbound },
{ Text: "Daily", Value: primaryCategories.Daily },
{ Text: "Services", Value: primaryCategories.Services },
{ Text: "Occupancy", Value: primaryCategories.Occupancy },
{ Text: "Data", Value: primaryCategories.Data }
];
self.showCategory = ko.observable(false);
self.toggleVisibility = function (report) {
var categoryName = report.PrimaryReportCategory;
var categoryContent = report.ID;
if (categoryName == categoryContent ) {
self.showCategory(!self.showCategory());
};
}
<div class="report-category-treeview" data-bind="foreach: $root.categories, mCustomScrollBar:true">
<ul class="column-list" >
<li class="report-category-heading" data-bind="click: $root.toggleVisibility"><span class="margin-top10" ><i class="fas fa-chevron-down"></i> <span class="report-category-name" data-bind="text: categoryName"></span></span></li>
<li id="panel" class="report-category-container" data-bind="foreach: reports, visible: $root.showCategory">
<div class="column-list-item" data-bind="click: $root.report_click, css: { 'selected': typeof $root.selectedReport() != 'undefined' && $data == $root.selectedReport() }">
<span class="column-list-text" data-bind="text: ReportName"></span>
</div>
</li>
</ul>
</div>
currently, when I click on the category name, it shows and hides all the
categories.
It's because showCategory is your single observable responsible for showing\hiding. What you really want is one show\hide observable per category.
I'm not sure how your entire data model looks like, but since you specifically asked about categories, then you should create a category view model, and probably some container view model, which I'll name here master:
var categoryVM = function (name) {
var self = this;
self.name = ko.observable(name);
self.isVisible = ko.observable(false);
self.toggleVisibility = function () {
self.isVisible(!self.isVisible());
}
// ... add here your other observables ...
}
// name 'masterVM' whatever you like
var masterVM = function () {
var self = this;
self.categories = ko.observables([]);
// ... probably add here other observables, e.g. 'reports' ...
self.init = function (rawCategories) {
rawCategories.forEach(function (item) {
categories.push(new categoryVM(item.name)); // replace 'name' with your property
}
}
}
var master = new masterVM();
master.init(getCategories()); // pass in your categories from wherever they come from
ko.applyBindings(master);
Then, in your html, this would be your outer foreach:
<div class="report-category-treeview" data-bind="foreach: categories ... />
and your lis (for brevity, I'm ommiting nested tags under your lis):
<li class="report-category-heading"
data-bind="click: toggleVisibility">
<li id="panel" class="report-category-container"
data-bind="foreach: $root.reports, visible: isVisible">
I have a problem when implementing a nested list in Angular: the view gets updated properly but, on the other side, the code is not updated on change.
I think it will be much clearer with the code:
_this.categories = injections.map(function (category) {
return {
title: category.get('title'),
object: category,
criteria: category._criteria.map(function (oneCriteria) {
return {
object: oneCriteria,
type: oneCriteria.get("type"),
min: _this.range(oneCriteria.get("range")).min,
max: _this.range(oneCriteria.get("range")).max,
key: oneCriteria.get("key"),
value: _this.range(oneCriteria.get("range")).min,
defaultValue: _this.range(oneCriteria.get("range")).min,
selected: false
}
})
}
});
_this.category = _this.categories[0];
_this.job = {
title: '',
description: '',
salaryAmount: 0,
salaryTimeUnit: _this.salaryTimeUnits[0],
category: _this.category.object,
criteria: _this.category.criteria,
location: {latitude: 48.137004, longitude: 11.575928}
};
So and, very quick here is my HTML:
<div ng-repeat="category in controller.categories">
<input type="radio" name="group" ng-value="category.object.get('title')" id="{{category.object.get('title')}}"
ng-checked="controller.category == category" ng-click="controller.category = category">
{{category.title}}
</div>
<br>
Criteria:
<div ng-repeat="criterium in controller.category.criteria">
<div class="row vertical-align">
<div class="col-xs-9">
<span ng-click="criterium.selected = !criterium.selected"
ng-class="['list-group-item', {active:criterium.selected == true}]">{{criterium.key}}</span>
</div>
</div>
</div>
The problem is the following: the value are getting updated in the view (when you click on a radio button on the category, you see the corresponding criteria(s)). But the job is for one reason that I ignore not updated although it has the same reference as the HTML (a reference to this category.criteria).
Did I miss something?
controller.job.criteria is still just a reference to controller.categories[0]. Your code should successfully update controller.category to point at whichever category you clicked on, but that does not also update the reference in your job data structure.
What you want to do is make your ngClick event a bit more robust, i.e.:
<input type="radio" ng-click="controller.updateCategory(category)" />
and then in your js:
_this.updateCategory = function (category) {
_this.category = category;
_this.updateJob(category);
};
_this.updateJob = function (category) {
_this.job.category = category.object;
_this.job.criteria = category.criteria;
};
This will update the references in your job to match the new jazz.
I would, however, recommend leveraging ngModel and ngChange in your radios instead. Like:
<input type="radio" ng-model="controller.category" ng-value="category" ng-change="updateJob(category)" /> {{category.title}}
I'm working on a project that need to allow users to add/remove tags on images.
There is grid view, single view and mixed view.
The grid view displays image thumbs in a grid,
Single view displays images one by one
and the mixed view has the grid in the background, and a single images in the front (a zoom in feature).
All those views have a footer which contains the tags that can be applied to an image. However, the grid has its own footer, while the single and mixed views share theirs.
Here is the HTML side code for those :
<section id="noRefContainer" class="photosWrapper photosWrapper-cq" style="display: block"> <!--ng-controller="gridController">-->
<div class="images-cq__wrapper" ng-show="displayStyle.style == 'grid' || displayStyle.style == 'both'">
<div class="images-cq__item" ng-repeat="photo in displayedPhotos">
<div ng-class="{active: photo.selected}">
<label for="{{photo.uuid}}">
<div class="img-cq">
<img ng-src="{{photo.thumbPath100}}" alt="Alternate Text" ng-click="selectionEvent({value: photo.uuid, event: $event, index: $index})" />
zoom
</div>
<p>
{{photo.title}}
</p>
</label>
</div>
</div>
<div class="images-cq__footer open">
<p>
<span>Tagger les</span>
<strong>{{selectedPhotos.length}}</strong>
<span>éléments sélectionnés</span>
</p>
<div class="images-cq__dropdown top">
...
<ul>
<li>Sélectionner toutes les images</li>
<li>Désélectionner toutes les images</li>
</ul>
</div>
<div class="images-cq__tags">
<ul>
<li ng-repeat="tag in tags">
{{tag.name}}
</li>
</ul>
</div>
<small>Attention, ceci effacera les précédents tags.</small>
</div>
</div>
<div ng-class="{'images-cq__lightbox': displayStyle.style == 'both', 'images-cq__wrapper': displayStyle.style == 'single', single: displayStyle.style == 'single'}" ng-show="displayStyle.style == 'both' || displayStyle.style == 'single'">
<div class="images-cq__carousel">
<a href="" class="images-cq__carouselclose" ng-click="zoomClose()" ng-show="displayStyle.style == 'both'">
Close
</a>
<div class="images-cq__carouselcontent" id="carousel">
</div>
<div class="images-cq__carouselfooter">
<div class="images-cq__tags">
<ul>
<li ng-repeat="tag in tags">
{{tag.name}}
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
And the app.js side code :
$scope.tags = [];
$scope.tags = [{ name: 'CQ:OK', count: 0, status: '', value: 'CQ:OK' },
{ name: 'CQ:OK_NO_ZOOM', count: 0, status: '', value: 'CQ:OK_NO_ZOOM' },
{ name: 'CQ:KO', count: 0, status: '', value: 'CQ:KO' },
{ name: 'Chromie', count: 0, status: '', value: 'Chromie' },
{ name: 'Détourer', count: 0, status: '', value: 'Détourer' },
{ name: 'Nettoyer_redresser', count: 0, status: '', value: 'Nettoyer_redresser' },
{ name: 'Interne', count: 0, status: '', value: 'Interne' },
{ name: 'Otsc', count: 0, status: '', value: 'Otsc' }];
$scope.zoomTagSelectionEvent = function (tag) {
var photo = $scope.carousel.settings.images[$scope.carousel.settings.currentImage];
if ($scope.hasTag(photo, tag.value)) {
$scope.removeTag(photo, tag.value);
}
else {
$scope.addTag(photo, tag.value);
}
$scope.updateTagStatus(tag.value);
}
$scope.tagSelectionEvent = function (tag) {
if ($scope.allHaveTag($scope.selectedPhotos, tag.value)) {
$scope.allRemoveTag($scope.selectedPhotos, tag.value);
}
else {
$scope.allAddTag($scope.selectedPhotos, tag.value);
}
$scope.updateTagStatus(tag.value);
}
$scope.updateAllTagStatus = function () {
angular.forEach($scope.tags, function (value, key) {
$scope.updateTagStatus(value.value);
});
}
$scope.updateTagStatus = function (tag) {
var currentTag = $scope.getTag(tag);
if ($scope.displayStyle.style == 'grid')
{
var tagged = $scope.countTagged($scope.selectedPhotos, tag);
if (tagged == 0) {
currentTag.status = 'none';
}
else if (tagged < $scope.selectedPhotos.length) {
currentTag.status = 'selected';
}
else {
currentTag.status = 'active';
}
}
else {
if ($scope.carousel.settings.currentImage !== false)
{
var photo = $scope.carousel.settings.images[$scope.carousel.settings.currentImage];
var res = $scope.hasTag(photo, tag);
if (res) {
currentTag.status = 'active';
}
else {
currentTag.status = 'none';
}
}
}
console.log('tag ' + tag + ' status updated');
}
Each time a tag is applied to an image, the tag status is updated, which should update the ng-class expression result. The only part that gets properly updated is the grid footer. That is shared between single/mixed view updates only when the view is shown.
As for what i tried to fix this, I've tried using $scope.apply() after each call for tag update, tried placing at the end of the updateTagStatus function. I also tried changing the expressions (class names with/without quotes, setting class to the tag status...), which all worked only for the grid footer, but not for the other. I also checked that the statuses were properly updated, the only problem is in the updating of the display.
Please help.
Update :
I am sorry for not answering in here, there was a huge list of evolutions for the project in a short amount of time so the code causing this problem is no more, which also removes the problem altogether. I was busy working on the project and forgot to update this.
However, thank you for taking the time to come here and try to help me.
I am not sure what i should do in a situation such a this.
For the first time it looks like var currentTag = $scope.getTag(tag); this line is the culprit. It might not be returning proper angular object for tag.
Or you can try using $apply function properly.
i.e. user $apply for every update statement like:
$scope.$apply(function () { currentTag.status = 'selected'; });
$scope.$apply(function () { currentTag.status = 'none'; });
So what exactly is the ko.observable() doing? Here's the situation. I have a boolean ko.observable(), as you can see. I have click set to that value, so it SHOULD toggle the value of the true false contained within it's method call.
When I watch the array get populated in the developer tools, I see that selected does not = true or false, it instead = a pretty extensive function, and I can't find the true or false value anywhere inside of that, so I have no idea what exactly is happening when ko.observable() is used
What I expected is for tab.selected to be the value of tabArray[tab].selected, and when the page loads, that is correct. However, after clicking, tabArray[tab].selected = [Object object] when the text value is written out. I attempt to use:
<pre data-bind="text: JSON.stringify(ko.toJS(tab.selected)"></pre>
(found here: http://www.knockmeout.net/2013/06/knockout-debugging-strategies-plugin.html) and that prints out either true or false, do I need to do this for the other places where i need that value? Because I'm not sure exactly what ko.observable is doing.
define(['knockout', 'text!../Content/SSB/PartialViews/MainContent.html'], function (ko, MCTemplate) {
ko.components.register('MainContent', {
template: MCTemplate
});
var MainViewModel = {
tabArray: [
{ name: 'bob', selected: ko.observable(true) },
{ name: 'bib', selected: ko.observable(false) },
{ name: 'bab', selected: ko.observable(false) },
{ name: 'bub', selected: ko.observable(false) },
{ name: 'beb', selected: ko.observable(false) },
]
};
ko.applyBindings(MainViewModel);
return {
viewModel: MainViewModel
}
});
the HTML
<div id="tab">
<ul class="nav nav-tabs" role="tablist">
<!--ko foreach: {data: $parent.tabArray, as: 'tab'}-->
<li data-bind="click: tab.selected, css: { 'active': tab.selected}">
<a data-bind="attr: {href: '#' + tab.name}, text: name"></a>
<div data-bind="text: tab.name"></div>
<div data-bind="text: tab.selected"></div>
</li>
<!--/ko-->
</ul>
<!--ko foreach: {data: $parent.tabArray, as: 'tab'}-->
<div class="ui-tabpanel" role="tabpanel" data-bind="visible: tab.selected">
<p data-bind="text: name"></p>
</div>
<!--/ko-->
</div>
The click binding calls the provided function, passing it the current view model (also called $data). That's why you see [Object object] as the observable's value after the click. Since you want the click to toggle the observable, you need to create a function to do that. A nice, clean way to do this is through a custom binding, which I'll call toggle:
ko.bindingHandlers.toggle = {
init: function(element, valueAccessor) {
ko.utils.registerEventHandler(element, 'click', function () {
var obs = valueAccessor();
obs(!obs());
});
}
};
Now you bind using toggle instead of click: <li data-bind="toggle: tab.selected...
I am using Knockout.js to populate a set of HTML5 <details> elements. Here is the structure:
<div class="items" data-bind="foreach: Playlists">
<details class="playlist-details" data-bind="attr: {id: 'playlist-details-' + $index(), open: isOpen}">
<summary>
<span data-bind="text: name"></span> - <span data-bind="text: count"></span> item(s)
<div class="pull-right">
<button data-bind="click: $parent.play, css: {disabled: count() == 0}, attr: {title: playbtn_title}" class="btn"><i class="icon-play"></i> Play</button>
<button data-bind="click: $parent.deleteList" class="btn btn-danger"><i class="icon-trash"></i> Delete</button>
</div>
</summary>
<div class="list" data-bind="with: items" style="padding-top: 2px;">
...
</div>
</details>
</div>
The data in the ViewModel looks something like this:
var VM = {
Playlists: [
{
name: "My Playlist1",
count: 3,
items: [<LIST OF SONG ID'S>],
playbtn_title: "Play this playlist",
isOpen: true
},
{
name: "My Playlist2",
count: 5,
items: [<LIST OF SONG ID'S>],
playbtn_title: "Play this playlist",
isOpen: null
},
{
name: "My Playlist3",
count: 0,
items: [],
playbtn_title: "You need to add items to this list before you can play it!",
isOpen: null
}
]
};
I have added the ability to remember the open or closed state of the details view using the isOpen property of the ViewModel and an attr binding (As originally described here).
However, when I click the <summary> to expand the details, the ViewModel does not get updated - unlike value bindings, attr bindings aren't two-way.
How can I get this binding to update when the attribute value changes?
I know that the browser triggers a DOMSubtreeModified event when the element is opened or closed, but I;m not sure what I would put there - several things I have tried (including .notifySubscribers(), if (list.open()) ..., etc.) cause looping, where the property being changed makes the event trigger again, which changes the property again, which triggers the event again, etc.
Using $ to play DOM directly is not ko way :-)
Just create a two-way binding for HTML5 details tag, it's cheap in ko.
http://jsfiddle.net/gznf3/
ko.bindingHandlers.disclose = {
init: function(element, valueAccessor) {
if (element.tagName.toLowerCase() !== 'details') {
throw "\"disclose\" binding only works on <details> tag!";
}
var value = valueAccessor();
if (ko.isObservable(value)) {
$(element).on("DOMSubtreeModified", function() {
value($(element).prop('open'));
});
}
},
update: function(element, valueAccessor) {
$(element).prop('open', ko.unwrap(valueAccessor()));
}
};
The way that I found in the end that works is simply to have the DOMSubtreeModified "manually" update the value:
$(document).on('DOMSubtreeModified', 'details.playlist-details', function(e) {
var list = ko.dataFor(this);
list.open(this.getAttribute('open'));
});
(Somehow, this does not cause the looping that more-complex constructs I tried had caused.)