Add draggable components to the 'Components page'

* Add components tiles organized via bootstrap carousel widget to the
  environment page
* Make these components draggable, drop area resides inside the
  components table
* Enhance components list with filter field and categories selector
  which perform components filtering client-side

Copy-paste the code for loading modals from horizon.modals.js
into static/muranodashboard/js/load-modals.js to not rely on
change in Horizon.

Implements blueprint draggable-components
Change-Id: I71935db1c5f193441e4a0df54362de7ee2bafca0
This commit is contained in:
Timur Sufiev
2015-02-06 18:08:19 -08:00
committed by Ekaterina Chernova
parent 97531bb34e
commit 6624d47ca0
10 changed files with 458 additions and 12 deletions

View File

@@ -85,6 +85,16 @@ def get_environments_context(request):
return context
def get_categories_list(request):
categories = []
with api.handled_exceptions(request):
client = api.muranoclient(request)
categories = client.packages.categories()
if ALL_CATEGORY_NAME not in categories:
categories.insert(0, ALL_CATEGORY_NAME)
return categories
@auth_dec.login_required
def switch(request, environment_id,
redirect_field_name=auth.REDIRECT_FIELD_NAME):
@@ -509,15 +519,8 @@ class IndexView(list_view.ListView):
def get_context_data(self, **kwargs):
context = super(IndexView, self).get_context_data(**kwargs)
categories = []
with api.handled_exceptions(self.request):
client = api.muranoclient(self.request)
categories = client.packages.categories()
if ALL_CATEGORY_NAME not in categories:
categories.insert(0, ALL_CATEGORY_NAME)
context.update({
'categories': categories,
'categories': get_categories_list(self.request),
'current_category': self.get_current_category(),
'latest_list': clean_latest_apps(self.request)
})

View File

@@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import json
import logging
from django.core.urlresolvers import reverse
@@ -22,9 +23,13 @@ from horizon import exceptions
from horizon import messages
from horizon import tables
from muranodashboard.catalog import views as catalog_views
from muranodashboard.environments import api
from muranodashboard.environments import consts
from muranodashboard import api as api_utils
from muranodashboard.api import packages as pkg_api
LOG = logging.getLogger(__name__)
@@ -273,10 +278,26 @@ class ServicesTable(tables.DataTable):
def get_object_id(self, datum):
return datum['?']['id']
def get_apps_list(self):
packages = []
with api_utils.handled_exceptions(self.request):
packages, self._more = pkg_api.package_list(
self.request, filters={'type': 'Application'})
return json.dumps([package.to_dict() for package in packages])
def actions_allowed(self):
status, version = _get_environment_status_and_version(
self.request, self)
return status not in consts.NO_ACTION_ALLOWED_STATUSES
def get_categories_list(self):
return catalog_views.get_categories_list(self.request)
class Meta:
name = 'services'
verbose_name = _('Components')
template = 'common/_data_table.html'
verbose_name = _('Component List')
template = 'services/_data_table.html'
no_data_message = _('NO COMPONENTS')
status_columns = ['status']
row_class = UpdateServiceRow
table_actions = (AddApplication, DeployThisEnvironment)

View File

@@ -76,9 +76,125 @@ h3.heading_switcher a.btn {
padding: 5px 0;
}
.centering {
width: 100%;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.draggable_app img {
margin-bottom: 5px;
display: block;
}
.draggable_app .well {
margin-bottom: 0;
}
@media (max-width: 916px) {
.draggable_app .well, .no_apps.well {
min-height: 120px;
}
}
@media (min-width: 916px) and (max-width: 1158px) {
.draggable_app .well, .no_apps.well {
min-height: 105px;
}
}
.has-feedback .form-control-feedback {
top: 10px;
}
.draggable_app {
font-size: 11px;
}
.draggable_app.col-xs-2 {
padding-left: 5px;
padding-right: 5px;
}
.carousel-inner .row {
margin-left: 0;
margin-right: 0;
}
.carousel {
padding: 0 40px;
margin-bottom: 20px;
}
.carousel-control {
position: absolute;
top: 30px;
left: -10px;
bottom: 0;
width: 30px;
opacity: 1;
filter: alpha(opacity=100);
font-size: 20px;
color: #666;
text-align: center;
text-shadow: none;
}
.carousel-control:hover:focus {
color: #d93c27;
}
.carousel-control:hover {
color: #d93c27;
opacity: 1;
}
.carousel-control:focus {
color: #666;
}
.carousel-control.left {
background: inherit;
}
.carousel-control.right {
background: inherit;
}
.extra_title {
padding-left: 8px;
}
.draggable_app .well:hover {
border-color: #d93c27;
background: #fff;
}
#envAppsCategory > button{
margin-top: 8px;
}
#envAppsCategory > h4 {
position: absolute;
left: -110px;
top: 7px;
}
#envAppsFilter {
margin-top: 15px;
}
div.drop_component {
border: 2px #ccc dashed;
width: 85%;
margin: 0 auto -2px;
border-radius: 5px;
}
div.drop_component.over {
background-color: #ffffc0;
border-color: #e8c502;
}
h3.link {
color: yellow;
opacity: 0.5;

View File

@@ -0,0 +1,136 @@
$(function() {
horizon.tabs.addTabLoadFunction(initServicesTab);
initServicesTab($('.tab-content .tab-pane.active'));
function initServicesTab($tab) {
var $dropArea = $tab.find('.drop_component'),
draggedAppUrl = null,
firstDropTarget = null;
function bindAppTileHandlers() {
$('.draggable_app').each(function () {
$(this).on('dragstart', function (ev) {
ev.originalEvent.dataTransfer.effectAllowed = 'copy';
// we have to use an external variable for this since
// storing data in dataTransfer object works only for FF
draggedAppUrl = $(this).find('input[type="hidden"]').val();
// set it so the DND works in FF
ev.originalEvent.dataTransfer.setData('text/uri-list', draggedAppUrl);
}).on('dragend', function () {
$dropArea.removeClass('over');
})
});
}
$dropArea.on('dragover', function (ev) {
ev.preventDefault();
ev.originalEvent.dataTransfer.dropEffect = 'copy';
return false;
}).on('dragenter', function (ev) {
$dropArea.addClass('over');
firstDropTarget = ev.target;
}).on('dragleave', function (ev) {
if (firstDropTarget === ev.target) {
$dropArea.removeClass('over');
}
}).on('drop', function (ev) {
ev.preventDefault();
horizon.modals.loadModal(draggedAppUrl);
return false;
});
var packages = $.parseJSON($('#apps_carousel_contents').val());
function subdivide(numOfItems) {
var chunks = [],
seq = this,
head = seq.slice(0, numOfItems),
tail = seq.slice(numOfItems);
while (tail.length) {
chunks.push(head);
head = tail.slice(0, numOfItems);
tail = tail.slice(numOfItems);
}
chunks.push(head);
return chunks;
}
Array.prototype.subdivide = subdivide;
var $carouselInner = $tab.find('.carousel-inner'),
$carousel = $('#apps_carousel'),
$filter = $('#envAppsFilter').find('input'),
category = ALL_CATEGORY = 'All',
filterValue = '',
ENTER_KEYCODE = 13;
var tileTemplate = Hogan.compile($('#app_tile_small').html()),
environmentId = $('#environmentId').val();
function fillCarousel(apps) {
if (apps.length) {
apps.subdivide(6).forEach(function (chunk, index) {
var $item = $('<div class="item"></div>'),
$row = $('<div class="row"></div>');
if (index == 0) {
$item.addClass('active');
}
$item.appendTo($row);
chunk.forEach(function (pkg) {
var html = tileTemplate.render({
app_name: pkg.name,
environment_id: environmentId,
app_id: pkg.id
});
$(html).appendTo($item);
});
$item.appendTo($carouselInner);
});
$('div.carousel-control').removeClass('item')
$carousel.show();
bindAppTileHandlers();
} else {
$carousel.hide();
}
}
if (packages) {
fillCarousel(packages);
}
$carousel.carousel({interval: false});
function refillCarousel() {
$carouselInner.empty();
if (category === ALL_CATEGORY && filterValue === '') {
fillCarousel(packages);
} else {
var filterRegexp = new RegExp(filterValue, 'i'),
filterRegexpExact = new RegExp('\\b' + filterValue + '\\b', 'i');
fillCarousel(packages.filter(function (pkg) {
var categorySatisfied = true, filterSatisfied = true;
if (category !== ALL_CATEGORY) {
categorySatisfied = pkg.categories.indexOf(category) > -1;
}
if (filterValue !== '') {
filterSatisfied = pkg.name.match(filterRegexp);
filterSatisfied = filterSatisfied || pkg.description.match(filterRegexp);
filterSatisfied = filterSatisfied || pkg.tags.some(function (tag) {
return tag.match(filterRegexpExact);
});
}
return categorySatisfied && filterSatisfied;
}))
}
}
// dynamic carousel refilling on category change
$('#envAppsCategory').on('click', 'a', function (env) {
category = $(this).text();
$('#envAppsCategoryName').text(category);
refillCarousel();
});
// dynamic carousel refilling on search box non-empty submission
$filter.keypress(function (ev) {
if (ev.which == ENTER_KEYCODE) {
filterValue = $filter.val();
refillCarousel();
ev.preventDefault();
}
});
}
});

View File

@@ -0,0 +1,49 @@
$(function() {
horizon.modals.loadModal = function (url, updateFieldId) {
// If there's an existing modal request open, cancel it out.
if (horizon.modals._request && typeof(horizon.modals._request.abort) !== undefined) {
horizon.modals._request.abort();
}
horizon.modals._request = $.ajax(url, {
beforeSend: function () {
horizon.modals.modal_spinner(gettext("Loading"));
},
complete: function () {
// Clear the global storage;
horizon.modals._request = null;
horizon.modals.spinner.modal('hide');
},
error: function(jqXHR, status, errorThrown) {
if (jqXHR.status === 401){
var redir_url = jqXHR.getResponseHeader("X-Horizon-Location");
if (redir_url){
location.href = redir_url;
} else {
location.reload(true);
}
}
else {
if (!horizon.ajax.get_messages(jqXHR)) {
// Generic error handler. Really generic.
horizon.alert("danger", gettext("An error occurred. Please try again later."));
}
}
},
success: function (data, textStatus, jqXHR) {
var update_field_id = updateFieldId,
modal,
form;
modal = horizon.modals.success(data, textStatus, jqXHR);
if (update_field_id) {
form = modal.find("form");
if (form.length) {
form.attr("data-add-to-field", update_field_id);
}
}
}
});
};
});

View File

@@ -4,7 +4,6 @@
{% block table_caption %}
<tr class='table_caption'>
<th class='table_header' colspan='{{ columns|length }}'>
{# <h3 class='table_title'>{{ table }}</h3> #}
{{ table.render_table_actions }}
</th>
</tr>

View File

@@ -0,0 +1,79 @@
{% extends 'common/_data_table.html' %}
{% load i18n %}
{% load custom_filters %}
{% block title %}{% trans "Components" %}{% endblock %}
{% block table_caption %}
{% if table.actions_allowed %}
<div class="row">
<div class="col-xs-4">
<h3 class="table_title extra_title">{% trans "Application Components" %}</h3>
</div>
<div class="col-xs-offset-2 col-xs-3">
<div class="dropdown" id="envAppsCategory">
{% with categories=table.get_categories_list %}
<h4>{% trans 'App category' %}</h4>
<button class="btn btn-default dropdown-toggle"
type="button" aria-expanded="true">
<span id="envAppsCategoryName">{{ categories.0 }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu" aria-labelledby="envAppsCategory">
{% for category in categories %}
<li role="presentation"><a role="menuitem" tabindex="-1" href="#">{{ category }}</a></li>
{% endfor %}
</ul>
{% endwith %}
</div>
</div>
<div class="col-xs-3">
<div class="has-feedback" id="envAppsFilter">
<input class="form-control" value="{{ search }}" type="text"
placeholder="Find in a selected category">
<span class="fa fa-search form-control-feedback"></span>
</div>
</div>
</div>
<div class="clearfix"></div>
{% with apps=table.get_apps_list %}
{% if apps|length %}
<div id="apps_carousel" class="carousel">
<input type="hidden" id="apps_carousel_contents" value="{{ apps }}">
<div class="carousel-inner"></div>
<a class="left carousel-control" href="#apps_carousel" data-slide="prev">
<i class="fa fa-chevron-left"></i>
</a>
<a class="right carousel-control" href="#apps_carousel" data-slide="next">
<i class="fa fa-chevron-right"></i>
</a>
</div>
{% else %}
<div class="well">
<h4>No Application Components match the current category and/or filter string</h4>
</div>
{% endif %}
{% endwith %}
{% endif %}
<tr class='table_caption'>
<th class='table_header' colspan='{{ columns|length }}'>
<h3 class='table_title'>{{ table }}</h3>
{{ table.render_table_actions }}
</th>
</tr>
{% endblock table_caption %}
{% block table_body %}
<tbody>
{% if table.actions_allowed %}
<tr><td colspan="{{ table.get_columns|length }}">
<div class="drop_component">
<h4 class="centering"><i class="fa fa-bullseye"></i> {% trans 'Drop component here to add it' %}</h4></div>
</td></tr>
{% endif %}
{% for row in rows %}
{{ row.render }}
{% endfor %}
</tbody>
{% endblock table_body %}

View File

@@ -0,0 +1,12 @@
{% load i18n %}
{% load url from future %}
<div class="col-xs-2 draggable_app">
<div class="well well-sm" draggable="true">
<img class="centering" src="{% url 'horizon:murano:catalog:images' app.id %}"
height="50" width="50" draggable="false"/>
<input type="hidden" value="{% url 'horizon:murano:catalog:add' app.id environment_id 'True' 'True' %}"/>
<div class="centering">{{ app.name }}</div>
</div>
</div>

View File

@@ -1,12 +1,15 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load horizon %}
{% load custom_filters %}
{% block title %}{% trans "Components" %}{% endblock %}
{% block page_header %}
{% include "services/_page_header.html" %}
{% endblock page_header %}
{% block main %}
<input type="hidden" id="environmentId" value="{{ environment_id }}">
<div class="row">
<div class="col-xs-12 col-sm-12 col-md-12 col-lg-12">
{{ tab_group.render }}
@@ -17,4 +20,24 @@
{% block css %}
{% include "_stylesheets.html" %}
<link rel="stylesheet" href="{% static 'muranodashboard/css/catalog.css' %}"/>
{% endblock %}
{% block js %}
{% include 'horizon/_scripts.html' %}
<script src="{% static 'muranodashboard/js/load-modals.js' %}"></script>
<script type="text/template" id="app_tile_small">
{% jstemplate %}
{% url 'horizon:murano:catalog:images' '[[app_id]]' as image_url %}
{% url 'horizon:murano:catalog:add' '[[app_id]]' '[[environment_id]]' 'True' 'True' as add_url %}
<div class="col-xs-2 draggable_app">
<div class="well well-sm" draggable="true">
<img class="centering" src="{{ image_url|unquote }}"
height="50" width="50" draggable="false"/>
<input type="hidden" value="{{ add_url|unquote }}"/>
<div class="centering">[[app_name]]</div>
</div>
</div>
{% endjstemplate %}
</script>
<script src="{% static 'muranodashboard/js/draggable-components.js' %}"></script>
{% endblock %}

View File

@@ -1,5 +1,7 @@
from django import forms
from django import template
from django.template import defaultfilters
import urllib
register = template.Library()
@@ -19,3 +21,9 @@ def first_half(seq):
def last_half(seq):
half_len = len(seq) / 2
return seq[half_len:]
@register.filter(name='unquote')
@defaultfilters.stringfilter
def unquote_raw(value):
return urllib.unquote(value)