dataviz: use select2 widget for all filters (#71885)
This commit is contained in:
parent
e8bd91b44e
commit
4bad6b488b
|
@ -26,12 +26,12 @@ from django.db.models import Q
|
|||
from django.db.models.fields import BLANK_CHOICE_DASH
|
||||
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from gadjo.forms.widgets import MultiSelectWidget
|
||||
|
||||
from combo.utils import cache_during_request, requests, spooler
|
||||
|
||||
from .fields import StaticField
|
||||
from .models import ChartCell, ChartFiltersCell, ChartNgCell
|
||||
from .widgets import MultipleSelect2Widget, Select2Widget
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
|
@ -108,7 +108,8 @@ class ChartFiltersMixin:
|
|||
|
||||
return fields
|
||||
|
||||
def build_choice_field(self, cell, filter_, initial):
|
||||
@classmethod
|
||||
def get_filter_options(cls, cell, filter_, initial):
|
||||
filter_id = filter_['id']
|
||||
|
||||
filter_options = filter_['options']
|
||||
|
@ -122,11 +123,11 @@ class ChartFiltersMixin:
|
|||
]
|
||||
|
||||
if filter_id == 'time_interval':
|
||||
self.extend_time_interval_choices(choices)
|
||||
cls.extend_time_interval_choices(choices)
|
||||
|
||||
required = filter_.get('required', False)
|
||||
multiple = filter_.get('multiple')
|
||||
if not required:
|
||||
if not required and not multiple:
|
||||
choices.insert(0, Choice(*BLANK_CHOICE_DASH[0]))
|
||||
|
||||
extra_variables = cell.page.get_extra_variables_keys()
|
||||
|
@ -149,15 +150,24 @@ class ChartFiltersMixin:
|
|||
if variable_choices and not multiple and filter_id != 'time_interval':
|
||||
choices.extend(variable_choices)
|
||||
|
||||
return choices
|
||||
|
||||
def build_choice_field(self, cell, filter_, initial):
|
||||
multiple = filter_.get('multiple')
|
||||
required = filter_.get('required', False)
|
||||
choices = self.get_filter_options(cell, filter_, initial)
|
||||
|
||||
widget_class = MultipleSelect2Widget if multiple else Select2Widget
|
||||
widget = widget_class(cell, filter_['id'], choices, initial)
|
||||
|
||||
field_class = forms.MultipleChoiceField if multiple else forms.ChoiceField
|
||||
widget_class = MultiSelectWidget if multiple else forms.Select
|
||||
field = field_class(
|
||||
label=filter_['label'],
|
||||
choices=Choice.get_field_choices(choices),
|
||||
required=required,
|
||||
initial=initial,
|
||||
widget=widget_class,
|
||||
)
|
||||
field.widget = widget
|
||||
field.dataviz_choices = choices
|
||||
return field
|
||||
|
||||
|
@ -168,9 +178,10 @@ class ChartFiltersMixin:
|
|||
initial=bool(initial == 'true'),
|
||||
)
|
||||
|
||||
def extend_time_interval_choices(self, choices):
|
||||
@classmethod
|
||||
def extend_time_interval_choices(cls, choices):
|
||||
if any(choice.id == 'day' for choice in choices):
|
||||
for choice in self.time_intervals:
|
||||
for choice in cls.time_intervals:
|
||||
if choice not in choices:
|
||||
choices.append(choice)
|
||||
|
||||
|
|
|
@ -15,8 +15,11 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf.urls import re_path
|
||||
from django.urls import path
|
||||
|
||||
from .views import ajax_gauge_count, dataviz_graph, dataviz_graph_export
|
||||
from combo.urls_utils import manager_required
|
||||
|
||||
from .views import ajax_gauge_count, dataviz_choices, dataviz_graph, dataviz_graph_export
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^ajax/gauge-count/(?P<cell>[\w_-]+)/$', ajax_gauge_count, name='combo-ajax-gauge-count'),
|
||||
|
@ -24,4 +27,9 @@ urlpatterns = [
|
|||
re_path(
|
||||
r'^dataviz/graph/(?P<cell>[\w_-]+)/export/$', dataviz_graph_export, name='combo-dataviz-graph-export'
|
||||
),
|
||||
path(
|
||||
'api/dataviz/graph/<int:cell_id>/<filter_id>/ajax-choices',
|
||||
manager_required(dataviz_choices),
|
||||
name='combo-dataviz-choices',
|
||||
),
|
||||
]
|
||||
|
|
|
@ -15,12 +15,13 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import io
|
||||
import unicodedata
|
||||
|
||||
import pyexcel_ods
|
||||
from django.core import signing
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseBadRequest
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import render, reverse
|
||||
from django.template import TemplateSyntaxError, VariableDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -31,7 +32,7 @@ from requests.exceptions import HTTPError
|
|||
|
||||
from combo.utils import NothingInCacheException, get_templated_url, requests
|
||||
|
||||
from .forms import ChartNgExportForm, ChartNgPartialForm
|
||||
from .forms import ChartFiltersMixin, ChartNgExportForm, ChartNgPartialForm, Choice
|
||||
from .models import ChartNgCell, Gauge, MissingVariable, UnsupportedDataSet
|
||||
|
||||
|
||||
|
@ -189,3 +190,58 @@ class DatavizGraphExportView(SingleObjectMixin, FormView):
|
|||
|
||||
|
||||
dataviz_graph_export = DatavizGraphExportView.as_view()
|
||||
|
||||
|
||||
class DatavizChoicesView(DetailView):
|
||||
model = ChartNgCell
|
||||
pk_url_kwarg = 'cell_id'
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
self.cell = self.get_object()
|
||||
|
||||
filter_id = self.kwargs.get('filter_id')
|
||||
for filter_ in self.cell.available_filters:
|
||||
if filter_['id'] == filter_id:
|
||||
self.filter = filter_
|
||||
break
|
||||
else:
|
||||
raise Http404()
|
||||
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
search_term = request.GET.get('term', '')
|
||||
search_term = unicodedata.normalize('NFKC', search_term).casefold()
|
||||
|
||||
try:
|
||||
page_number = int(request.GET.get('page', 1))
|
||||
except ValueError:
|
||||
page_number = 1
|
||||
|
||||
initial = self.cell.filter_params.get(self.filter['id'], self.filter.get('default'))
|
||||
objects = ChartFiltersMixin.get_filter_options(self.cell, self.filter, initial)
|
||||
objects = [x for x in objects if search_term in unicodedata.normalize('NFKC', str(x)).casefold()]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'results': self.format_results(objects, (page_number - 1) * 10, page_number * 10),
|
||||
'pagination': {'more': bool(len(objects) >= page_number * 10)},
|
||||
}
|
||||
)
|
||||
|
||||
def format_results(self, objects, start_index, end_index):
|
||||
page_objects = objects[start_index:end_index]
|
||||
|
||||
if start_index > 0:
|
||||
last_displayed_group = objects[start_index - 1].group
|
||||
for option in page_objects:
|
||||
if option.group == last_displayed_group:
|
||||
option.group = None
|
||||
|
||||
return [
|
||||
{'text': group, 'children': [{'id': k, 'text': v} for k, v in choices]}
|
||||
for group, choices in Choice.get_field_choices(page_objects)
|
||||
]
|
||||
|
||||
|
||||
dataviz_choices = DatavizChoicesView.as_view()
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2014-2022 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf import settings
|
||||
from django.forms.widgets import Select, SelectMultiple
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class Select2WidgetMixin:
|
||||
class Media:
|
||||
js = 'xstatic/select2.min.js'
|
||||
css = {'all': ('xstatic/select2.min.css',)}
|
||||
|
||||
def __init__(self, cell, filter_id, choices, initial):
|
||||
from .forms import Choice
|
||||
|
||||
attrs = {}
|
||||
if self.enable_select2(choices):
|
||||
attrs['data-autocomplete'] = 'true'
|
||||
attrs['lang'] = settings.LANGUAGE_CODE
|
||||
attrs['data-select2-url'] = reverse(
|
||||
'combo-dataviz-choices', kwargs={'cell_id': cell.pk, 'filter_id': filter_id}
|
||||
)
|
||||
choices = self.filter_choices(choices, initial)
|
||||
|
||||
super().__init__(choices=Choice.get_field_choices(choices), attrs=attrs)
|
||||
|
||||
def enable_select2(self, choices):
|
||||
return True
|
||||
|
||||
|
||||
class Select2Widget(Select2WidgetMixin, Select):
|
||||
min_choices = 20
|
||||
|
||||
@staticmethod
|
||||
def filter_choices(choices, initial):
|
||||
return [x for x in choices if x.id == initial]
|
||||
|
||||
def enable_select2(self, choices):
|
||||
return bool(len(choices) > self.min_choices)
|
||||
|
||||
|
||||
class MultipleSelect2Widget(Select2WidgetMixin, SelectMultiple):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['multiple'] = 'multiple'
|
||||
|
||||
@staticmethod
|
||||
def filter_choices(choices, initial):
|
||||
return [x for x in choices if x.id in (initial or [])]
|
|
@ -1924,46 +1924,30 @@ 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)
|
||||
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)
|
||||
cell.refresh_from_db()
|
||||
assert cell.filter_params == {'color': ['blue']}
|
||||
assert resp.form[field_prefix + 'color'].options == []
|
||||
|
||||
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')
|
||||
resp.form[field_prefix + 'color'].force_value(['blue', 'green'])
|
||||
manager_submit_cell(resp.form)
|
||||
assert resp.form[field_prefix + 'color'].value == ['green', 'blue']
|
||||
assert resp.form[field_prefix + 'color'].options == [('green', True, 'Green'), ('blue', True, 'Blue')]
|
||||
|
||||
cell.refresh_from_db()
|
||||
assert cell.filter_params == {'color': ['red', 'green']}
|
||||
assert cell.filter_params == {'color': ['blue', '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.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', False, 'Green'),
|
||||
('red', True, 'red (unavailable)'),
|
||||
assert resp.form[field_prefix + 'color'].value == ['green', 'blue']
|
||||
assert resp.form[field_prefix + 'color'].options == [
|
||||
('green', True, 'Green'),
|
||||
('blue', True, 'blue (unavailable)'),
|
||||
]
|
||||
|
||||
resp.form.get(field_prefix + 'color', 0).select(text='Green')
|
||||
resp.form[field_prefix + 'color'].select_multiple(texts=[])
|
||||
manager_submit_cell(resp.form)
|
||||
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 == ''
|
||||
assert resp.form[field_prefix + 'color'].value is None
|
||||
cell.refresh_from_db()
|
||||
assert cell.get_filter_params() == {}
|
||||
|
||||
|
@ -2256,14 +2240,6 @@ def test_chartng_cell_manager_new_api_page_variables(app, admin_user, new_api_st
|
|||
time_interval_field = resp.form[field_prefix + 'time_interval']
|
||||
assert [x[0] for x in time_interval_field.options] == ['day', 'month', 'year', 'week', 'weekday']
|
||||
|
||||
# no variables allowed for multiple choice field
|
||||
cell.statistic = Statistic.objects.get(slug='filter-multiple')
|
||||
cell.save()
|
||||
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']
|
||||
|
||||
|
||||
def test_chartng_cell_manager_new_api_tabs(app, admin_user):
|
||||
page = Page.objects.create(title='One', slug='index')
|
||||
|
@ -3597,3 +3573,104 @@ def test_update_available_statistics(app, settings, caplog, freezer):
|
|||
update_available_statistics()
|
||||
assert caplog.records
|
||||
assert caplog.messages == ['statistics from "Connection" have not been available for more than 48 hours.']
|
||||
|
||||
|
||||
def test_chartng_cell_select2_choices(app, admin_user, new_api_statistics):
|
||||
page = Page.objects.create(title='One', slug='index', extra_variables={'foo': 'bar'})
|
||||
cell = ChartNgCell(page=page, order=1, placeholder='content')
|
||||
cell.statistic = Statistic.objects.get(slug='option-groups')
|
||||
cell.save()
|
||||
|
||||
app = login(app)
|
||||
resp = app.get('/manage/pages/%s/' % page.id)
|
||||
assert not resp.pyquery('select#id_cdataviz_chartngcell-%s-form' % cell.id).attr('data-select2-url')
|
||||
|
||||
# add choices to enable select2
|
||||
form_filter = next(x for x in cell.statistic.filters if x['id'] == 'form')
|
||||
form_filter['options'].append(
|
||||
['Category C', [{'id': 'test-%s' % i, 'label': 'test %s' % i} for i in range(20)]]
|
||||
)
|
||||
cell.statistic.save()
|
||||
|
||||
resp = app.get('/manage/pages/%s/' % page.id)
|
||||
|
||||
field_prefix = 'cdataviz_chartngcell-%s-' % cell.id
|
||||
assert resp.form[field_prefix + 'form'].options == []
|
||||
|
||||
resp.form[field_prefix + 'form'].force_value('test-0')
|
||||
manager_submit_cell(resp.form)
|
||||
assert resp.form[field_prefix + 'form'].options == [('test-0', True, 'test 0')]
|
||||
|
||||
url = resp.pyquery('select#id_cdataviz_chartngcell-%s-form' % cell.id).attr('data-select2-url')
|
||||
resp = app.get(url)
|
||||
assert resp.json['results'] == [
|
||||
{'children': [{'id': '', 'text': '---------'}, {'id': 'all', 'text': 'All'}], 'text': None},
|
||||
{'children': [{'id': 'test', 'text': 'Test'}], 'text': 'Category A'},
|
||||
{'children': [{'id': 'test-2', 'text': 'test 2'}], 'text': 'Category B'},
|
||||
{
|
||||
'children': [
|
||||
{'id': 'test-0', 'text': 'test 0'},
|
||||
{'id': 'test-1', 'text': 'test 1'},
|
||||
{'id': 'test-2', 'text': 'test 2'},
|
||||
{'id': 'test-3', 'text': 'test 3'},
|
||||
{'id': 'test-4', 'text': 'test 4'},
|
||||
{'id': 'test-5', 'text': 'test 5'},
|
||||
],
|
||||
'text': 'Category C',
|
||||
},
|
||||
]
|
||||
assert resp.json['pagination']['more'] is True
|
||||
|
||||
resp = app.get(url + '?page=2')
|
||||
assert len(resp.json['results']) == 1
|
||||
assert resp.json['results'][0]['text'] is None
|
||||
assert len(resp.json['results'][0]['children']) == 10
|
||||
assert resp.json['results'][0]['children'][0]['id'] == 'test-6'
|
||||
assert resp.json['results'][0]['children'][-1]['id'] == 'test-15'
|
||||
assert resp.json['pagination']['more'] is True
|
||||
|
||||
resp = app.get(url + '?page=3')
|
||||
assert resp.json['results'] == [
|
||||
{
|
||||
'children': [
|
||||
{'id': 'test-16', 'text': 'test 16'},
|
||||
{'id': 'test-17', 'text': 'test 17'},
|
||||
{'id': 'test-18', 'text': 'test 18'},
|
||||
{'id': 'test-19', 'text': 'test 19'},
|
||||
],
|
||||
'text': None,
|
||||
},
|
||||
{'children': [{'id': 'variable:foo', 'text': 'foo'}], 'text': 'Page variables'},
|
||||
]
|
||||
assert resp.json['pagination']['more'] is False
|
||||
|
||||
resp = app.get(url + '?term=test 2')
|
||||
assert resp.json['results'] == [
|
||||
{'children': [{'id': 'test-2', 'text': 'test 2'}], 'text': 'Category B'},
|
||||
{'children': [{'id': 'test-2', 'text': 'test 2'}], 'text': 'Category C'},
|
||||
]
|
||||
assert resp.json['pagination']['more'] is False
|
||||
|
||||
# no variables allowed for multiple choice field
|
||||
cell.statistic = Statistic.objects.get(slug='filter-multiple')
|
||||
cell.save()
|
||||
|
||||
resp = app.get('/manage/pages/%s/' % page.id)
|
||||
url = resp.pyquery('select#id_cdataviz_chartngcell-%s-color' % cell.id).attr('data-select2-url')
|
||||
resp = app.get(url)
|
||||
assert resp.json == {
|
||||
'pagination': {'more': False},
|
||||
'results': [
|
||||
{
|
||||
'children': [
|
||||
{'id': 'red', 'text': 'Red'},
|
||||
{'id': 'green', 'text': 'Green'},
|
||||
{'id': 'blue', 'text': 'Blue'},
|
||||
],
|
||||
'text': None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# unknown filter
|
||||
resp = app.get('/api/dataviz/graph/%s/unknown/ajax-choices' % cell.id, status=404)
|
||||
|
|
Loading…
Reference in New Issue