From 82e20f648e3c857ce2a62a038cbd5b136cd1d842 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 11 Sep 2026 18:18:18 +0200 Subject: [PATCH] feat: enhance panel type filter with group selection, checkboxes, and bulk management controls. --- loko/sign/static/sign/sign.js | 2 + loko/sign/templates/sign/index.html | 459 ++++++++++++++++++++++------ loko/sign/tests.py | 71 +++++ 3 files changed, 444 insertions(+), 88 deletions(-) diff --git a/loko/sign/static/sign/sign.js b/loko/sign/static/sign/sign.js index f555346..93e7416 100644 --- a/loko/sign/static/sign/sign.js +++ b/loko/sign/static/sign/sign.js @@ -1657,6 +1657,8 @@ document.addEventListener('DOMContentLoaded', function() { Array.from(panelTypeSelect.options).forEach(opt => opt.selected = false); Array.from(organizationSelect.options).forEach(opt => opt.selected = false); + panelTypeSelect.dispatchEvent(new Event('change')); + document.getElementById('installation_year').value = ''; currentFilters = { diff --git a/loko/sign/templates/sign/index.html b/loko/sign/templates/sign/index.html index 8cef6ee..86c275b 100644 --- a/loko/sign/templates/sign/index.html +++ b/loko/sign/templates/sign/index.html @@ -40,6 +40,35 @@ box-shadow: 0 4px 12px rgba(47, 62, 139, 0.06) !important; } + .accordion-header { + transition: background-color 0.2s ease; + border-radius: 8px 8px 0 0; + } + + .accordion-header:hover { + background-color: #f8f9fa !important; + } + + .accordion-header:hover .accordion-button { + background-color: transparent !important; + color: #0d6efd !important; + } + + .accordion-header.is-open, + .accordion-header:has(.accordion-button:not(.collapsed)) { + background-color: rgba(13, 110, 253, 0.05) !important; + border-bottom: 1px solid rgba(13, 110, 253, 0.1) !important; + } + + .accordion-header.is-open .accordion-button, + .accordion-header:has(.accordion-button:not(.collapsed)) .accordion-button { + background-color: transparent !important; + } + + .accordion-header .accordion-button { + background-color: transparent !important; + } + .accordion-button { padding: 10px 16px !important; font-size: 0.9rem !important; @@ -65,6 +94,23 @@ background-size: 1rem !important; transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; } + + .group-select-all { + cursor: pointer; + width: 1.15rem; + height: 1.15rem; + transition: all 0.15s ease-in-out; + } + + .group-select-all:hover { + transform: scale(1.1); + } + + .group-select-all:indeterminate { + background-color: #0d6efd; + border-color: #0d6efd; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); + } .accordion-body { background-color: #fbfbfc !important; @@ -215,20 +261,42 @@
{% for group_key, group_label in panel_types_groups %} -
-

- -

+
+ +
+ + 0 sélectionné(s) + +
+ + +
+
{% for group, panels in panel_types_grouped.items %} {% if group == group_key %} @@ -236,6 +304,7 @@
@@ -255,7 +324,7 @@
-
+
{% translate "Aucun type sélectionné" %}
@@ -1933,109 +2002,323 @@ document.addEventListener('DOMContentLoaded', function() { const panelTypeSelect = document.getElementById('panel_type'); if (!panelTypeSelect) return; - const accordionButtons = document.querySelectorAll('#panel-type-accordion .accordion-button'); + const accordion = document.getElementById('panel-type-accordion'); const selectedList = document.getElementById('selected-panels-list'); - const emptyMessage = selectedList.querySelector('.empty-message'); + const emptyMessage = selectedList ? selectedList.querySelector('.empty-message') : null; + if (!selectedList) return; - // Helper to update selected list UI + // Helper to load images for a category + function loadGroupImages(group) { + const collapseDiv = document.querySelector(`#collapse-${group}`); + if (!collapseDiv) return; + collapseDiv.querySelectorAll('.panel-item').forEach(item => { + const placeholder = item.querySelector('.panel-img-placeholder'); + if (placeholder && placeholder.children.length === 0) { + const imageUrl = item.dataset.imageUrl; + const code = item.dataset.code; + if (imageUrl) { + const img = document.createElement('img'); + img.src = imageUrl; + img.style.maxHeight = '100%'; + img.style.maxWidth = '100%'; + placeholder.appendChild(img); + } else if (typeof buildPanelIconElement === 'function') { + const img = buildPanelIconElement(code); + img.style.height = '40px'; + img.style.width = '40px'; + placeholder.appendChild(img); + } else { + placeholder.textContent = code; + } + } + }); + } + + // Map option values to option elements for fast lookup + function getOptionMap() { + const map = new Map(); + Array.from(panelTypeSelect.options).forEach(opt => { + map.set(String(opt.value), opt); + }); + return map; + } + + // Helper to update selected list UI & Group states function updateSelectedList() { - const selectedOptions = Array.from(panelTypeSelect.options).filter(opt => opt.selected); - - // Clear list (except empty message) + const optionMap = getOptionMap(); + const selectedIds = new Set( + Array.from(panelTypeSelect.options) + .filter(opt => opt.selected) + .map(opt => String(opt.value)) + ); + + // 1. Update active states on grid items + document.querySelectorAll('.panel-item').forEach(item => { + if (selectedIds.has(String(item.dataset.id))) { + item.classList.add('selected'); + } else { + item.classList.remove('selected'); + } + }); + + // 2. Clear selected list container (except empty message) Array.from(selectedList.children).forEach(child => { if (!child.classList.contains('empty-message')) { child.remove(); } }); - if (selectedOptions.length === 0) { - emptyMessage.style.display = 'block'; + // 3. Process groups to calculate counts, update checkboxes, and build selection items + const groupItems = document.querySelectorAll('#panel-type-accordion .accordion-item'); + const fullySelectedGroups = new Set(); + const groupSelectedPanelsMap = new Map(); + + groupItems.forEach(groupEl => { + const groupKey = groupEl.dataset.groupKey; + if (!groupKey) return; + + const panelsInGroup = Array.from(groupEl.querySelectorAll('.panel-item')); + const totalInGroup = panelsInGroup.length; + const selectedPanels = panelsInGroup.filter(p => selectedIds.has(String(p.dataset.id))); + const count = selectedPanels.length; + + groupSelectedPanelsMap.set(groupKey, selectedPanels); + + // Update group checkbox state (checked / indeterminate / unchecked) + const checkbox = document.getElementById(`check-group-${groupKey}`); + if (checkbox) { + if (count === 0) { + checkbox.checked = false; + checkbox.indeterminate = false; + } else if (count === totalInGroup && totalInGroup > 0) { + checkbox.checked = true; + checkbox.indeterminate = false; + fullySelectedGroups.add(groupKey); + } else { + checkbox.checked = false; + checkbox.indeterminate = true; + } + } + + // Update header badge + const badge = document.getElementById(`badge-${groupKey}`); + if (badge) { + if (count > 0) { + badge.textContent = `${count}/${totalInGroup}`; + badge.className = 'badge rounded-pill bg-primary text-white border-0 group-count-badge me-2'; + } else { + badge.textContent = '0'; + badge.className = 'badge rounded-pill bg-light text-muted border group-count-badge me-2'; + } + } + + // Update status text inside accordion body + const statusText = document.getElementById(`status-${groupKey}`); + if (statusText) { + if (count === totalInGroup && totalInGroup > 0) { + statusText.innerHTML = `Tous sélectionnés (${totalInGroup})`; + } else if (count > 0) { + statusText.textContent = `${count} / ${totalInGroup} sélectionné(s)`; + } else { + statusText.textContent = `0 / ${totalInGroup} sélectionné(s)`; + } + } + }); + + // 4. Render selected list + if (selectedIds.size === 0) { + if (emptyMessage) emptyMessage.style.display = 'block'; } else { - emptyMessage.style.display = 'none'; - selectedOptions.forEach(opt => { - const id = opt.value; - const code = opt.text.trim(); - - const item = document.createElement('div'); - item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm'; - item.innerHTML = ` - ${code} - - `; - selectedList.appendChild(item); + if (emptyMessage) emptyMessage.style.display = 'none'; + + // Selection header with total count and "Tout effacer" button + const headerRow = document.createElement('div'); + headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom'; + headerRow.innerHTML = ` + ${selectedIds.size} sélectionné(s) + + `; + selectedList.appendChild(headerRow); + + const handledPanelIds = new Set(); + + // Display consolidated cards for fully selected groups first + groupItems.forEach(groupEl => { + const groupKey = groupEl.dataset.groupKey; + if (!groupKey) return; + const groupLabel = groupEl.querySelector('.group-title')?.textContent?.trim() || groupKey; + const selectedPanels = groupSelectedPanelsMap.get(groupKey) || []; + + if (fullySelectedGroups.has(groupKey)) { + // Entire group selected + const groupItem = document.createElement('div'); + groupItem.className = 'd-flex justify-content-between align-items-center bg-primary-subtle border border-primary-subtle rounded p-1 mb-1 shadow-sm'; + groupItem.innerHTML = ` +
+ + ${groupLabel} + Tous (${selectedPanels.length}) +
+ + `; + selectedList.appendChild(groupItem); + selectedPanels.forEach(p => handledPanelIds.add(String(p.dataset.id))); + } else if (selectedPanels.length > 0) { + // Partially selected group: list individual items + selectedPanels.forEach(p => { + const id = String(p.dataset.id); + const code = p.dataset.code || optionMap.get(id)?.text?.trim() || id; + handledPanelIds.add(id); + + const item = document.createElement('div'); + item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm'; + item.innerHTML = ` + ${code} + + `; + selectedList.appendChild(item); + }); + } + }); + + // Any remaining panels not in groups (fallback) + selectedIds.forEach(id => { + if (!handledPanelIds.has(id)) { + const opt = optionMap.get(id); + const code = opt ? opt.text.trim() : id; + const item = document.createElement('div'); + item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm'; + item.innerHTML = ` + ${code} + + `; + selectedList.appendChild(item); + } }); } - - // Update active state in grid - document.querySelectorAll('.panel-item').forEach(item => { - const id = item.dataset.id; - if (Array.from(panelTypeSelect.options).find(o => o.value === id && o.selected)) { - item.classList.add('selected'); - } else { - item.classList.remove('selected'); - } - }); } - // Handle accordion toggle to load images - accordionButtons.forEach(btn => { + // Set group selection helper + function setGroupSelection(groupKey, isSelected) { + const collapseDiv = document.querySelector(`#collapse-${groupKey}`); + if (!collapseDiv) return; + const optionMap = getOptionMap(); + + collapseDiv.querySelectorAll('.panel-item').forEach(item => { + const opt = optionMap.get(String(item.dataset.id)); + if (opt) { + opt.selected = isSelected; + } + }); + + if (isSelected) { + loadGroupImages(groupKey); + } + updateSelectedList(); + } + + // Group checkbox change handler + document.addEventListener('change', function(e) { + if (e.target && e.target.classList.contains('group-select-all')) { + const groupKey = e.target.dataset.group; + setGroupSelection(groupKey, e.target.checked); + } + }); + + // Toolbar and action button handlers via event delegation + document.addEventListener('click', function(e) { + const selectAllBtn = e.target.closest('.select-all-btn'); + if (selectAllBtn) { + const groupKey = selectAllBtn.dataset.group; + setGroupSelection(groupKey, true); + return; + } + + const deselectAllBtn = e.target.closest('.deselect-all-btn'); + if (deselectAllBtn) { + const groupKey = deselectAllBtn.dataset.group; + setGroupSelection(groupKey, false); + return; + } + + // Remove entire group from selected list + const removeGroupBtn = e.target.closest('.remove-group-type'); + if (removeGroupBtn) { + const groupKey = removeGroupBtn.dataset.group; + setGroupSelection(groupKey, false); + return; + } + + // Remove single panel from selected list + const removeBtn = e.target.closest('.remove-panel-type'); + if (removeBtn) { + const id = String(removeBtn.dataset.id); + const optionMap = getOptionMap(); + const opt = optionMap.get(id); + if (opt) { + opt.selected = false; + updateSelectedList(); + } + return; + } + + // Clear all panel types + const clearAllBtn = e.target.closest('#clear-all-panel-types'); + if (clearAllBtn) { + Array.from(panelTypeSelect.options).forEach(opt => opt.selected = false); + updateSelectedList(); + return; + } + + // Panel item click in grid + const item = e.target.closest('.panel-item'); + if (item) { + const id = String(item.dataset.id); + const optionMap = getOptionMap(); + const opt = optionMap.get(id); + if (opt) { + opt.selected = !opt.selected; + updateSelectedList(); + } + return; + } + }); + + // Handle accordion toggle to load images & sync visual open class + document.querySelectorAll('#panel-type-accordion .accordion-button').forEach(btn => { btn.addEventListener('click', () => { const group = btn.dataset.group; - const collapseDiv = document.querySelector(`#collapse-${group}`); - if (collapseDiv) { - collapseDiv.querySelectorAll('.panel-item').forEach(item => { - const placeholder = item.querySelector('.panel-img-placeholder'); - if (placeholder && placeholder.children.length === 0) { - const imageUrl = item.dataset.imageUrl; - const code = item.dataset.code; - - if (imageUrl) { - const img = document.createElement('img'); - img.src = imageUrl; - img.style.maxHeight = '100%'; - img.style.maxWidth = '100%'; - placeholder.appendChild(img); - } else if (typeof buildPanelIconElement === 'function') { - const img = buildPanelIconElement(code); - img.style.height = '40px'; - img.style.width = '40px'; - placeholder.appendChild(img); - } else { - placeholder.textContent = code; - } - } - }); - } + loadGroupImages(group); }); }); - // Handle panel item click - document.addEventListener('click', function(e) { - const item = e.target.closest('.panel-item'); - if (item) { - const id = item.dataset.id; - const option = Array.from(panelTypeSelect.options).find(o => o.value === id); - if (option) { - option.selected = !option.selected; - updateSelectedList(); - } - } + document.querySelectorAll('#panel-type-accordion .accordion-collapse').forEach(collapse => { + collapse.addEventListener('show.bs.collapse', () => { + const header = collapse.previousElementSibling; + if (header) header.classList.add('is-open'); + }); + collapse.addEventListener('hide.bs.collapse', () => { + const header = collapse.previousElementSibling; + if (header) header.classList.remove('is-open'); + }); }); - // Handle remove click - document.addEventListener('click', function(e) { - const btn = e.target.closest('.remove-panel-type'); - if (btn) { - const id = btn.dataset.id; - const option = Array.from(panelTypeSelect.options).find(o => o.value === id); - if (option) { - option.selected = false; - updateSelectedList(); - } - } - }); + // Listen to panelTypeSelect changes and reset button + panelTypeSelect.addEventListener('change', updateSelectedList); + const resetFiltersBtn = document.getElementById('reset-filters'); + if (resetFiltersBtn) { + resetFiltersBtn.addEventListener('click', function() { + setTimeout(updateSelectedList, 0); + }); + } // Initial update updateSelectedList(); diff --git a/loko/sign/tests.py b/loko/sign/tests.py index ff27995..e54340d 100644 --- a/loko/sign/tests.py +++ b/loko/sign/tests.py @@ -1202,3 +1202,74 @@ class SupportRepresentationAndModelTests(TestCase): self.assertTrue(tl_support['properties'].get('is_standalone_support')) +class SignPanelCategoryFilterTest(TestCase): + """Tests for panel type category grouping and filter controls.""" + + def setUp(self): + from django.contrib.auth.models import User + from common.models import UserConfig, AppView + from sign.models import SignPanelType + + self.user = User.objects.create_superuser(username="filter_test_user", password="password123", email="filter@test.com") + self.sign_view, _ = AppView.objects.get_or_create( + code='sign', + defaults={ + 'name_fr': 'Signalisation', + 'name_nl': 'Signalisatie', + 'url_name': 'sign:index', + } + ) + self.user_config = UserConfig.objects.create(user=self.user) + self.user_config.accessible_views.add(self.sign_view) + + # Create panel types across different categories + self.type_danger_1 = SignPanelType.objects.create(code="A1a", name_fr="Virage à droite", name_nl="Bocht rechts") + self.type_danger_2 = SignPanelType.objects.create(code="A1b", name_fr="Virage à gauche", name_nl="Bocht links") + self.type_priority = SignPanelType.objects.create(code="B1", name_fr="Céder le passage", name_nl="Voorrang verlenen") + self.type_prohibition = SignPanelType.objects.create(code="C1", name_fr="Sens interdit", name_nl="Verboden richting") + + def test_get_grouped_panel_types(self): + """Verify get_grouped_panel_types categorizes panel types according to Belgian traffic code.""" + from sign.views import get_grouped_panel_types + + grouped, groups = get_grouped_panel_types() + + # Check group labels exist + group_keys = [g[0] for g in groups] + self.assertIn("danger", group_keys) + self.assertIn("priority", group_keys) + self.assertIn("prohibition", group_keys) + + # Check danger group contains A1a and A1b + danger_codes = [p['code'] for p in grouped['danger']] + self.assertIn("A1a", danger_codes) + self.assertIn("A1b", danger_codes) + + # Check priority contains B1 + priority_codes = [p['code'] for p in grouped['priority']] + self.assertIn("B1", priority_codes) + + def test_index_view_renders_category_filter_controls(self): + """Verify the sign index view renders the category select-all checkboxes and quick-action toolbar.""" + self.client.force_login(self.user) + response = self.client.get(reverse('sign:index')) + self.assertEqual(response.status_code, 200) + + content = response.content.decode('utf-8') + + # Verify accordion item with group key + self.assertIn('data-group-key="danger"', content) + # Verify group select-all checkbox + self.assertIn('class="form-check-input group-select-all m-0"', content) + self.assertIn('id="check-group-danger"', content) + self.assertIn('data-group="danger"', content) + # Verify count badge + self.assertIn('id="badge-danger"', content) + # Verify toolbar buttons: Tout cocher / Tout décocher + self.assertIn('select-all-btn', content) + self.assertIn('deselect-all-btn', content) + # Verify data-group attribute on panel item + self.assertIn('data-group="danger"', content) + + +