diff --git a/CHANGELOG.txt b/CHANGELOG.txt index b4f0cac25b..84d9ca960a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -9,6 +9,7 @@ # A visitor mode has been added; from any list-view, you can visit all the related detail-views the one after the other. (button "Enter the exploration mode" in list-views). # The link in the list-views selectors are now opened in other tabs automatically. + # The .XLS file export now displays an error when its maximum number of rows is exceeded. # In forms : - The custom fields with ENUM/MULTI_ENUM type are now using the Select2 combobox with lazy loading & search. - In the form for entity-filters : diff --git a/creme/creme_config/apps.py b/creme/creme_config/apps.py index 15c74db918..5109793df9 100644 --- a/creme/creme_config/apps.py +++ b/creme/creme_config/apps.py @@ -1,6 +1,6 @@ ################################################################################ # Creme is a free/open-source Customer Relationship Management software -# Copyright (C) 2015-2022 Hybird +# Copyright (C) 2015-2023 Hybird # # 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 @@ -17,6 +17,7 @@ ################################################################################ from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext from creme.creme_core.apps import CremeAppConfig @@ -31,9 +32,45 @@ class CremeConfigConfig(CremeAppConfig): def all_apps_ready(self): super().all_apps_ready() + self.hook_password_validators() + from .registry import config_registry self.populate_config_registry(config_registry) + # TODO: define our own classes? + def hook_password_validators(self): + from django.contrib.auth import password_validation + + # --- + def minlen_get_help_text(this): + return ngettext( + 'The password must contain at least %(min_length)d character.', + 'The password must contain at least %(min_length)d characters.', + this.min_length, + ) % {'min_length': this.min_length} + + password_validation.MinimumLengthValidator.get_help_text = minlen_get_help_text + + # --- + def personal_get_help_text(self): + return _( + "The password can’t be too similar to the other personal information." + ) + + password_validation.UserAttributeSimilarityValidator.get_help_text = personal_get_help_text + + # --- + def common_get_help_text(self): + return _("The password can’t be a commonly used password.") + + password_validation.CommonPasswordValidator.get_help_text = common_get_help_text + + # --- + def numeric_get_help_text(self): + return _("The password can’t be entirely numeric.") + + password_validation.NumericPasswordValidator.get_help_text = numeric_get_help_text + def populate_config_registry(self, config_registry): from creme.creme_core.apps import creme_app_configs diff --git a/creme/creme_config/forms/user.py b/creme/creme_config/forms/user.py index 1630cc1f82..28df0b0a2a 100644 --- a/creme/creme_config/forms/user.py +++ b/creme/creme_config/forms/user.py @@ -159,7 +159,8 @@ class UserPasswordChangeForm(CremeForm): } old_password = CharField( - label=_('Old password'), + # label=_('Old password'), + label=_('Your old password'), strip=False, widget=PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}), ) @@ -177,9 +178,16 @@ class UserPasswordChangeForm(CremeForm): ) def __init__(self, *args, **kwargs): - self.user2edit = kwargs.pop('instance') + self.user2edit = user2edit = kwargs.pop('instance') super().__init__(*args, **kwargs) + if self.user != user2edit: + fields = self.fields + fields['old_password'].label = _('Your password') + fields['password_1'].label = gettext( + 'New password for «{user}»' + ).format(user=user2edit) + def clean_old_password(self): old_password = self.cleaned_data["old_password"] diff --git a/creme/creme_config/locale/fr/LC_MESSAGES/django.mo b/creme/creme_config/locale/fr/LC_MESSAGES/django.mo index acccddea43..57001bbc2d 100644 Binary files a/creme/creme_config/locale/fr/LC_MESSAGES/django.mo and b/creme/creme_config/locale/fr/LC_MESSAGES/django.mo differ diff --git a/creme/creme_config/locale/fr/LC_MESSAGES/django.po b/creme/creme_config/locale/fr/LC_MESSAGES/django.po index 7b4506df78..3763941c6d 100644 --- a/creme/creme_config/locale/fr/LC_MESSAGES/django.po +++ b/creme/creme_config/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Creme Creme-Config 2.5\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-06-20 10:12+0200\n" +"POT-Creation-Date: 2023-09-06 13:44+0200\n" "Last-Translator: Hybird \n" "Language: fr\n" "MIME-Version: 1.0\n" @@ -19,6 +19,26 @@ msgstr "" msgid "General configuration" msgstr "Configuration générale" +#, python-format +msgid "The password must contain at least %(min_length)d character." +msgid_plural "The password must contain at least %(min_length)d characters." +msgstr[0] "" +"Le mot de passe doit contenir au minimum %(min_length)d caractère." +msgstr[1] "" +"Le mot de passe doit contenir au minimum %(min_length)d caractères." + +msgid "The password can’t be too similar to the other personal information." +msgstr "" +"Le mot de passe ne peut pas trop ressembler aux autres informations " +"personnelles." + +msgid "The password can’t be a commonly used password." +msgstr "" +"Le mot de passe ne peut pas être un mot de passe couramment utilisé." + +msgid "The password can’t be entirely numeric." +msgstr "Le mot de passe ne peut pas être entièrement numérique." + msgid "Export & import configuration" msgstr "Exporter & importer la configuration" @@ -702,9 +722,8 @@ msgstr "Confirmation du mot de passe" msgid "Enter the same password as before, for verification." msgstr "" -# Already translated in django.auth -msgid "Old password" -msgstr "" +msgid "Your old password" +msgstr "Votre ancien mot de passe" # Already translated in django.auth msgid "New password" @@ -714,6 +733,13 @@ msgstr "" msgid "New password confirmation" msgstr "" +msgid "Your password" +msgstr "Votre mot de passe" + +#, python-brace-format +msgid "New password for «{user}»" +msgstr "Nouveau mot de passe pour «{user}»" + msgid "Teammates" msgstr "Coéquipiers" @@ -2403,6 +2429,9 @@ msgstr "Importer" msgid "Change password for «{object}»" msgstr "Changer le mot de passe pour «{object}»" +msgid "Change your password" +msgstr "Changer votre mot de passe" + msgid "Save the team" msgstr "Enregistrer l'équipe" diff --git a/creme/creme_config/tests/test_user.py b/creme/creme_config/tests/test_user.py index 25e208ed88..f4666c8bae 100644 --- a/creme/creme_config/tests/test_user.py +++ b/creme/creme_config/tests/test_user.py @@ -1044,10 +1044,25 @@ def test_change_password01(self): # GET --- response1 = self.assertGET200(url) self.assertTemplateUsed(response1, 'creme_core/generics/blockform/edit-popup.html') + + context1 = response1.context self.assertEqual( _('Change password for «{object}»').format(object=other_user), - response1.context.get('title'), + context1.get('title'), + ) + + with self.assertNoException(): + fields = context1['form'].fields + old_password_f = fields['old_password'] + password1_f = fields['password_1'] + password2_f = fields['password_2'] + + self.assertEqual(_('Your password'), old_password_f.label) + self.assertEqual( + _('New password for «{user}»').format(user=other_user), + password1_f.label, ) + self.assertEqual(_('New password confirmation'), password2_f.label) # POST (error) --- new_password = 'password' @@ -1082,6 +1097,7 @@ def test_change_password01(self): @skipIfNotCremeUser def test_change_password02(self): + "Not administrator." self.login_as_config_admin() other_user = User.objects.create(username='deunan') @@ -1125,8 +1141,19 @@ def test_change_password04(self): other_user = User.objects.create(username='deunan') url = self._build_edit_url(other_user.id, password=True) - self.assertGET200(url) + response1 = self.assertGET200(url) + + with self.assertNoException(): + password1_f = response1.context['form'].fields['password_1'] + msg = ngettext( + "The password must contain at least %(min_length)d character.", + "The password must contain at least %(min_length)d characters.", + 8, + ) % {"min_length": 8} + self.assertHTMLEqual(f'', password1_f.help_text) + + # --- password = 'pass' response = self.assertPOST200( url, data={'password_1': password, 'password_2': password} @@ -1141,6 +1168,58 @@ def test_change_password04(self): ) % {'min_length': 8}, ) + @skipIfNotCremeUser + def test_change_own_password(self): + user = self.login_as_root_and_get() + + url = self._build_edit_url(user.id, password=True) + + # GET --- + response1 = self.assertGET200(url) + + context1 = response1.context + self.assertEqual(_('Change your password'), context1.get('title')) + + with self.assertNoException(): + fields = context1['form'].fields + old_password_f = fields['old_password'] + password1_f = fields['password_1'] + password2_f = fields['password_2'] + + self.assertEqual(_('Your old password'), old_password_f.label) + self.assertEqual(_('New password'), password1_f.label) + self.assertEqual(_('New password confirmation'), password2_f.label) + + # POST (error) --- + new_password = 'password' + response2 = self.assertPOST200( + url, + follow=True, + data={ + 'old_password': 'mismatch', + 'password_1': new_password, + 'password_2': new_password, + }, + ) + self.assertFormError( + response2.context['form'], + field='old_password', + errors=_('Your old password was entered incorrectly. Please enter it again.'), + ) + + # POST --- + response3 = self.client.post( + url, + follow=True, + data={ + 'old_password': ROOT_PASSWORD, + 'password_1': new_password, + 'password_2': new_password, + }, + ) + self.assertNoFormError(response3) + self.assertTrue(self.refresh(user).check_password(new_password)) + @skipIfNotCremeUser def test_user_activation(self): # self.login() diff --git a/creme/creme_config/views/user.py b/creme/creme_config/views/user.py index 902726a014..753fed4f53 100644 --- a/creme/creme_config/views/user.py +++ b/creme/creme_config/views/user.py @@ -49,11 +49,17 @@ class PasswordChange(generic.CremeModelEditionPopup): pk_url_kwarg = 'user_id' permissions = SUPERUSER_PERM title = _('Change password for «{object}»') + title_for_own = _('Change your password') @method_decorator(sensitive_post_parameters()) def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) + def get_title(self) -> str: + title = self.title_for_own if self.get_object() == self.request.user else self.title + + return title.format(**self.get_title_format_data()) + class BaseUserCreation(generic.CremeModelCreationPopup): model = get_user_model() diff --git a/creme/creme_core/backends/base.py b/creme/creme_core/backends/base.py index c73cac16ef..5f4cd08d20 100644 --- a/creme/creme_core/backends/base.py +++ b/creme/creme_core/backends/base.py @@ -71,3 +71,6 @@ def save(self, filename: str, user): instance of . """ raise NotImplementedError + + def validate(self, *, total_count=None): + pass diff --git a/creme/creme_core/backends/xls_export.py b/creme/creme_core/backends/xls_export.py index c733dd70b9..51d7388d69 100644 --- a/creme/creme_core/backends/xls_export.py +++ b/creme/creme_core/backends/xls_export.py @@ -21,8 +21,10 @@ from django.conf import settings from django.http import HttpResponseRedirect from django.template.defaultfilters import slugify +from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ +from ..core.exceptions import ConflictError from ..models import FileRef from ..utils.file_handling import FileCreator from ..utils.xlwt_utils import XlwtWriter @@ -34,6 +36,7 @@ class XLSExportBackend(ExportBackend): verbose_name = _('XLS File') help_text = '' dir_parts = ('xls',) # Sub-directory under settings.MEDIA_ROOT + max_row_index = 65535 def __init__(self, encoding='utf-8'): super().__init__() @@ -56,3 +59,12 @@ def save(self, filename, user): def writerow(self, row): self.writer.writerow(row) + + def validate(self, *, total_count=None): + if total_count is not None and total_count > self.max_row_index: + raise ConflictError( + gettext( + "The Microsoft Excel 97–2003 Workbook (.xls) format has a limit of" + " {count} rows. Use a CSV format instead.".format(count=self.max_row_index) + ) + ) diff --git a/creme/creme_core/locale/fr/LC_MESSAGES/django.po b/creme/creme_core/locale/fr/LC_MESSAGES/django.po index 54d26ec34a..85753f6b06 100644 --- a/creme/creme_core/locale/fr/LC_MESSAGES/django.po +++ b/creme/creme_core/locale/fr/LC_MESSAGES/django.po @@ -75,6 +75,13 @@ msgstr "" msgid "XLS File" msgstr "Fichier XLS" +#, python-brace-format +msgid "" +"The Microsoft Excel 97–2003 Workbook (.xls) format has a limit of {count} " +"rows. Use a CSV format instead." +msgstr "Le format Microsoft Excel 97–2003 Workbook (.xls) est limité à {count} lignes. " +"Utilisez un format CSV à la place." + msgid "" "XLS is a file extension for a spreadsheet file format created by Microsoft " "for use with Microsoft Excel (Excel 97-2003 Workbook)." diff --git a/creme/creme_core/static/creme_core/js/lib/color.js b/creme/creme_core/static/creme_core/js/lib/color.js index 970f7d912b..11f96ad271 100644 --- a/creme/creme_core/static/creme_core/js/lib/color.js +++ b/creme/creme_core/static/creme_core/js/lib/color.js @@ -204,8 +204,12 @@ RGBColor.prototype = { return clamp(scaleround(c, 3), 1, 21); }, + isDark: function(gamma) { + return this.contrast(0, gamma) < 10; + }, + foreground: function(gamma) { - return this.contrast(0, gamma) < 10 ? new RGBColor(0xFFFFFF) : new RGBColor(0x000000); + return this.isDark(gamma) ? new RGBColor(0xFFFFFF) : new RGBColor(0x000000); } }; diff --git a/creme/creme_core/static/creme_core/js/widgets/dialog/dialog.js b/creme/creme_core/static/creme_core/js/widgets/dialog/dialog.js index e8f42ef2d2..bd4c9bc7d7 100644 --- a/creme/creme_core/static/creme_core/js/widgets/dialog/dialog.js +++ b/creme/creme_core/static/creme_core/js/widgets/dialog/dialog.js @@ -117,6 +117,10 @@ creme.dialog.Dialog = creme.component.Component.sub({ } this._events.trigger('frame-activated', [this.frame()], this); + + if (this.options.closeOnEscape) { + this.resetFocus(); + } }, _dialogBackground: function() { @@ -171,7 +175,7 @@ creme.dialog.Dialog = creme.component.Component.sub({ // HACK : force focus in order to enable escape handling. if (options.closeOnEscape && !options.autoFocus) { - this.focus(); + this.resetFocus(); } if (options.scrollbackOnClose) { @@ -476,8 +480,25 @@ creme.dialog.Dialog = creme.component.Component.sub({ return this._dialog; }, + resetFocus: function() { + // When ui.dialog takes focus on a tabbable element, the element is stored in _focusedElement, + // but if the element disappear (removed button, frame update, ..) we cannot get the focus + // back without a mousedown event at the right place or... this little hack. + if (this.isOpened()) { + var instance = this._dialog.dialog("instance"); + instance._focusedElement = null; + instance._focusTabbable(); + } + + return this; + }, + focus: function() { - this._dialogContainer().trigger('focus'); + if (this.isOpened()) { + var instance = this._dialog.dialog("instance"); + instance._focusTabbable(); + } + return this; }, diff --git a/creme/creme_core/tests/test_backends.py b/creme/creme_core/tests/test_backends.py index ed7693016f..47c5c9fc76 100644 --- a/creme/creme_core/tests/test_backends.py +++ b/creme/creme_core/tests/test_backends.py @@ -2,6 +2,8 @@ from creme.creme_core.backends.csv_import import CSVImportBackend from creme.creme_core.backends.xls_import import XLSImportBackend +from ..backends.xls_export import XLSExportBackend +from ..core.exceptions import ConflictError from .base import CremeTestCase @@ -56,3 +58,14 @@ def test_registration_errors03(self): with self.assertRaises(registry.InvalidClass): registry.get_backend_class(CSVImportBackend.id) + + +class XLSExportBackendTestCase(CremeTestCase): + def test_validate_total_count__lte_max(self): + backend = XLSExportBackend() + backend.validate(total_count=XLSExportBackend.max_row_index) + + def test_validate_total_count__gt_max(self): + backend = XLSExportBackend() + with self.assertRaises(ConflictError): + backend.validate(total_count=XLSExportBackend.max_row_index + 1) diff --git a/creme/creme_core/tests/views/test_mass_export.py b/creme/creme_core/tests/views/test_mass_export.py index 98bf24eef8..950a979f72 100644 --- a/creme/creme_core/tests/views/test_mass_export.py +++ b/creme/creme_core/tests/views/test_mass_export.py @@ -14,6 +14,8 @@ from django.utils.translation import gettext as _ from django.utils.translation import pgettext +from creme.creme_core.backends import _BackendRegistry +from creme.creme_core.backends.base import ExportBackend from creme.creme_core.core.entity_cell import ( EntityCellFunctionField, EntityCellRegularField, @@ -23,6 +25,7 @@ RegularFieldConditionHandler, ) from creme.creme_core.core.entity_filter.operators import ISTARTSWITH +from creme.creme_core.core.exceptions import ConflictError from creme.creme_core.gui.history import html_history_registry from creme.creme_core.models import ( CremeProperty, @@ -45,16 +48,41 @@ from creme.creme_core.utils.content_type import as_ctype from creme.creme_core.utils.queries import QSerializer from creme.creme_core.utils.xlrd_utils import XlrdReader +from creme.creme_core.views.mass_export import MassExport from .base import ViewsTestCase +class TestExportBackend(ExportBackend): + id: str = 'test_backend_validator' + verbose_name: str = 'test_backend_validator' + help_text: str = 'test_backend_validator' + + def writerow(self, row): + pass + + def validate(self, **kwargs): + raise ConflictError("TestExportBackend.validate fail") + + class MassExportViewsTestCase(ViewsTestCase): + backup_mass_export_backend_registry = None + @classmethod def setUpClass(cls): super().setUpClass() cls.ct = ContentType.objects.get_for_model(FakeContact) + cls.backup_mass_export_backend_registry = MassExport.backend_registry + MassExport.backend_registry = _BackendRegistry(ExportBackend, [ + *settings.EXPORT_BACKENDS, + "creme.creme_core.tests.views.test_mass_export.TestExportBackend" + ]) + + @classmethod + def tearDownClass(cls): + MassExport.backend_registry = cls.backup_mass_export_backend_registry + # def _build_hf_n_contacts(self, user=None): def _build_hf_n_contacts(self, user): # if user is None: @@ -529,6 +557,11 @@ def test_extra_filter(self): # Error self.assertGET(400, self._build_contact_dl_url(extra_q='[123]')) + def test_backend_validator(self): + self.login_as_root() + response = self.assertGET409(self._build_contact_dl_url(doc_type=TestExportBackend.id)) + self.assertContains(response, 'TestExportBackend.validate fail', status_code=409) + def test_list_view_export_with_filter01(self): # user = self.login() user = self.login_as_root_and_get() diff --git a/creme/creme_core/views/mass_export.py b/creme/creme_core/views/mass_export.py index 36a3f1da6f..ce1e51c6ff 100644 --- a/creme/creme_core/views/mass_export.py +++ b/creme/creme_core/views/mass_export.py @@ -61,6 +61,8 @@ class MassExport(base.EntityCTypeRelatedMixin, base.CheckedView): search_field_registry = search_field_registry search_form_class = ListViewSearchForm + backend_registry = export_backend_registry + def check_related_ctype(self, ctype): super().check_related_ctype(ctype=ctype) @@ -69,7 +71,7 @@ def check_related_ctype(self, ctype): def get_backend_class(self): doc_type = get_from_GET_or_404(self.request.GET, self.doc_type_arg) - backend_class = export_backend_registry.get_backend_class(doc_type) + backend_class = self.backend_registry.get_backend_class(doc_type) if backend_class is None: raise Http404(f'No such exporter for extension "{doc_type}"') @@ -227,6 +229,8 @@ def get(self, request, *args, **kwargs): if use_distinct: entities_qs = entities_qs.distinct() + writer.validate(total_count=entities_qs.count()) + paginator = self.get_paginator(queryset=entities_qs, ordering=ordering) total_count = 0 diff --git a/creme/reports/static/chantilly/reports/css/reports.css b/creme/reports/static/chantilly/reports/css/reports.css index a81bcb8a01..48f1f3ff31 100644 --- a/creme/reports/static/chantilly/reports/css/reports.css +++ b/creme/reports/static/chantilly/reports/css/reports.css @@ -254,9 +254,16 @@ button.reports-chart-selectors-refresh { cursor: ew-resize; } +.tube-chart .bar rect { + shape-rendering: crispedges; +} + .tube-chart .bar text { text-anchor: middle; - font-weight: bold; +} + +.tube-chart .bar text.dark-bg { + font-weight: 600; } .tube-chart .bar:hover { diff --git a/creme/reports/static/icecream/reports/css/reports.css b/creme/reports/static/icecream/reports/css/reports.css index b434f000f2..f2fce3b4e4 100644 --- a/creme/reports/static/icecream/reports/css/reports.css +++ b/creme/reports/static/icecream/reports/css/reports.css @@ -258,9 +258,16 @@ button.reports-chart-selectors-refresh { cursor: ew-resize; } +.tube-chart .bar rect { + shape-rendering: crispedges; +} + .tube-chart .bar text { text-anchor: middle; - font-weight: bold; +} + +.tube-chart .bar text.dark-bg { + font-weight: 600; } .tube-chart .bar:hover { diff --git a/creme/reports/static/reports/js/chart/tubechart.js b/creme/reports/static/reports/js/chart/tubechart.js index cc3089f07b..5bba587529 100644 --- a/creme/reports/static/reports/js/chart/tubechart.js +++ b/creme/reports/static/reports/js/chart/tubechart.js @@ -42,14 +42,12 @@ creme.D3TubeChart = creme.D3Chart.sub({ font: "10px sans-serif" }, ".tube-chart .bar rect": { - fill: props.barColor, - "shape-rendering": "crispEdges" - }, - ".tube-chart .bar.selected rect": { - fill: props.barSelected + "shape-rendering": "crispedges" }, ".tube-chart .bar text": { - "text-anchor": "middle", + "text-anchor": "middle" + }, + ".tube-chart .bar .dark-bg": { "font-weight": "bold" } }); @@ -89,21 +87,21 @@ creme.D3TubeChart = creme.D3Chart.sub({ _updateChart: function(sketch, chart, data, props) { var scrollLegend = props.scrollLegend; - var bounds = creme.svgBounds(sketch.size(), props.margin); + var bounds = creme.svgBounds(sketch.size(), props.margin, { + left: 5, + right: 5 + }); var colors = creme.d3ColorRange(props.colors, {size: data.length}); var colorScale = d3.scaleOrdinal().domain([0, data.length]).range(colors); var xscale = d3.scaleLinear(); var xkeys = data.map(function(d) { return d.x; }); + var colorize = creme.d3Colorize().scale(colorScale); data = this.hierarchy(data); // pre-compute colors - data.forEach(function(d) { - d.background = colorScale(d.x); - d.color = new RGBColor(d.background).foreground(); - }); - + data = colorize(data); data = data.filter(function(d) { return d.y > 0; }); var yDataInfo = creme.d3NumericDataInfo(data, function(d) { return d.endX; }); @@ -138,7 +136,7 @@ creme.D3TubeChart = creme.D3Chart.sub({ } xscale.domain([0, ymax]) - .range([0, bounds.width], 0.1); + .range([0, bounds.width]); var xAxisTicks = yDataInfo.integer ? Math.min(yDataInfo.gap, 8) : 8; @@ -214,13 +212,27 @@ creme.D3TubeChart = creme.D3Chart.sub({ }); }, + itemClasses: function(d, context) { + var classes = []; + + if (Math.abs(context.xscale(d.y)) < context.textMinVisibleSize) { + classes.push('hidden-label'); + } + + if (d.isDarkColor) { + classes.push('dark-bg'); + } + + return classes.join(' '); + }, + _enterStack: function(enter, context) { + var self = this; var selection = this.selection(); var xscale = context.xscale; var textformat = context.textformat; var textAlignRatio = context.textAlignRatio; - var textVisibleMinSize = context.textVisibleMinSize; var bounds = context.bounds; var bar = enter.append('g') @@ -234,24 +246,23 @@ creme.D3TubeChart = creme.D3Chart.sub({ .attr('x', 1) .attr('width', function(d) { return xscale(d.y); }) .attr('height', bounds.height) - .attr("fill", function(d) { return d.background; }) + .attr("fill", function(d) { return d.color; }) .on('click', function(e, d) { selection.select(d.index); }); bar.append('text') .attr('dy', '.75em') .attr('y', Math.ceil(bounds.height / 2)) - // .attr('x', function(d) { return (bounds.width - xscale(d.y)) > 15 ? 6 : -12; }) .attr('x', function(d) { return xscale(d.y) * textAlignRatio; }) - .attr('class', function(d) { return xscale(d.y) < textVisibleMinSize ? 'hidden-label' : ''; }) - .attr('fill', function(d) { return d.color; }) + .attr('class', function(d) { return self.itemClasses(d, context); }) + .attr('fill', function(d) { return d.textColor; }) .text(function(d) { return textformat(d.y); }); }, _updateStack: function(update, context) { + var self = this; var xscale = context.xscale; var textformat = context.textformat; var textAlignRatio = context.textAlignRatio; - var textVisibleMinSize = context.textVisibleMinSize; var bounds = context.bounds; update.selection() @@ -264,13 +275,13 @@ creme.D3TubeChart = creme.D3Chart.sub({ update.select('rect') .attr('width', function(d) { return xscale(d.y); }) .attr('height', bounds.height) - .attr("fill", function(d) { return d.background; }); + .attr("fill", function(d) { return d.color; }); update.select('text') .attr('y', Math.ceil(bounds.height / 2)) .attr('x', function(d) { return xscale(d.y) * textAlignRatio; }) - .attr('class', function(d) { return xscale(d.y) < textVisibleMinSize ? 'hidden-label' : ''; }) - .attr('fill', function(d) { return d.color; }) + .attr('class', function(d) { return self.itemClasses(d, context); }) + .attr('fill', function(d) { return d.textColor; }) .text(function(d) { return textformat(d.y); }); } }); diff --git a/creme/reports/templates/reports/bricks/report-chart-list.html b/creme/reports/templates/reports/bricks/report-chart-list.html index 3289b6ae21..7819bb60ce 100644 --- a/creme/reports/templates/reports/bricks/report-chart-list.html +++ b/creme/reports/templates/reports/bricks/report-chart-list.html @@ -124,7 +124,7 @@ }), barchart: new creme.D3BarChart({ margin: {top: 3, left: 3, right: 3}, - barColor: creme.d3SpectralColors + colors: creme.d3SpectralColors }), tubechart: new creme.D3TubeChart({ margin: {top: 3, left: 3, right: 3}, diff --git a/creme/reports/templates/reports/bricks/report-chart.html b/creme/reports/templates/reports/bricks/report-chart.html index 1be15a071c..601311b0a7 100644 --- a/creme/reports/templates/reports/bricks/report-chart.html +++ b/creme/reports/templates/reports/bricks/report-chart.html @@ -81,7 +81,7 @@ }), barchart: new creme.D3BarChart({ margin: {top: 3, left: 3, right: 3}, - barColor: creme.d3SpectralColors + colors: creme.d3SpectralColors }), tubechart: new creme.D3TubeChart({ margin: {top: 3, left: 3, right: 3}, diff --git a/creme/settings.py b/creme/settings.py index d13e40a125..faeda3e8d9 100644 --- a/creme/settings.py +++ b/creme/settings.py @@ -930,6 +930,7 @@ ('creme.creme_config', 'creme_config/js/settings-menu.js'), ('creme.sketch', 'sketch/js/utils.js'), + ('creme.sketch', 'sketch/js/color.js'), ('creme.sketch', 'sketch/js/sketch.js'), ('creme.sketch', 'sketch/js/chart.js'), ('creme.sketch', 'sketch/js/bricks.js'), diff --git a/creme/sketch/static/chantilly/sketch/css/sketch.css b/creme/sketch/static/chantilly/sketch/css/sketch.css index b3fdeae9d3..c6005ab625 100644 --- a/creme/sketch/static/chantilly/sketch/css/sketch.css +++ b/creme/sketch/static/chantilly/sketch/css/sketch.css @@ -79,7 +79,7 @@ text-anchor: middle; } -.bar-chart .bar text.inner { +.bar-chart .bar text.inner.dark-bg { font-weight: 600; } @@ -109,6 +109,10 @@ visibility: hidden; } +.donut-chart .slice text.dark-bg { + font-weight: 600; +} + .area-chart .area { fill: #4682b4; } diff --git a/creme/sketch/static/icecream/sketch/css/sketch.css b/creme/sketch/static/icecream/sketch/css/sketch.css index b3fdeae9d3..c6005ab625 100644 --- a/creme/sketch/static/icecream/sketch/css/sketch.css +++ b/creme/sketch/static/icecream/sketch/css/sketch.css @@ -79,7 +79,7 @@ text-anchor: middle; } -.bar-chart .bar text.inner { +.bar-chart .bar text.inner.dark-bg { font-weight: 600; } @@ -109,6 +109,10 @@ visibility: hidden; } +.donut-chart .slice text.dark-bg { + font-weight: 600; +} + .area-chart .area { fill: #4682b4; } diff --git a/creme/sketch/static/sketch/js/chart/barchart.js b/creme/sketch/static/sketch/js/chart/barchart.js index 744bcb8a42..8c9da03db6 100644 --- a/creme/sketch/static/sketch/js/chart/barchart.js +++ b/creme/sketch/static/sketch/js/chart/barchart.js @@ -27,10 +27,7 @@ creme.D3BarChart = creme.D3Chart.sub({ yAxisTitle: '', yAxisTickFormat: null, barMinWidth: 60, - barColor: "#4682b4", - barHilighted: "#66a2d4", - barSelected: "#d6c2f4", - barTextColor: "#fff", + colors: "#4682b4", barTextFormat: null, limits: [], margin: 0, @@ -50,7 +47,7 @@ creme.D3BarChart = creme.D3Chart.sub({ font: "10px sans-serif" }, ".bar-chart .bar rect": { - "shape-rendering": "crispEdges" + "shape-rendering": "crispedges" }, ".bar-chart .bar.selected rect": { "opacity": "0.8" @@ -58,8 +55,8 @@ creme.D3BarChart = creme.D3Chart.sub({ ".bar-chart .bar text": { "text-anchor": "middle" }, - ".bar-chart .bar text.inner": { - fill: props.barTextColor + ".bar-chart .bar text.inner.dark-bg": { + "font-weight": 600 }, ".bar-chart .limit": { stroke: "#f6c2d4", @@ -111,6 +108,7 @@ creme.D3BarChart = creme.D3Chart.sub({ index: i, x: d.x, y: d.y, + color: d.color, data: d }; }); @@ -146,9 +144,12 @@ creme.D3BarChart = creme.D3Chart.sub({ // 6px tick line + 3em width for the label var yAxisSize = Math.max(props.yAxisSize, 6 + (yAxisFontSize * 3)); - var colorScale = d3.scaleOrdinal() - .domain([0, data.length]) - .range(creme.d3ColorRange(props.barColor, {size: data.length})); + var colorize = creme.d3Colorize() + .scale(d3.scaleOrdinal() + .domain([0, data.length]) + .range(creme.d3ColorRange(props.colors, {size: data.length}))); + + data = colorize(data); chart.attr('transform', creme.svgTransform().translate(bounds.left, bounds.top)); @@ -209,7 +210,6 @@ creme.D3BarChart = creme.D3Chart.sub({ var context = { bounds: bounds, - colorScale: colorScale, xscale: xscale, yscale: yscale, textformat: barTextFormat @@ -242,7 +242,6 @@ creme.D3BarChart = creme.D3Chart.sub({ var xscale = context.xscale; var yscale = context.yscale; - var colorScale = context.colorScale; var textformat = context.textformat; var bounds = context.bounds; @@ -261,13 +260,16 @@ creme.D3BarChart = creme.D3Chart.sub({ .attr('x', 1) .attr('width', xscale.bandwidth()) .attr('height', function(d) { return bounds.height - yscale(d.y); }) - .attr("fill", function(d) { return colorScale(d.x); }) + .attr("fill", function(d) { return d.color; }) .on('click', function(e, d) { selection.select(d.index); }); bar.append('text') + .classed('dark-bg', function(d) { return d.isDarkColor; }) .attr('dy', '.75em') - .attr('class', function(d) { return isInnerText(d) ? 'inner' : 'outer'; }) - .attr('fill', function(d) { return isInnerText(d) ? new RGBColor(colorScale(d.x)).foreground() : 'black'; }) + .attr('class', function(d) { + return isInnerText(d) ? (d.isDarkColor ? 'inner dark-bg' : 'inner') : 'outer'; + }) + .attr('fill', function(d) { return d.textColor; }) .attr('y', function(d) { return isInnerText(d) ? 6 : -12; }) .attr('x', Math.ceil(xscale.bandwidth() / 2)) .text(function(d) { return textformat(d.y); }); @@ -276,7 +278,6 @@ creme.D3BarChart = creme.D3Chart.sub({ _updateBar: function(update, context) { var xscale = context.xscale; var yscale = context.yscale; - var colorScale = context.colorScale; var textformat = context.textformat; var bounds = context.bounds; @@ -291,13 +292,15 @@ creme.D3BarChart = creme.D3Chart.sub({ }); update.select('rect') - .attr("fill", function(d) { return colorScale(d.x); }) + .attr("fill", function(d) { return d.color; }) .attr('width', xscale.bandwidth()) .attr('height', function(d) { return bounds.height - yscale(d.y); }); update.select('text') - .attr('class', function(d) { return isInnerText(d) ? 'inner' : 'outer'; }) - .attr('fill', function(d) { return isInnerText(d) ? new RGBColor(colorScale(d.x)).foreground() : 'black'; }) + .attr('class', function(d) { + return isInnerText(d) ? (d.isDarkColor ? 'inner dark-bg' : 'inner') : 'outer'; + }) + .attr('fill', function(d) { return d.textColor; }) .attr('y', function(d) { return isInnerText(d) ? 6 : -12; }) .attr('x', Math.ceil(xscale.bandwidth() / 2)) .text(function(d) { return textformat(d.y); }); diff --git a/creme/sketch/static/sketch/js/chart/donutchart.js b/creme/sketch/static/sketch/js/chart/donutchart.js index c2996d491a..1fa470c3bb 100644 --- a/creme/sketch/static/sketch/js/chart/donutchart.js +++ b/creme/sketch/static/sketch/js/chart/donutchart.js @@ -62,6 +62,23 @@ creme.D3DonutChart = creme.D3Chart.sub({ }; }, + exportStyle: function(props) { + return creme.svgRulesAsCSS({ + ".donut-chart": { + font: "10px sans-serif" + }, + ".donut-chart .arc path": { + stroke: "#fff" + }, + ".donut-chart .slice .hidden-label": { + visibility: "visible" + }, + ".donut-chart .slice text.dark-bg": { + "font-weight": 600 + } + }); + }, + chartData: function(data) { return data.map(function(d, i) { return { @@ -69,6 +86,7 @@ creme.D3DonutChart = creme.D3Chart.sub({ x: d.x, y: d.y, selected: d.selected, + color: d.color, data: d }; }); @@ -98,6 +116,8 @@ creme.D3DonutChart = creme.D3Chart.sub({ .domain([0, data.length]) .range(creme.d3ColorRange(colors)); + var colorize = creme.d3Colorize().scale(colorScale); + var pielayout = d3.pie() .sort(null) .value(function(d) { return d.y; }); @@ -141,17 +161,18 @@ creme.D3DonutChart = creme.D3Chart.sub({ bounds.top + (bounds.height / 2) )); + // pre-compute colors + data = colorize(data); + data = pielayout(data.filter(function(d) { return d.y > 0; })); + var items = chart.select('.slices') .selectAll('.slice') - .data(pielayout(data.filter(function(d) { - return d.y > 0; - }))); + .data(data); var context = { arcpath: arcpath, textArc: textArc, textVisibleMinAngle: props.textVisibleMinAngle, - colorScale: colorScale, formatValue: textFormat, transition: props.transition }; @@ -179,11 +200,25 @@ creme.D3DonutChart = creme.D3Chart.sub({ return chart; }, + itemClasses: function(d, context) { + var classes = []; + + if (Math.abs(d.startAngle - d.endAngle) < context.textVisibleMinAngle) { + classes.push('hidden-label'); + } + + if (d.data.isDarkColor) { + classes.push('dark-bg'); + } + + return classes.join(' '); + }, + _enterItem: function(item, context) { + var self = this; var selection = this.selection(); var arcpath = context.arcpath; - var colorScale = context.colorScale; var formatValue = context.formatValue; var textArc = context.textArc; @@ -193,7 +228,7 @@ creme.D3DonutChart = creme.D3Chart.sub({ arc.append("path") .attr('d', arcpath) - .attr("fill", function(d) { return colorScale(d.data.x); }) + .attr("fill", function(d) { return d.data.color; }) .on('click', function(e, d) { selection.select(d.data.index); }); arc.append("text") @@ -205,16 +240,15 @@ creme.D3DonutChart = creme.D3Chart.sub({ return creme.svgTransform().translate(pos[0], pos[1]); }) .attr('class', function(d) { - return Math.abs(d.startAngle - d.endAngle) < context.textVisibleMinAngle ? 'hidden-label' : ''; + return self.itemClasses(d, context); }) - .attr('fill', function(d) { - return new RGBColor(colorScale(d.data.x)).foreground(); - }); + .attr('fill', function(d) { return d.data.textColor; }) + .classed('dark-bg', function(d) { return d.data.isDarkColor; }); }, _updateItem: function(item, context) { + var self = this; var arcpath = context.arcpath; - var colorScale = context.colorScale; var formatValue = context.formatValue; var textArc = context.textArc; @@ -236,7 +270,7 @@ creme.D3DonutChart = creme.D3Chart.sub({ } item.select('path') - .attr("fill", function(d) { return colorScale(d.data.x); }); + .attr("fill", function(d) { return d.data.color; }); item.select("text") .text(function(d) { return formatValue(d.data.y); }) @@ -244,9 +278,10 @@ creme.D3DonutChart = creme.D3Chart.sub({ var pos = textArc.centroid(d); return creme.svgTransform().translate(pos[0], pos[1]); }) - .attr('fill', function(d) { - return new RGBColor(colorScale(d.data.x)).foreground(); - }); + .attr('fill', function(d) { return d.data.textColor; }) + .attr('class', function(d) { + return self.itemClasses(d, context); + }); } }); diff --git a/creme/sketch/static/sketch/js/color.js b/creme/sketch/static/sketch/js/color.js new file mode 100644 index 0000000000..482edfa083 --- /dev/null +++ b/creme/sketch/static/sketch/js/color.js @@ -0,0 +1,87 @@ +/******************************************************************************* + Creme is a free/open-source Customer Relationship Management software + Copyright (C) 2023 Hybird + + 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 . +*******************************************************************************/ + +(function($) { +"use strict"; + +creme.d3ColorRange = function(colors, options) { + if (Array.isArray(colors)) { + return colors; + } else if (Object.isFunc(colors)) { + return colors(options); + } else { + return [colors]; + } +}; + +creme.d3SpectralColors = function(options) { + options = $.extend({ + start: 0, + step: 1.0, + size: 2 + }, options || {}); + + return d3.quantize(function(t) { + return d3.interpolateSpectral(t * options.step + options.start); + }, Math.max(options.size, 2)); +}; + +creme.d3Colorize = function() { + var props = { + scale: function(d) { return 'black'; } + }; + + function colorize(data) { + return data.map(function(d) { + var color = props.color ? props.color(d) : d.color; + var textColor = props.textColor ? props.textColor(d) : d.textColor; + + d.color = color || props.scale(d.x); + + if (textColor) { + d.textColor = textColor; + } else { + var rgbColor = new RGBColor(d.color); + + d.isDarkColor = rgbColor.isDark(); + d.textColor = d.isDarkColor ? 'white' : 'black'; + } + + return d; + }); + } + + colorize.scale = function(scale) { + props.scale = scale; + return colorize; + }; + + colorize.color = function(color) { + props.color = color; + return colorize; + }; + + colorize.textColor = function(color) { + props.textColor = color; + return colorize; + }; + + return colorize; +}; + +}(jQuery)); diff --git a/creme/sketch/static/sketch/js/utils.js b/creme/sketch/static/sketch/js/utils.js index b9cd4fc9ab..41ddf8bb6e 100644 --- a/creme/sketch/static/sketch/js/utils.js +++ b/creme/sketch/static/sketch/js/utils.js @@ -254,28 +254,6 @@ creme.d3FontSize = function(select) { } }; -creme.d3ColorRange = function(colors, options) { - if (Array.isArray(colors)) { - return colors; - } else if (Object.isFunc(colors)) { - return colors(options); - } else { - return [colors]; - } -}; - -creme.d3SpectralColors = function(options) { - options = $.extend({ - start: 0.1, - step: 0.8, - size: 2 - }, options || {}); - - return d3.quantize(function(t) { - return d3.interpolateSpectral(t * options.step + options.start); - }, Math.max(options.size, 2)); -}; - creme.d3NumericDataInfo = function(data, getter) { data = data || []; getter = getter || function (d) { return d; };