diff --git a/horizon/static/horizon/js/horizon.tables.js b/horizon/static/horizon/js/horizon.tables.js index 82c45ff3b..61991fbba 100644 --- a/horizon/static/horizon/js/horizon.tables.js +++ b/horizon/static/horizon/js/horizon.tables.js @@ -218,6 +218,20 @@ horizon.datatables.update_footer_count = function (el, modifier) { $footer.text(footer_text); }; +horizon.datatables.add_no_results_row = function (table) { + // Add a "no results" row if there are no results. + template = horizon.templates.compiled_templates["#empty_row_template"]; + if (!table.find("tbody tr:visible").length && typeof(template) !== "undefined") { + colspan = table.find("th[colspan]").attr('colspan'); + params = {"colspan": colspan}; + table.find("tbody").append(template.render(params)); + } +}; + +horizon.datatables.remove_no_results_row = function (table) { + table.find("tr.empty").remove(); +}; + horizon.datatables.set_table_sorting = function (parent) { // Function to initialize the tablesorter plugin strictly on sortable columns. $(parent).find("table.datatable").each(function () { @@ -252,7 +266,7 @@ horizon.datatables.add_table_checkboxes = function(parent) { }); }; -horizon.datatables.set_table_filter = function (parent) { +horizon.datatables.set_table_query_filter = function (parent) { $(parent).find('table').each(function (index, elm) { var input = $($(elm).find('div.table_search input')), table_selector; @@ -280,23 +294,14 @@ horizon.datatables.set_table_filter = function (parent) { 'show': this.show, 'hide': this.hide, onBefore: function () { - // Clear the "no results" row. var table = $(table_selector); - table.find("tr.empty").remove(); + horizon.datatables.remove_no_results_row(table); }, onAfter: function () { var template, table, colspan, params; table = $(table_selector); horizon.datatables.update_footer_count(table); - // Add a "no results" row if there are no results. - template = horizon.templates.compiled_templates["#empty_row_template"]; - if (!$(table_selector + " tbody tr:visible").length && typeof(template) !== "undefined") { - colspan = table.find("th[colspan]").attr('colspan'); - params = {"colspan": colspan}; - table.find("tbody").append(template.render(params)); - } - // Update footer count - + horizon.datatables.add_no_results_row(table); }, prepareQuery: function (val) { return new RegExp(val, "i"); @@ -309,6 +314,29 @@ horizon.datatables.set_table_filter = function (parent) { }); }; +horizon.datatables.set_table_fixed_filter = function (parent) { + $(parent).find('table.datatable').each(function (index, elm) { + $(elm).on('click', 'div.table_filter button', function(evt) { + var table = $(elm); + var category = $(this).val(); + evt.preventDefault(); + horizon.datatables.remove_no_results_row(table); + table.find('tbody tr').hide(); + table.find('tbody tr.category-' + category).show(); + horizon.datatables.update_footer_count(table); + horizon.datatables.add_no_results_row(table); + }); + $(elm).find('div.table_filter button').each(function (i, button) { + // Select the first non-empty category + if ($(button).text().indexOf(' (0)') == -1) { + $(button).addClass('active'); + $(button).trigger('click'); + return false; + } + }); + }); +}; + horizon.addInitFunction(function() { horizon.datatables.validate_button(); horizon.datatables.update_footer_count($.find('table.datatable'),0); @@ -341,12 +369,14 @@ horizon.addInitFunction(function() { // Trigger run-once setup scripts for tables. horizon.datatables.add_table_checkboxes($('body')); horizon.datatables.set_table_sorting($('body')); - horizon.datatables.set_table_filter($('body')); + horizon.datatables.set_table_query_filter($('body')); + horizon.datatables.set_table_fixed_filter($('body')); // Also apply on tables in modal views. horizon.modals.addModalInitFunction(horizon.datatables.add_table_checkboxes); horizon.modals.addModalInitFunction(horizon.datatables.set_table_sorting); - horizon.modals.addModalInitFunction(horizon.datatables.set_table_filter); + horizon.modals.addModalInitFunction(horizon.datatables.set_table_query_filter); + horizon.modals.addModalInitFunction(horizon.datatables.set_table_fixed_filter); horizon.datatables.update(); }); diff --git a/horizon/static/horizon/tests/tables.js b/horizon/static/horizon/tests/tables.js index 410c0aaeb..cbccdd905 100644 --- a/horizon/static/horizon/tests/tables.js +++ b/horizon/static/horizon/tests/tables.js @@ -1,29 +1,49 @@ module("Tables (horizon.tables.js)"); +test("Row filtering (fixed)", function () { + var fixture = $("#qunit-fixture"); + var table = fixture.find("#table2"); + + ok(!table.find(".cat").is(":hidden"), "Filtering cats: cats visible by default"); + ok(table.find(":not(.cat)").is(":hidden"), "Filtering cats: non-cats hidden by default"); + + $("#button_cats").trigger("click"); + ok(!table.find(".cat").is(":hidden"), "Filtering cats: cats visible"); + ok(table.find(":not(.cat)").is(":hidden"), "Filtering cats: non-cats hidden"); + + $("#button_dogs").trigger("click"); + ok(!table.find(".dog").is(":hidden"), "Filtering dogs: dogs visible"); + ok(table.find(":not(.dog)").is(":hidden"), "Filtering dogs: non-dogs hidden"); + + $("#button_big").trigger("click"); + ok(!table.find(".big").is(":hidden"), "Filtering big animals: big visible"); + ok(table.find(":not(.big)").is(":hidden"), "Filtering big animals: non-big hidden"); +}); + test("Footer count update", function () { var fixture = $("#qunit-fixture"); - var table = fixture.find("table.datatable"); + var table = fixture.find("#table1"); var tbody = table.find('tbody'); var table_count = table.find("span.table_count"); + var rows = tbody.find('tr'); horizon.datatables.update_footer_count(table); notEqual(table_count.text().indexOf('4 items'), -1, "Initial count is correct"); // hide rows - $("table.datatable tbody tr#dog1").hide(); - $("table.datatable tbody tr#cat2").hide(); + rows.first().hide(); + rows.first().next().hide(); horizon.datatables.update_footer_count(table); notEqual(table_count.text().indexOf('2 items'), -1, "Count correct after hiding two rows"); // show a row - $("table.datatable tbody tr#cat2").show(); + rows.first().next().show(); horizon.datatables.update_footer_count(table); notEqual(table_count.text().indexOf('3 items'), -1, "Count correct after showing one row"); // add rows - $("table.datatable tbody tr#cat2").show(); - $('cat3"').appendTo(tbody); - $('cat4"').appendTo(tbody); + $('cat3"').appendTo(tbody); + $('cat4"').appendTo(tbody); horizon.datatables.update_footer_count(table); notEqual(table_count.text().indexOf('5 items'), -1, "Count correct after adding two rows"); }); diff --git a/horizon/tables/__init__.py b/horizon/tables/__init__.py index 92acef6cc..570a2f8cc 100644 --- a/horizon/tables/__init__.py +++ b/horizon/tables/__init__.py @@ -16,7 +16,7 @@ # Convenience imports for public API components. from .actions import (Action, BatchAction, DeleteAction, - LinkAction, FilterAction) + LinkAction, FilterAction, FixedFilterAction) from .base import DataTable, Column, Row from .views import DataTableView, MultiTableView, MultiTableMixin, \ MixedDataTableView diff --git a/horizon/tables/actions.py b/horizon/tables/actions.py index 635349bf6..e25d065de 100644 --- a/horizon/tables/actions.py +++ b/horizon/tables/actions.py @@ -16,6 +16,7 @@ import logging import new +from collections import defaultdict from django import shortcuts from django.conf import settings @@ -328,6 +329,16 @@ class FilterAction(BaseAction): A string representing the name of the request parameter used for the search term. Default: ``"q"``. + + .. attribute: filter_type + + A string representing the type of this filter. Default: ``"query"``. + + .. attribute: needs_preloading + + If True, the filter function will be called for the initial + GET request with an empty ``filter_string``, regardless of the + value of ``method``. """ # TODO(gabriel): The method for a filter action should be a GET, # but given the form structure of the table that's currently impossible. @@ -336,6 +347,8 @@ class FilterAction(BaseAction): method = "POST" name = "filter" verbose_name = _("Filter") + filter_type = "query" + needs_preloading = False def __init__(self, verbose_name=None, param_name=None): super(FilterAction, self).__init__() @@ -387,6 +400,52 @@ class FilterAction(BaseAction): "implemented by %s." % self.__class__) +class FixedFilterAction(FilterAction): + """ A filter action with fixed buttons. + """ + filter_type = 'fixed' + needs_preloading = True + + def __init__(self, *args, **kwargs): + super(FixedFilterAction, self).__init__(args, kwargs) + self.fixed_buttons = self.get_fixed_buttons() + self.filter_string = '' + + def filter(self, table, images, filter_string): + self.filter_string = filter_string + categories = self.categorize(table, images) + self.categories = defaultdict(list, categories) + for button in self.fixed_buttons: + button['count'] = len(self.categories[button['value']]) + if not filter_string: + return images + return self.categories[filter_string] + + def get_fixed_buttons(self): + """Returns a list of dictionaries describing the fixed buttons + to use for filtering. + + Each list item should be a dict with the keys: + text: Text to display on the button + icon: Icon class for icon element (inserted before text). + value: Value returned when the button is clicked. + This value is passed to ``filter()`` as + ``filter_string``. + """ + raise NotImplementedError("The get_fixed_buttons method has " + "not been implemented by %s." % + self.__class__) + + def categorize(self, table, images): + """Override to separate images into categories. + + Return a dict with a key for the value of each fixed button, + and a value that is a list of images in that category. + """ + raise NotImplementedError("The categorize method has not been " + "implemented by %s." % self.__class__) + + class BatchAction(Action): """ A table action which takes batch action on one or more objects. This action should not require user input on a diff --git a/horizon/tables/base.py b/horizon/tables/base.py index 507293c8f..b9c0dc84f 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -950,7 +950,11 @@ class DataTable(object): action = self._meta._filter_action filter_string = self.get_filter_string() request_method = self.request.method - if filter_string and request_method == action.method: + needs_preloading = (not filter_string + and request_method == 'GET' + and action.needs_preloading) + valid_method = (request_method == action.method) + if (filter_string and valid_method) or needs_preloading: if self._meta.mixed_data_type: self._filtered_data = action.data_type_filter(self, self.data, diff --git a/horizon/templates/horizon/common/_data_table_table_actions.html b/horizon/templates/horizon/common/_data_table_table_actions.html index 169406fd2..ba06a3098 100644 --- a/horizon/templates/horizon/common/_data_table_table_actions.html +++ b/horizon/templates/horizon/common/_data_table_table_actions.html @@ -1,9 +1,15 @@
-{% if filter %} - +{% if filter.filter_type == 'fixed' %} +
+ {% for button in filter.fixed_buttons %} + + {% endfor %} +
+{% elif filter.filter_type == 'query' %} + {% endif %} {% for action in table_actions %} {% if action != filter %} diff --git a/horizon/templates/horizon/qunit.html b/horizon/templates/horizon/qunit.html index f1b7d6933..5f0028d74 100644 --- a/horizon/templates/horizon/qunit.html +++ b/horizon/templates/horizon/qunit.html @@ -33,10 +33,10 @@ - - - - + + + + @@ -46,6 +46,28 @@
cat1
dog1
cat2
dog2
cat1
dog1
cat2
dog2
+ + + + + + + + + + + + + + +
+ + + +
cat1
dog1
cat2
dog2
+ Displaying 4 items +
+
diff --git a/openstack_dashboard/dashboards/admin/images/tests.py b/openstack_dashboard/dashboards/admin/images/tests.py index faed64c2d..0a601a4f4 100644 --- a/openstack_dashboard/dashboards/admin/images/tests.py +++ b/openstack_dashboard/dashboards/admin/images/tests.py @@ -48,21 +48,23 @@ class ImagesViewTest(test.BaseAdminViewTests): @test.create_stubs({api.glance: ('image_list_detailed',)}) def test_images_list_get_pagination(self): + images = self.images.list()[:5] + api.glance.image_list_detailed(IsA(http.HttpRequest), marker=None) \ - .AndReturn([self.images.list(), + .AndReturn([images, True]) api.glance.image_list_detailed(IsA(http.HttpRequest), marker=None) \ - .AndReturn([self.images.list()[:2], + .AndReturn([images[:2], True]) api.glance.image_list_detailed(IsA(http.HttpRequest), - marker=self.images.list()[2].id) \ - .AndReturn([self.images.list()[2:4], + marker=images[2].id) \ + .AndReturn([images[2:4], True]) api.glance.image_list_detailed(IsA(http.HttpRequest), - marker=self.images.list()[4].id) \ - .AndReturn([self.images.list()[4:], + marker=images[4].id) \ + .AndReturn([images[4:], True]) self.mox.ReplayAll() @@ -70,7 +72,7 @@ class ImagesViewTest(test.BaseAdminViewTests): res = self.client.get(url) # get all self.assertEqual(len(res.context['images_table'].data), - len(self.images.list())) + len(images)) self.assertTemplateUsed(res, 'admin/images/index.html') page_size = getattr(settings, "API_RESULT_PAGE_SIZE", None) @@ -83,7 +85,7 @@ class ImagesViewTest(test.BaseAdminViewTests): url = "?".join([reverse('horizon:admin:images:index'), "=".join([AdminImagesTable._meta.pagination_param, - self.images.list()[2].id])]) + images[2].id])]) res = self.client.get(url) # get second page (items 2-4) self.assertEqual(len(res.context['images_table'].data), @@ -91,7 +93,7 @@ class ImagesViewTest(test.BaseAdminViewTests): url = "?".join([reverse('horizon:admin:images:index'), "=".join([AdminImagesTable._meta.pagination_param, - self.images.list()[4].id])]) + images[4].id])]) res = self.client.get(url) # get third page (item 5) self.assertEqual(len(res.context['images_table'].data), diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py index 81247578c..b6edb6f89 100644 --- a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tables.py @@ -15,13 +15,16 @@ # under the License. import logging +from collections import defaultdict +from django.conf import settings from django.core.urlresolvers import reverse from django.template import defaultfilters as filters from django.utils.http import urlencode from django.utils.translation import ugettext_lazy as _ from horizon import tables +from horizon.utils.memoized import memoized from openstack_dashboard import api @@ -78,6 +81,52 @@ class EditImage(tables.LinkAction): return False +def filter_tenants(): + return getattr(settings, 'IMAGES_LIST_FILTER_TENANTS', []) + + +@memoized +def filter_tenant_ids(): + return map(lambda ft: ft['tenant'], filter_tenants()) + + +class OwnerFilter(tables.FixedFilterAction): + def get_fixed_buttons(self): + def make_dict(text, tenant, icon): + return dict(text=text, value=tenant, icon=icon) + + buttons = [make_dict('Project', 'project', 'icon-home')] + for button_dict in filter_tenants(): + new_dict = button_dict.copy() + new_dict['value'] = new_dict['tenant'] + buttons.append(new_dict) + buttons.append(make_dict('Shared with Me', 'shared', 'icon-share')) + buttons.append(make_dict('Public', 'public', 'icon-fire')) + return buttons + + def categorize(self, table, images): + user_tenant_id = table.request.user.tenant_id + tenants = defaultdict(list) + for im in images: + categories = get_image_categories(im, user_tenant_id) + for category in categories: + tenants[category].append(im) + return tenants + + +def get_image_categories(im, user_tenant_id): + categories = [] + if im.is_public: + categories.append('public') + if im.owner == user_tenant_id: + categories.append('project') + elif im.owner in filter_tenant_ids(): + categories.append(im.owner) + elif not im.is_public: + categories.append('shared') + return categories + + def get_image_type(image): return getattr(image, "properties", {}).get("image_type", _("Image")) @@ -97,6 +146,15 @@ class UpdateRow(tables.Row): image = api.glance.image_get(request, image_id) return image + def load_cells(self, image=None): + super(UpdateRow, self).load_cells(image) + # Tag the row with the image category for client-side filtering. + image = self.datum + my_tenant_id = self.table.request.user.tenant_id + image_categories = get_image_categories(image, my_tenant_id) + for category in image_categories: + self.classes.append('category-' + category) + class ImagesTable(tables.DataTable): STATUS_CHOICES = ( @@ -133,6 +191,6 @@ class ImagesTable(tables.DataTable): # Hide the image_type column. Done this way so subclasses still get # all the columns by default. columns = ["name", "status", "public", "disk_format"] - table_actions = (CreateImage, DeleteImage,) + table_actions = (OwnerFilter, CreateImage, DeleteImage,) row_actions = (LaunchImage, EditImage, DeleteImage,) pagination_param = "image_marker" diff --git a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py index 24c7670ee..8ec19f60d 100644 --- a/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py +++ b/openstack_dashboard/dashboards/project/images_and_snapshots/images/tests.py @@ -19,13 +19,18 @@ # under the License. from django import http +from django.conf import settings from django.core.urlresolvers import reverse +from django.test.utils import override_settings from mox import IsA +from horizon import tables as horizon_tables from openstack_dashboard import api from openstack_dashboard.test import helpers as test +from . import tables + IMAGES_INDEX_URL = reverse('horizon:project:images_and_snapshots:index') @@ -116,3 +121,48 @@ class ImageViewTests(test.TestCase): " name='public' checked='checked'>", html=True, msg_prefix="The is_public checkbox is not checked") + + +class OwnerFilterTests(test.TestCase): + def setUp(self): + super(OwnerFilterTests, self).setUp() + self.table = self.mox.CreateMock(horizon_tables.DataTable) + self.table.request = self.request + + @override_settings(IMAGES_LIST_FILTER_TENANTS=[{'name': 'Official', + 'tenant': 'officialtenant', + 'icon': 'icon-ok'}]) + def test_filter(self): + self.mox.ReplayAll() + all_images = self.images.list() + table = self.table + self.filter_tenants = settings.IMAGES_LIST_FILTER_TENANTS + + filter_ = tables.OwnerFilter() + + images = filter_.filter(table, all_images, 'project') + self.assertEqual(images, self._expected('project')) + + images = filter_.filter(table, all_images, 'public') + self.assertEqual(images, self._expected('public')) + + images = filter_.filter(table, all_images, 'shared') + self.assertEqual(images, self._expected('shared')) + + images = filter_.filter(table, all_images, 'officialtenant') + self.assertEqual(images, self._expected('officialtenant')) + + def _expected(self, filter_string): + my_tenant_id = self.request.user.tenant_id + images = self.images.list() + special = map(lambda t: t['tenant'], self.filter_tenants) + + if filter_string == 'public': + return filter(lambda im: im.is_public, images) + if filter_string == 'shared': + return filter(lambda im: not im.is_public and + im.owner != my_tenant_id and + im.owner not in special, images) + if filter_string == 'project': + filter_string = my_tenant_id + return filter(lambda im: im.owner == filter_string, images) diff --git a/openstack_dashboard/static/dashboard/less/horizon.less b/openstack_dashboard/static/dashboard/less/horizon.less index a8d463455..de6c11694 100644 --- a/openstack_dashboard/static/dashboard/less/horizon.less +++ b/openstack_dashboard/static/dashboard/less/horizon.less @@ -545,7 +545,7 @@ table form { min-width: 400px; } -.table_actions .table_search { +.table_actions .table_search, .table_actions .table_filter { display: inline-block; } @@ -568,11 +568,20 @@ table form { min-width: 0; } -.table_header .table_actions a, .table_header .table_actions button { +.table_header .table_actions a, .table_header .table_actions > button { display: inline-block; float: none; } +.table_header .table_filter { + vertical-align: bottom; + margin-right: 20px; +} + +.table_header .table_filter i { + vertical-align: middle; +} + .table_actions form { float: right; margin-left: 10px; diff --git a/openstack_dashboard/test/test_data/glance_data.py b/openstack_dashboard/test/test_data/glance_data.py index 6fa67b82d..6dd4372be 100644 --- a/openstack_dashboard/test/test_data/glance_data.py +++ b/openstack_dashboard/test/test_data/glance_data.py @@ -27,19 +27,22 @@ def data(TEST): 'id': 3, 'status': "active", 'owner': TEST.tenant.id, - 'properties': {'image_type': u'snapshot'}} + 'properties': {'image_type': u'snapshot'}, + 'is_public': False} snapshot_dict_no_owner = {'name': u'snapshot 2', 'container_format': u'ami', 'id': 4, 'status': "active", 'owner': None, - 'properties': {'image_type': u'snapshot'}} + 'properties': {'image_type': u'snapshot'}, + 'is_public': False} snapshot_dict_queued = {'name': u'snapshot 2', 'container_format': u'ami', 'id': 5, 'status': "queued", 'owner': TEST.tenant.id, - 'properties': {'image_type': u'snapshot'}} + 'properties': {'image_type': u'snapshot'}, + 'is_public': False} snapshot = Image(ImageManager(None), snapshot_dict) TEST.snapshots.add(snapshot) snapshot = Image(ImageManager(None), snapshot_dict_no_owner) @@ -53,14 +56,16 @@ def data(TEST): 'status': "active", 'owner': TEST.tenant.id, 'container_format': 'novaImage', - 'properties': {'image_type': u'image'}} + 'properties': {'image_type': u'image'}, + 'is_public': True} public_image = Image(ImageManager(None), image_dict) image_dict = {'id': 'a001c047-22f8-47d0-80a1-8ec94a9524fe', 'name': 'private_image', 'status': "active", 'owner': TEST.tenant.id, - 'container_format': 'aki'} + 'container_format': 'aki', + 'is_public': False} private_image = Image(ImageManager(None), image_dict) image_dict = {'id': '278905a6-4b52-4d1e-98f9-8c57bb25ba32', @@ -68,22 +73,45 @@ def data(TEST): 'status': "active", 'owner': TEST.tenant.id, 'container_format': 'novaImage', - 'properties': {'image_type': u'image'}} + 'properties': {'image_type': u'image'}, + 'is_public': True} public_image2 = Image(ImageManager(None), image_dict) image_dict = {'id': '710a1acf-a3e3-41dd-a32d-5d6b6c86ea10', 'name': 'private_image 2', 'status': "active", 'owner': TEST.tenant.id, - 'container_format': 'aki'} + 'container_format': 'aki', + 'is_public': False} private_image2 = Image(ImageManager(None), image_dict) image_dict = {'id': '7cd892fd-5652-40f3-a450-547615680132', 'name': 'private_image 3', 'status': "active", 'owner': TEST.tenant.id, - 'container_format': 'aki'} + 'container_format': 'aki', + 'is_public': False} private_image3 = Image(ImageManager(None), image_dict) + # A shared image. Not public and not local tenant. + image_dict = {'id': 'c8756975-7a3b-4e43-b7f7-433576112849', + 'name': 'shared_image 1', + 'status': "active", + 'owner': 'someothertenant', + 'container_format': 'aki', + 'is_public': False} + shared_image1 = Image(ImageManager(None), image_dict) + + # "Official" image. Public and tenant matches an entry + # in IMAGES_LIST_FILTER_TENANTS. + image_dict = {'id': 'f448704f-0ce5-4d34-8441-11b6581c6619', + 'name': 'official_image 1', + 'status': "active", + 'owner': 'officialtenant', + 'container_format': 'aki', + 'is_public': True} + official_image1 = Image(ImageManager(None), image_dict) + TEST.images.add(public_image, private_image, - public_image2, private_image2, private_image3) + public_image2, private_image2, private_image3, + shared_image1, official_image1)