dataviz, avoir un widget dynamique pour la sélection multiple (#74061) #41

Merged
vdeniaud merged 2 commits from wip/74061-dataviz-avoir-un-widget-dynamiqu into main 2023-02-28 10:20:19 +01:00
12 changed files with 230 additions and 36 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ combo/apps/maps/static/css/combo.map.css.map
combo/apps/pwa/static/css/combo.manager.pwa.css
combo/apps/pwa/static/css/combo.manager.pwa.css.map
combo/apps/family/static/css/combo.weekly_agenda.css
combo/apps/dataviz/static/css/combo.multiselectwidget.css
combo/manager/static/css/combo.manager.css
data/themes/gadjo/static/css/agent-portal.css
data/themes/gadjo/static/css/agent-portal.css.map

View File

@ -3,7 +3,7 @@ recursive-include combo/locale *.po *.mo
# static
recursive-include combo/apps/lingo/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include combo/apps/dataviz/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include combo/apps/dataviz/static *.css *.js *.ico *.gif *.png *.jpg *.scss
recursive-include combo/apps/dashboard/static *.js
recursive-include combo/apps/family/static *.css *.scss *.js
recursive-include combo/apps/gallery/static *.js

View File

@ -29,6 +29,7 @@ from django.utils.translation import gettext_lazy as _
from combo.utils import cache_during_request, requests, spooler
from .models import ChartCell, ChartFiltersCell, ChartNgCell
from .widgets import MultiSelectWidget
class ChartForm(forms.ModelForm):
@ -93,7 +94,7 @@ class ChartFiltersMixin:
required = filter_.get('required', False)
multiple = filter_.get('multiple')
if not required and not multiple:
if not required:
choices_to_complete.insert(0, BLANK_CHOICE_DASH[0])
extra_variables = cell.page.get_extra_variables_keys()
@ -117,8 +118,13 @@ class ChartFiltersMixin:
choices.append((_('Page variables'), variable_choices))
field_class = forms.MultipleChoiceField if multiple else forms.ChoiceField
widget_class = MultiSelectWidget if multiple else forms.Select
fields[filter_id] = field_class(
label=filter_['label'], choices=choices, required=required, initial=initial
label=filter_['label'],
choices=choices,
required=required,
initial=initial,
widget=widget_class,
)
if filter_.get('deprecated'):
fields[filter_id].label += ' (%s)' % _('deprecated')

View File

@ -757,6 +757,10 @@ class ChartFiltersCell(CellBase):
class Meta:
verbose_name = _('Filters')
class Media:
js = ('js/combo.multiselectwidget.js',)
css = {'all': ('css/combo.multiselectwidget.css',)}
@classmethod
def is_enabled(cls):
return settings.CHART_FILTERS_CELL_ENABLED and settings.STATISTICS_PROVIDERS

View File

@ -0,0 +1,31 @@
.combo-multi-select-widget--field {
margin-bottom: 0.2em;
}
.combo-multi-select-widget--select-button-container {
display: flex;
gap: 0.5em;
}
.combo-multi-select-widget--field select {
min-width: 0;
}
.combo-multi-select-widget--field button {
margin-top: auto;
margin-bottom: auto;
}
.combo-multi-select-widget--field:first-of-type button.combo-multi-select-widget--button-remove {
display: none;
}
button.combo-multi-select-widget--button-add::before {
content: "\f067"; /* plus */
font-family: FontAwesome;
}
button.combo-multi-select-widget--button-remove::before {
content: "\f068"; /* minus */
font-family: FontAwesome;
}

View File

@ -0,0 +1,69 @@
const multiSelectWidget = (function () {
const add_row = function(widget) {
event.preventDefault();
/* get last row node */
const rows = widget.querySelectorAll('.combo-multi-select-widget--field');
const $last_row = $(rows).last();
/* clone the row */
const $new_row = $last_row.clone();
/* set new label and ids */
const row_label = widget.dataset.rowLabel;
const new_label = row_label + ' ' + rows.length;
$new_row.find('label').text(new_label);
const row_id = widget.dataset.rowId;
const new_id = row_id + '_' + rows.length;
$new_row.find('label').attr('for', new_id);
$new_row.find('select').attr('id', new_id);
/* add new row after the last row */
$last_row.after($new_row);
$('.combo-multi-select-widget--button-remove', $new_row).click(remove_row);
}
const remove_row = function(event) {
event.preventDefault();
var $field = $(this).parents('.content');
vdeniaud marked this conversation as resolved Outdated

C'est nécessaire pour le rafraîchissement auto de la cellule, le fonctionnement étant :

  • Clic sur « + », une ligne apparaît mais le rafraîchissement n'est pas déclenché
  • Sélection de la valeur souhaitée, le rafraîchissement est déclenché
  • Clic sur « − », le rafraîchissement n'est pas déclenché
  • Mais si il faut rafraîchir, du coup on trigger cet évènement et on s'assure que ça provoque un rafra
C'est nécessaire pour le rafraîchissement auto de la cellule, le fonctionnement étant : * Clic sur « + », une ligne apparaît mais le rafraîchissement n'est pas déclenché * Sélection de la valeur souhaitée, le rafraîchissement est déclenché * Clic sur « − », le rafraîchissement n'est pas déclenché * Mais si il faut rafraîchir, du coup on trigger cet évènement et on s'assure que ça provoque un rafra
Outdated
Review

Utiliser un event pour cela t'oblige à ajouter des lignes des codes JS dans les templates où le widget est utilisé. On peut s'en passer, je pousse un commit de proposition

Utiliser un event pour cela t'oblige à ajouter des lignes des codes JS dans les templates où le widget est utilisé. On peut s'en passer, je pousse un commit de proposition
var $row = $(this).parents('.combo-multi-select-widget--field');
$row.remove();
$field.change();
vdeniaud marked this conversation as resolved Outdated

Si plusieurs cellules sont sur la même page, le JS se retrouve inclut et donc exécuté plusieurs fois. Ceci m'a l'air d'être la technique la plus simple pour assurer qu'on ait pas plusieurs handlers attachés aux boutons (sinon un clic va déclencher l'apparition d'autant de lignes qu'il y a de cellules, pas ouf).

Si plusieurs cellules sont sur la même page, le JS se retrouve inclut et donc exécuté plusieurs fois. Ceci m'a l'air d'être la technique la plus simple pour assurer qu'on ait pas plusieurs handlers attachés aux boutons (sinon un clic va déclencher l'apparition d'autant de lignes qu'il y a de cellules, pas ouf).
Outdated
Review

unbind eet déprécié, utiliser off à la place. cf commit de proposition

unbind eet déprécié, utiliser `off` à la place. cf commit de proposition
}
const init = function(cell) {
const widgets = cell.querySelectorAll('.combo-multi-select-widget');
if (!widgets.length) return;
widgets.forEach(function(widget){
const deletBtn = widget.querySelectorAll('.combo-multi-select-widget--button-remove');
const addBtn = widget.querySelectorAll('.combo-multi-select-widget--button-add');
$(addBtn).off('click');
$(addBtn).click( () => add_row(widget) );
$(deletBtn).off('click');
$(deletBtn).click(remove_row);
});
}
return {
init
}
})();
$(function() {
$('.cell').each(function(i, cell) {
multiSelectWidget.init(cell);
});
$(document).on('combo:cell-loaded', function(e, cell) {
multiSelectWidget.init(cell);
});
$('.cell').on('combo:cellform-reloaded', function() {
multiSelectWidget.init(this);
});
});

View File

@ -1,4 +1,4 @@
{% load i18n %}
{% load i18n gadjo %}
{% block cell-content %}
<h2>{{ cell.title }}</h2>
@ -6,7 +6,7 @@
<div>
{% if form.fields %}
<form method='get' enctype='multipart/form-data' id='chart-filters'>
{{ form.as_p }}
{{ form|with_template }}
<div class='buttons'>
<button class='submit-button'>{% trans 'Refresh' %}</button>
</div>
@ -44,15 +44,15 @@
});
}
start_field = $('#id_filter-time_range_start');
end_field = $('#id_filter-time_range_end');
start_field = $('#id_filter-time_range_start_p');
end_field = $('#id_filter-time_range_end_p');
$('#id_filter-time_range').change(function() {
if(this.value == 'range') {
start_field.parent().show();
end_field.parent().show();
start_field.show();
end_field.show();
} else {
start_field.parent().hide();
end_field.parent().hide();
start_field.hide();
end_field.hide();
}
}).change();

View File

@ -1,5 +1,7 @@
{% load gadjo %}
<div style="position: relative">
{{ form.as_p }}
{{ form|with_template }}
{% if cell.statistic and cell.chart_type != "table" and cell.chart_type != "table-inverted" %}
<div style="position: absolute; right: 0; top: 0; width: 300px; height: 150px">
<embed type="image/svg+xml" src="{% url 'combo-dataviz-graph' cell=cell.id %}?width=300&height=150"/>
@ -9,28 +11,28 @@
<script>
$(function () {
start_field = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_start');
end_field = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_end');
start_field_template = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_start_template');
end_field_template = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_end_template');
start_field = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_start_p');
end_field = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_end_p');
start_field_template = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_start_template_p');
end_field_template = $('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range_end_template_p');
$('#id_cdataviz_chartngcell-{{ cell.pk }}-time_range').change(function() {
if(this.value == 'range') {
start_field.parent().show();
end_field.parent().show();
start_field.show();
end_field.show();
} else {
start_field.parent().hide();
end_field.parent().hide();
start_field.hide();
end_field.hide();
}
if(this.value == 'range-template') {
start_field_template.parent().show();
end_field_template.parent().show();
start_field_template.show();
end_field_template.show();
} else {
start_field_template.parent().hide();
end_field_template.parent().hide();
start_field_template.hide();
end_field_template.hide();
}
}).change();
$('select, input', 'div#panel-dataviz_chartngcell-{{ cell.pk }}-general').change(function() {
$('div#panel-dataviz_chartngcell-{{ cell.pk }}-general div.content').change(function() {
$('div#cell-dataviz_chartngcell-{{ cell.pk }} button.save').click();
vdeniaud marked this conversation as resolved Outdated

Petit changement de cible de ce sélecteur, sinon les <select> ajoutés dynamiquement ne déclenchaient pas le rafraîchissement de la cellule.

Petit changement de cible de ce sélecteur, sinon les `<select>` ajoutés dynamiquement ne déclenchaient pas le rafraîchissement de la cellule.
});

View File

@ -0,0 +1,17 @@
{% load i18n %}
<div class="combo-multi-select-widget" data-row-id="{{ widget.name }}" data-row-label="{% trans "Value" %}">
<div class="combo-multi-select-widget--fields" role="group">
{% for widget in widget.subwidgets %}
<div class="combo-multi-select-widget--field">
<label for="{{ widget.name }}_{{ forloop.counter }}" class="sr-only">{% trans "Value" %} {{ forloop.counter }}</label>
<div class="combo-multi-select-widget--select-button-container">
{% include widget.template_name %}
<button type="button" name="{{ widget.name }}$remove_element" class="combo-multi-select-widget--button-remove" title="{% trans "Remove" %}" aria-label="{% trans "Remove value" %} {{ forloop.counter }}"></button>
</div>
</div>
{% endfor %}
</div>
<button type="button" name="{{ widget.name }}$add_element" class="combo-multi-select-widget--button-add" title="{% trans "Add" %}" aria-label="{% trans "Add" %}"></button>
</div>

View File

@ -0,0 +1,43 @@
import django
from django import forms
class MultiSelectWidget(forms.MultiWidget):
template_name = 'combo/widgets/multiselectwidget.html'
class Media:
js = ('js/combo.multiselectwidget.js',)
css = {'all': ('css/combo.multiselectwidget.css',)}
def __init__(self, attrs=None):
self.attrs = attrs
widgets = [forms.Select(attrs=attrs)]
super().__init__(widgets, attrs)
def get_context(self, name, value, attrs):
if not isinstance(value, list):
value = [value]
self.widgets = []
for _ in range(max(len(value), 1)):
self.widgets.append(forms.Select(attrs=self.attrs, choices=self.choices))
# all subwidgets must have the same name
if django.VERSION >= (3, 1):
self.widgets_names = [''] * len(self.widgets)
return super().get_context(name, value, attrs)
else:
context = super().get_context(name, value, attrs)
subwidgets = context['widget']['subwidgets']
for widget in subwidgets:
widget['name'] = widget['name'].rsplit('_', 1)[0]
return context
def decompress(self, value):
return value or []
def value_from_datadict(self, data, files, name):
values = [x for x in data.getlist(name) if x]
# remove duplicates while keeping order
return list(dict.fromkeys(values))

View File

@ -5,6 +5,7 @@
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.manager.css" %}"/>
<link rel="stylesheet" type="text/css" media="all" href="{% static "xstatic/leaflet.css" %}"/>
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.map.css" %}"/>
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.multiselectwidget.css" %}"/>
{% endblock %}
{% block page-title %}{% firstof site_title "Combo" %}{% endblock %}
{% block site-title %}{% firstof site_title "Combo" %}{% endblock %}
@ -34,5 +35,6 @@
<script src="{% static "js/combo.manager.js" %}"></script>
<script src="{% static "xstatic/leaflet.js" %}"></script>
<script src="{% static "js/combo.map.js" %}"></script>
<script src="{% static "js/combo.multiselectwidget.js" %}"></script>
<script src="{% url "javascript-catalog" %}"></script>
{% endblock %}

View File

@ -1584,27 +1584,46 @@ def test_chartng_cell_manager_new_api(app, admin_user, new_api_statistics):
filter_multiple_stat = Statistic.objects.get(slug='filter-multiple')
resp.form[field_prefix + 'statistic'] = filter_multiple_stat.pk
manager_submit_cell(resp.form)
resp.form[field_prefix + 'color'].select_multiple(texts=['Blue', 'Green'])
assert field_prefix + 'color$add_element' in resp.form.fields
assert field_prefix + 'color$remove_element' in resp.form.fields
resp.form[field_prefix + 'color'].select(text='Blue')
manager_submit_cell(resp.form)
assert resp.form[field_prefix + 'color'].value == ['green', 'blue']
cell.refresh_from_db()
assert cell.filter_params == {'color': ['green', 'blue']}
assert cell.filter_params == {'color': ['blue']}
cell.filter_params = {'color': ['blue', 'green']}
cell.save()
resp = app.get('/manage/pages/%s/' % page.id)
assert resp.form.get(field_prefix + 'color', 0).value == 'blue'
assert resp.form.get(field_prefix + 'color', 1).value == 'green'
resp.form.get(field_prefix + 'color', 0).select(text='Red')
manager_submit_cell(resp.form)
cell.refresh_from_db()
assert cell.filter_params == {'color': ['red', 'green']}
color_filter = next(x for x in cell.statistic.filters if x['id'] == 'color')
color_filter['options'] = [{'id': 'black', 'label': 'Black'}, {'id': 'green', 'label': 'Green'}]
cell.statistic.save()
resp = app.get('/manage/pages/%s/' % page.id)
assert resp.form[field_prefix + 'color'].value == ['green', 'blue']
assert resp.form[field_prefix + 'color'].options == [
assert resp.form.get(field_prefix + 'color', 0).value == 'red'
assert resp.form.get(field_prefix + 'color', 1).value == 'green'
assert resp.form.get(field_prefix + 'color', 0).options == [
('', False, '---------'),
('black', False, 'Black'),
('green', True, 'Green'),
('blue', True, 'blue (unavailable)'),
('green', False, 'Green'),
('red', True, 'red (unavailable)'),
]
resp.form[field_prefix + 'color'].select_multiple(texts=[])
resp.form.get(field_prefix + 'color', 0).select(text='Green')
manager_submit_cell(resp.form)
assert resp.form[field_prefix + 'color'].value is None
cell.refresh_from_db()
assert cell.filter_params == {'color': ['green']}
resp.form[field_prefix + 'color'] = ''
manager_submit_cell(resp.form)
assert resp.form[field_prefix + 'color'].value == ''
cell.refresh_from_db()
assert cell.get_filter_params() == {}
@ -1822,7 +1841,7 @@ def test_chartng_cell_manager_new_api_dynamic_fields(app, admin_user, new_api_st
resp.form[field_prefix + 'statistic'] = statistic.pk
resp = app.post(resp.form.action, params=resp.form.submit_fields(), xhr=True)
assert 'time_interval' in resp.json['tabs']['general']['form']
assert 'This field is required.' not in resp.json['tabs']['general']['form']
assert '<div class="error">' not in resp.json['tabs']['general']['form']
@with_httmock(new_api_mock)
@ -1884,7 +1903,7 @@ def test_chartng_cell_manager_new_api_page_variables(app, admin_user, new_api_st
resp = app.get('/manage/pages/%s/' % page.id)
color_field = resp.form[field_prefix + 'color']
assert [x[0] for x in color_field.options] == ['red', 'green', 'blue']
assert [x[0] for x in color_field.options] == ['', 'red', 'green', 'blue']
def test_chartng_cell_manager_new_api_tabs(app, admin_user):