From c219a3efc6622c6e5857577d94633efc7d139860 Mon Sep 17 00:00:00 2001 From: Diana Whitten Date: Tue, 9 Feb 2016 11:20:38 -0700 Subject: [PATCH] Horizon Spinner/Loader should inherit from theme The Horizon spinner was using a spinner generated and animated entirely out of JavaScript. Since CSS3 provides animates and we have access to icon fonts, doing everything with JavaScript is not necessary and actually taxing on the browser. Plus, all of the spinner options were being passed in and around with JavaScript, including the colors. This makes it supremely difficult to use the theme to style the spinner. The new spinner is just defined by a handful of templates now. There are two clientside templates to support Legacy Horizon, and one template in the Angular to support spinners going forward. Legacy Horizon had two forms of spinners, so it was broken up. Angular as not yet made use of the inline spinner, but should follow the same markup when it is made. There are two types of spinners, inline spinners (those shown when a dynamic tab content is loading) and modal spinners (various other places). These are consistent with each other for the 'default' experience, but their experience can be entirely customized separate from each other. 'material' has been augmented with loaders defined within their design spec to show the power of this new feature. horizon.templates.js was augmented with this refactor to support only having to compile one tempalte at a time (instead of all of them) and caching that template so that all of them can be recompiled later. Also, horizon.loader.js was added to house template compilation code that was repeated in several locations. To test overwriting page modal spinner and inline-modal spinner examples, please follow the instructions in _loading_inline_exmaple.html, _loading_modal_example.html under openstack_dashboard/themes/material/templates/horizon/client_side Change-Id: I92bc786160e070d30691eeabd4f2a50d6e2bb395 Partially-implements: blueprint horizon-theme-css-reorg Partially-Implements: blueprint bootstrap-html-standards Closes-bug: #1570485 --- horizon/static/framework/conf/conf.js | 30 -------- .../modal-wait-spinner.directive.js | 22 ++---- .../modal-wait-spinner.scss | 22 ------ .../modal-wait-spinner.service.js | 28 ++++---- .../modal-wait-spinner.spec.js | 60 +++++++++++----- .../modal-wait-spinner.template.html | 5 ++ horizon/static/framework/widgets/widgets.scss | 1 - .../static/horizon/js/horizon.d3linechart.js | 27 ++----- horizon/static/horizon/js/horizon.loader.js | 19 +++++ horizon/static/horizon/js/horizon.modals.js | 12 ++-- horizon/static/horizon/js/horizon.tabs.js | 28 ++++---- .../static/horizon/js/horizon.templates.js | 38 ++++++++-- .../horizon/client_side/_loading_inline.html | 11 +++ .../{_loading.html => _loading_modal.html} | 5 +- .../horizon/client_side/templates.html | 3 +- horizon/test/templates/base.html | 30 -------- openstack_dashboard/karma.conf.js | 2 - openstack_dashboard/static/app/app.module.js | 4 -- .../static/app/redirect.controller.spec.js | 22 +++++- .../dashboard/scss/_bootstrap_helpers.scss | 19 +++++ .../dashboard/scss/components/_loader.scss | 22 +++--- .../dashboard/scss/components/_modals.scss | 3 +- .../static/js/horizon.flatnetworktopology.js | 7 +- .../static/js/horizon.networktopology.js | 40 +++-------- .../js/horizon.networktopologycommon.js | 5 ++ .../templates/horizon/_scripts.html | 2 +- .../test/integration_tests/basewebobject.py | 2 +- .../bootstrap/_variable_customizations.scss | 2 + .../material/static/horizon/_styles.scss | 3 + .../static/horizon/components/_hamburger.scss | 16 ----- .../components/_loader_circular_example.scss | 70 +++++++++++++++++++ .../components/_loader_line_example.scss | 65 +++++++++++++++++ .../horizon/components/_loader_spinner.scss | 14 ++++ .../client_side/_loading_inline_example.html | 27 +++++++ .../client_side/_loading_modal_example.html | 24 +++++++ openstack_dashboard/utils/settings.py | 1 - 36 files changed, 439 insertions(+), 252 deletions(-) delete mode 100644 horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.scss create mode 100644 horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.template.html create mode 100644 horizon/static/horizon/js/horizon.loader.js create mode 100644 horizon/templates/horizon/client_side/_loading_inline.html rename horizon/templates/horizon/client_side/{_loading.html => _loading_modal.html} (64%) create mode 100644 openstack_dashboard/themes/material/static/horizon/components/_loader_circular_example.scss create mode 100644 openstack_dashboard/themes/material/static/horizon/components/_loader_line_example.scss create mode 100644 openstack_dashboard/themes/material/static/horizon/components/_loader_spinner.scss create mode 100644 openstack_dashboard/themes/material/templates/horizon/client_side/_loading_inline_example.html create mode 100644 openstack_dashboard/themes/material/templates/horizon/client_side/_loading_modal_example.html diff --git a/horizon/static/framework/conf/conf.js b/horizon/static/framework/conf/conf.js index fbdb9f4fea..645f5782d1 100644 --- a/horizon/static/framework/conf/conf.js +++ b/horizon/static/framework/conf/conf.js @@ -17,36 +17,6 @@ angular .module('horizon.framework.conf', []) - .constant('horizon.framework.conf.spinner_options', { - inline: { - lines: 10, - length: 5, - width: 2, - radius: 3, - color: '#000', - speed: 0.8, - trail: 50, - zIndex: 100 - }, - modal: { - lines: 10, - length: 15, - width: 4, - radius: 10, - color: '#000', - speed: 0.8, - trail: 50 - }, - line_chart: { - lines: 10, - length: 15, - width: 4, - radius: 11, - color: '#000', - speed: 0.8, - trail: 50 - } - }) .value('horizon.framework.conf.toastOptions', { 'delay': 3000, 'dimissible': ['alert-success', 'alert-info'] diff --git a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.directive.js b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.directive.js index b3eeb9ca7e..f3ccac0227 100644 --- a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.directive.js +++ b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.directive.js @@ -54,32 +54,18 @@ .module('horizon.framework.widgets.modal-wait-spinner') .directive('waitSpinner', waitSpinner); - waitSpinner.$inject = ['horizon.framework.conf.spinner_options']; + waitSpinner.$inject = ['horizon.framework.widgets.basePath']; - function waitSpinner(spinnerOptions) { + function waitSpinner(basePath) { var directive = { scope: { text: '@text' // One-direction binding (reads from parent) }, - restrict: 'A', - link: link, - template: '

{$text$}…

' + templateUrl: basePath + 'modal-wait-spinner/modal-wait-spinner.template.html', + restrict: 'A' }; return directive; - - //////////////////// - - /* - * At the time link is executed, element may not have been sized by the browser. - * Spin.js may mistakenly places the spinner at 50% of 0 (left:0, top:0). To work around - * this, explicitly set 50% left and top to center the spinner in the parent - * container - */ - function link(scope, element) { - element.spin(spinnerOptions.modal); - element.find('.spinner').css({'left': '50%', 'top': '50%'}); - } } })(); diff --git a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.scss b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.scss deleted file mode 100644 index 93b12b5f0d..0000000000 --- a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.scss +++ /dev/null @@ -1,22 +0,0 @@ -/* - * (c) Copyright 2015 Hewlett-Packard Development Company, L.P. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* - * Disable the Angular Bootstrap slide in animation for wait spinner modals - */ -.modal-wait-spinner.modal.fade .modal-dialog, .modal.in .modal-dialog { - @include translate(0, 0); -} \ No newline at end of file diff --git a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.service.js b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.service.js index 83bd358458..4edcd68fa3 100644 --- a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.service.js +++ b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.service.js @@ -19,20 +19,23 @@ .module('horizon.framework.widgets.modal-wait-spinner') .factory('horizon.framework.widgets.modal-wait-spinner.service', WaitSpinnerService); - WaitSpinnerService.$inject = ['$uibModal']; + WaitSpinnerService.$inject = [ + '$interpolate', + '$templateCache', + 'horizon.framework.widgets.basePath', + '$uibModal' + ]; /* * @ngdoc factory * @name horizon.framework.widgets.modal-wait-spinner.factory:WaitSpinnerService * @description * In order to provide a seamless transition to a Horizon that uses more Angular - * based pages, the service is currently implemented using the existing - * Spin.js library and the corresponding jQuery plugin (jquery.spin.js). This widget - * looks and feels the same as the existing spinner we are familiar with in Horizon. - * Over time, uses of the existing Horizon spinner ( horizon.modals.modal_spinner() ) - * can be phased out, or refactored to use this component. + * based pages, the service is currently implemented using the same markup as the + * client side loader, which is composed of HTML and a spinner Icon Font. */ - function WaitSpinnerService ($uibModal) { + + function WaitSpinnerService ($interpolate, $templateCache, basePath, $uibModal) { var spinner = this; var service = { showModalSpinner: showModalSpinner, @@ -44,15 +47,12 @@ //////////////////// function showModalSpinner(spinnerText) { + var templateUrl = basePath + 'modal-wait-spinner/modal-wait-spinner.template.html'; + var html = $templateCache.get(templateUrl); var modalOptions = { backdrop: 'static', - /* - * Using
for wait-spinner instead of a wait-spinner element - * because the existing Horizon spinner CSS styling expects a div - * for the modal-body - */ - template: '', - windowClass: 'modal-wait-spinner modal_wrapper loading' + template: $interpolate(html)({text: spinnerText}), + windowClass: 'modal-wait-spinner' }; spinner.modalInstance = $uibModal.open(modalOptions); } diff --git a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.spec.js b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.spec.js index aaa432d344..5c9a5e00bc 100644 --- a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.spec.js +++ b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.spec.js @@ -18,12 +18,33 @@ describe('Wait Spinner Tests', function() { - var service; + var service, $scope, $element, markup; + + var expectedTemplateResult = + '\n' + + '\n'; + beforeEach(module('ui.bootstrap')); + beforeEach(module('templates')); beforeEach(module('horizon.framework')); - beforeEach(inject(function($injector) { + beforeEach(inject(function ($injector) { + var $compile = $injector.get('$compile'); + var $templateCache = $injector.get('$templateCache'); + var basePath = $injector.get('horizon.framework.widgets.basePath'); + + $scope = $injector.get('$rootScope').$new(); service = $injector.get('horizon.framework.widgets.modal-wait-spinner.service'); + + markup = $templateCache + .get(basePath + 'modal-wait-spinner/modal-wait-spinner.template.html'); + + $element = angular.element(markup); + $compile($element)($scope); + + $scope.$apply(); })); it('returns the service', function() { @@ -37,17 +58,16 @@ }); it('opens modal with the correct object', inject(function($uibModal) { - var wanted = { backdrop: 'static', - template: '', - windowClass: 'modal-wait-spinner modal_wrapper loading' - }; - spyOn($uibModal, 'open'); - service.showModalSpinner('my text'); - expect($uibModal.open).toHaveBeenCalled(); - expect($uibModal.open.calls.count()).toBe(1); - expect($uibModal.open.calls.argsFor(0)).toEqual([wanted]); - })); + spyOn($uibModal, 'open').and.callThrough(); + service.showModalSpinner('wait'); + $scope.$apply(); + expect($uibModal.open).toHaveBeenCalled(); + expect($uibModal.open.calls.count()).toEqual(1); + expect($uibModal.open.calls.argsFor(0)[0].backdrop).toEqual('static'); + expect($uibModal.open.calls.argsFor(0)[0].template).toEqual(expectedTemplateResult); + expect($uibModal.open.calls.argsFor(0)[0].windowClass).toEqual('modal-wait-spinner'); + })); }); describe('hideModalSpinner', function() { @@ -60,19 +80,20 @@ var modal = {dismiss: function() {}}; spyOn($uibModal, 'open').and.returnValue(modal); service.showModalSpinner('asdf'); + spyOn(modal, 'dismiss'); service.hideModalSpinner(); + expect(modal.dismiss).toHaveBeenCalled(); })); - }); - }); describe('Wait Spinner Directive', function() { var $scope, $element; beforeEach(module('ui.bootstrap')); + beforeEach(module('templates')); beforeEach(module('horizon.framework')); beforeEach(inject(function($injector) { @@ -82,14 +103,17 @@ var markup = '
'; $element = angular.element(markup); $compile($element)($scope); - $scope.$apply(); })); - it("creates a p element", function() { - var elems = $element.find('p'); + it("creates a div element with correct text", function() { + var elems = $element.find('div div'); expect(elems.length).toBe(1); + //The spinner is a nested div with the "text" set according to the attribute + //indexOf is used because the spinner puts … after the text, however + //jasmine does not convert … to the three dots and thinks they don't match + //when compared with toEqual + expect(elems[0].innerText.indexOf('hello!')).toBe(0); }); - }); })(); diff --git a/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.template.html b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.template.html new file mode 100644 index 0000000000..16194e2c65 --- /dev/null +++ b/horizon/static/framework/widgets/modal-wait-spinner/modal-wait-spinner.template.html @@ -0,0 +1,5 @@ + + diff --git a/horizon/static/framework/widgets/widgets.scss b/horizon/static/framework/widgets/widgets.scss index 61fc56f598..e0b34cf97e 100644 --- a/horizon/static/framework/widgets/widgets.scss +++ b/horizon/static/framework/widgets/widgets.scss @@ -3,7 +3,6 @@ @import "charts/chart-tooltip"; @import "charts/pie-chart"; @import "action-list/action-list"; -@import "modal-wait-spinner/modal-wait-spinner"; @import "metadata/metadata"; @import "magic-search/magic-search"; @import "table/hz-dynamic-table"; diff --git a/horizon/static/horizon/js/horizon.d3linechart.js b/horizon/static/horizon/js/horizon.d3linechart.js index f8a953f7ea..b7fbb26afc 100644 --- a/horizon/static/horizon/js/horizon.d3linechart.js +++ b/horizon/static/horizon/js/horizon.d3linechart.js @@ -222,6 +222,7 @@ horizon.d3_line_chart = { self.chart_module = chart_module; self.html_element = html_element; self.jquery_element = jquery_element; + self.$spinner = horizon.loader.inline(gettext('Loading')).hide().appendTo(jquery_element); /************************************************************************/ /*********************** Initialization methods *************************/ @@ -437,8 +438,8 @@ horizon.d3_line_chart = { self.refresh = function (){ var self = this; + self.start_loading(); if (typeof self.data === 'string') { - self.start_loading(); horizon.ajax.queue({ url: self.final_url, success: function (data) { @@ -453,6 +454,7 @@ horizon.d3_line_chart = { }); } else if (self.data) { self.load_data(self.data); + self.finish_loading(); } else { self.error_message(gettext('No data available.')); } @@ -620,34 +622,17 @@ horizon.d3_line_chart = { self.start_loading = function () { var self = this; - /* Find and remove backdrops and spinners that could be already there.*/ - $(self.html_element).find('.modal-backdrop').remove(); - $(self.html_element).find('.spinner_wrapper').remove(); - - // Display the backdrop that will be over the chart. - self.backdrop = $(''); - self.backdrop.css('width', self.width).css('height', self.height); - $(self.html_element).append(self.backdrop); + $(self.html_element).addClass('has-spinner'); + self.$spinner.show(); // Hide the legend. $(self.legend_element).empty().addClass('disabled'); - // Show the spinner. - self.spinner = $('
'); - $(self.html_element).append(self.spinner); /* TODO(lsmola) a loader for in-line tables spark-lines has to be prepared, the parameters of loader could be sent in settings. */ - self.spinner.spin(horizon.conf.spinner_options.line_chart); - // Center the spinner considering the size of the spinner. - var radius = horizon.conf.spinner_options.line_chart.radius; - var length = horizon.conf.spinner_options.line_chart.length; - var spinner_size = radius + length; - var top = (self.height / 2) - spinner_size / 2; - var left = (self.width / 2) - spinner_size / 2; - self.spinner.css('top', top).css('left', left); }; /** @@ -658,6 +643,8 @@ horizon.d3_line_chart = { var self = this; // Showing the legend. $(self.legend_element).removeClass('disabled'); + $(self.html_element).removeClass('has-spinner'); + self.$spinner.hide(); }; }, diff --git a/horizon/static/horizon/js/horizon.loader.js b/horizon/static/horizon/js/horizon.loader.js new file mode 100644 index 0000000000..41b5f6ea10 --- /dev/null +++ b/horizon/static/horizon/js/horizon.loader.js @@ -0,0 +1,19 @@ +/* + Simple loader rendering logic + */ + +horizon.loader = { + templates: { + inline: '#loader-inline', + modal: '#loader-modal' + } +}; + +horizon.loader.inline = function(text) { + return horizon.templates.compile(horizon.loader.templates.inline, {text: text}); +}; + +horizon.loader.modal = function(text) { + return horizon.templates.compile(horizon.loader.templates.modal, {text: text}); +}; + diff --git a/horizon/static/horizon/js/horizon.modals.js b/horizon/static/horizon/js/horizon.modals.js index b756a18766..385080f197 100644 --- a/horizon/static/horizon/js/horizon.modals.js +++ b/horizon/static/horizon/js/horizon.modals.js @@ -67,13 +67,15 @@ horizon.modals.success = function (data) { return modal; }; -horizon.modals.modal_spinner = function (text) { +horizon.modals.modal_spinner = function (text, $container) { + if (!$container) { + $container = $('#modal_wrapper'); + } + // Adds a spinner with the desired text in a modal window. - var template = horizon.templates.compiled_templates["#spinner-modal"]; - horizon.modals.spinner = $(template.render({text: text})); - horizon.modals.spinner.appendTo("#modal_wrapper"); + horizon.modals.spinner = horizon.loader.modal(text); + horizon.modals.spinner.appendTo($container); horizon.modals.spinner.modal({backdrop: 'static'}); - horizon.modals.spinner.find(".modal-body").spin(horizon.conf.spinner_options.modal); }; horizon.modals.progress_bar = function (text) { diff --git a/horizon/static/horizon/js/horizon.tabs.js b/horizon/static/horizon/js/horizon.tabs.js index 9ed4558c1a..8b86551ad0 100644 --- a/horizon/static/horizon/js/horizon.tabs.js +++ b/horizon/static/horizon/js/horizon.tabs.js @@ -20,32 +20,34 @@ horizon.tabs.addTabLoadFunction = function (f) { horizon.tabs._init_load_functions.push(f); }; -horizon.tabs.initTabLoad = function (tab) { +horizon.tabs.initTabLoad = function ($tab) { + $tab.removeClass('tab-loading'); $(horizon.tabs._init_load_functions).each(function (index, f) { - f(tab); + f($tab); }); - recompileAngularContent($(tab)); + recompileAngularContent($tab); }; horizon.tabs.load_tab = function () { var $this = $(this), tab_id = $this.attr('data-target'), - tab_pane = $(tab_id); + $tab_pane = $(tab_id); - // FIXME(gabriel): This style mucking shouldn't be in the javascript. - tab_pane.append("" + gettext("Loading") + "…"); - tab_pane.spin(horizon.conf.spinner_options.inline); - $(tab_pane.data().spinner.el).css('top', '9px'); - $(tab_pane.data().spinner.el).css('left', '15px'); + // Set up the client side template to append + var $template = horizon.loader.inline(gettext('Loading')); + + $tab_pane + .append($template) + .addClass('tab-loading'); // If query params exist, append tab id. if(window.location.search.length > 0) { - tab_pane.load(window.location.search + "&tab=" + tab_id.replace('#', ''), function() { - horizon.tabs.initTabLoad(tab_pane); + $tab_pane.load(window.location.search + "&tab=" + tab_id.replace('#', ''), function() { + horizon.tabs.initTabLoad($tab_pane); }); } else { - tab_pane.load("?tab=" + tab_id.replace('#', ''), function() { - horizon.tabs.initTabLoad(tab_pane); + $tab_pane.load("?tab=" + tab_id.replace('#', ''), function() { + horizon.tabs.initTabLoad($tab_pane); }); } $this.attr("data-loaded", "true"); diff --git a/horizon/static/horizon/js/horizon.templates.js b/horizon/static/horizon/js/horizon.templates.js index 3098783b6f..dc8d39aa64 100644 --- a/horizon/static/horizon/js/horizon.templates.js +++ b/horizon/static/horizon/js/horizon.templates.js @@ -19,7 +19,8 @@ horizon.templates = { "#modal_template", "#empty_row_template", "#alert_message_template", - "#spinner-modal", + "#loader-modal", + "#loader-inline", "#membership_template", "#confirm_modal", "#progress-modal" @@ -28,10 +29,37 @@ horizon.templates = { }; /* Pre-loads and compiles the client-side templates. */ -horizon.templates.compile_templates = function () { - $.each(horizon.templates.template_ids, function (ind, template_id) { - horizon.templates.compiled_templates[template_id] = Hogan.compile($(template_id).html()); - }); +horizon.templates.compile_templates = function (id) { + + // If an id is passed in, only compile that template + if (id) { + horizon.templates.compiled_templates[id] = Hogan.compile($(id).html()); + } else { + // If its never been set, make it an empty object + horizon.templates.compiled_templates = + $.isEmptyObject(horizon.templates.compiled_templates) ? {} : horizon.templates.compiled_templates; + + // Over each template found, only recompile ones that need it + $.each(horizon.templates.template_ids, function (ind, template_id) { + if (!(template_id in horizon.templates.compiled_templates)) { + horizon.templates.compiled_templates[template_id] = Hogan.compile($(template_id).html()); + } + }); + } +}; + +/* Takes a template id, as defined in horizon.templates.template_ids, and returns the compiled + template given the context passed in, as a jQuery object + */ +horizon.templates.compile = function(id, context) { + var template = horizon.templates.compiled_templates[id]; + + // If its not available, maybe we didn't compile it yet, try one more time + if (!template) { + horizon.templates.compile_templates(id); + template = horizon.templates.compiled_templates[id]; + } + return $(template.render(context)); }; horizon.addInitFunction(horizon.templates.init = function () { diff --git a/horizon/templates/horizon/client_side/_loading_inline.html b/horizon/templates/horizon/client_side/_loading_inline.html new file mode 100644 index 0000000000..3af3421f4f --- /dev/null +++ b/horizon/templates/horizon/client_side/_loading_inline.html @@ -0,0 +1,11 @@ +{% extends "horizon/client_side/template.html" %} +{% load i18n horizon %} + +{% block id %}loader-inline{% endblock %} + +{% block template %}{% spaceless %}{% jstemplate %} +
+ +
[[text]]…
+
+{% endjstemplate %}{% endspaceless %}{% endblock %} diff --git a/horizon/templates/horizon/client_side/_loading.html b/horizon/templates/horizon/client_side/_loading_modal.html similarity index 64% rename from horizon/templates/horizon/client_side/_loading.html rename to horizon/templates/horizon/client_side/_loading_modal.html index 92d98088f6..89a1afdaae 100644 --- a/horizon/templates/horizon/client_side/_loading.html +++ b/horizon/templates/horizon/client_side/_loading_modal.html @@ -1,14 +1,15 @@ {% extends "horizon/client_side/template.html" %} {% load i18n horizon %} -{% block id %}spinner-modal{% endblock %} +{% block id %}loader-modal{% endblock %} {% block template %}{% spaceless %}{% jstemplate %}