diff --git a/.gitignore b/.gitignore index 97be3f4c..45ba7140 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/MANIFEST.in b/MANIFEST.in index 073c377c..3ff7143c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 diff --git a/combo/apps/dataviz/forms.py b/combo/apps/dataviz/forms.py index bdf1a0e9..0af3b3c8 100644 --- a/combo/apps/dataviz/forms.py +++ b/combo/apps/dataviz/forms.py @@ -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') diff --git a/combo/apps/dataviz/models.py b/combo/apps/dataviz/models.py index d8473ebc..b62f0a2f 100644 --- a/combo/apps/dataviz/models.py +++ b/combo/apps/dataviz/models.py @@ -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 diff --git a/combo/apps/dataviz/static/css/combo.multiselectwidget.scss b/combo/apps/dataviz/static/css/combo.multiselectwidget.scss new file mode 100644 index 00000000..48b80e20 --- /dev/null +++ b/combo/apps/dataviz/static/css/combo.multiselectwidget.scss @@ -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; +} diff --git a/combo/apps/dataviz/static/js/combo.multiselectwidget.js b/combo/apps/dataviz/static/js/combo.multiselectwidget.js new file mode 100644 index 00000000..2a7d5e31 --- /dev/null +++ b/combo/apps/dataviz/static/js/combo.multiselectwidget.js @@ -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'); + var $row = $(this).parents('.combo-multi-select-widget--field'); + $row.remove(); + $field.change(); + } + + 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); + }); +}); diff --git a/combo/apps/dataviz/templates/combo/chartngcell_form.html b/combo/apps/dataviz/templates/combo/chartngcell_form.html index 5990d96a..a7797b37 100644 --- a/combo/apps/dataviz/templates/combo/chartngcell_form.html +++ b/combo/apps/dataviz/templates/combo/chartngcell_form.html @@ -32,7 +32,7 @@ } }).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(); }); diff --git a/combo/apps/dataviz/templates/combo/widgets/multiselectwidget.html b/combo/apps/dataviz/templates/combo/widgets/multiselectwidget.html new file mode 100644 index 00000000..41874301 --- /dev/null +++ b/combo/apps/dataviz/templates/combo/widgets/multiselectwidget.html @@ -0,0 +1,17 @@ +{% load i18n %} + +
+
+ {% for widget in widget.subwidgets %} +
+ +
+ {% include widget.template_name %} + +
+
+ {% endfor %} +
+ + +
diff --git a/combo/apps/dataviz/widgets.py b/combo/apps/dataviz/widgets.py new file mode 100644 index 00000000..5c07a67d --- /dev/null +++ b/combo/apps/dataviz/widgets.py @@ -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)) diff --git a/combo/manager/templates/combo/manager_base.html b/combo/manager/templates/combo/manager_base.html index b8be407f..bbfde20c 100644 --- a/combo/manager/templates/combo/manager_base.html +++ b/combo/manager/templates/combo/manager_base.html @@ -5,6 +5,7 @@ + {% endblock %} {% block page-title %}{% firstof site_title "Combo" %}{% endblock %} {% block site-title %}{% firstof site_title "Combo" %}{% endblock %} @@ -34,5 +35,6 @@ + {% endblock %} diff --git a/tests/test_dataviz.py b/tests/test_dataviz.py index 487f02c5..28b234fd 100644 --- a/tests/test_dataviz.py +++ b/tests/test_dataviz.py @@ -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() == {} @@ -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):