diff --git a/loko/controls/static/controls/controls_index.css b/loko/controls/static/controls/controls_index.css index 6c7c634..ae412af 100644 --- a/loko/controls/static/controls/controls_index.css +++ b/loko/controls/static/controls/controls_index.css @@ -3,7 +3,8 @@ ========================================================================== */ /* Supprime le contour bleu Bootstrap au focus sur l'input de recherche */ -#controls-search-input:focus { +#controls-search-input:focus, +#project-autocomplete-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; @@ -149,6 +150,83 @@ cursor: pointer; } +/* Project Autocomplete & Chips */ +.project-chip { + font-size: 0.8rem; + font-weight: 500; + border-radius: 1rem !important; + transition: background-color 0.15s ease; + border-color: #ced4da !important; +} + +.project-chip:hover { + background-color: #f1f3f5 !important; +} + +.project-chip .remove-project-btn { + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.project-chip .remove-project-btn:hover { + opacity: 1; +} + +#project-autocomplete-dropdown .list-group-item { + cursor: pointer; + font-size: 0.85rem; + padding: 0.45rem 0.75rem; + border-left: none; + border-right: none; +} + +#project-autocomplete-dropdown .list-group-item:hover, +#project-autocomplete-dropdown .list-group-item.active-item { + background-color: #e9ecef; +} + +#project-autocomplete-dropdown .list-group-item.selected-project { + background-color: #e7f1ff; + color: #0c5cc9; +} + +/* Limites de largeur pour les colonnes Code et Thématiques */ +#controls-table th[data-field="control_code"], +#controls-table td.col-code { + max-width: 140px; + width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#controls-table td.col-code a { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +#controls-table th[data-field="thematic_names"], +#controls-table td.col-thematics { + max-width: 180px; + width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#controls-table td.col-thematics .thematic-content { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + /* Layout helpers */ .controls-layout #controls-table-container { position: relative; diff --git a/loko/controls/static/controls/controls_index.js b/loko/controls/static/controls/controls_index.js index 128847c..befb256 100644 --- a/loko/controls/static/controls/controls_index.js +++ b/loko/controls/static/controls/controls_index.js @@ -232,8 +232,8 @@ function initControlsTable() { $table.bootstrapTable({ columns: [ { field: 'actions', title: '', formatter: 'actionsFormatter', class: 'text-nowrap' }, - { field: 'control_code', title: gettext('Code'), formatter: 'codeFormatter', class: 'text-nowrap', sortable: true }, - { field: 'thematic_names', title: gettext('Thématiques'), formatter: 'thematicFormatter', class: 'text-nowrap', sortable: true }, + { field: 'control_code', title: gettext('Code'), formatter: 'codeFormatter', class: 'text-nowrap col-code', sortable: true }, + { field: 'thematic_names', title: gettext('Thématiques'), formatter: 'thematicFormatter', class: 'col-thematics', sortable: true }, { field: 'category_names', title: gettext('Catégories'), sortable: true }, { field: 'control_type_label', title: gettext('Type'), formatter: 'controlTypeFormatter', class: 'text-nowrap', sortable: true }, { field: 'status_label', title: gettext('Statut'), formatter: 'statusFormatter', class: 'text-nowrap', sortable: true }, @@ -403,6 +403,26 @@ function setupFilterForm() { const mineCheck = form.querySelector('input[name="mine"]'); if (mineCheck) mineCheck.checked = false; + // Clear project chips + const projContainer = document.getElementById('selected-projects-container'); + if (projContainer) { + projContainer.querySelectorAll('.project-chip').forEach(chip => chip.remove()); + let noProjMsg = document.getElementById('no-project-selected-msg'); + if (!noProjMsg) { + noProjMsg = document.createElement('div'); + noProjMsg.id = 'no-project-selected-msg'; + noProjMsg.className = 'text-muted small align-self-center ps-1'; + noProjMsg.textContent = gettext('Aucun projet sélectionné (tous les projets sont inclus)'); + projContainer.appendChild(noProjMsg); + } else { + noProjMsg.style.display = ''; + } + } + const projInput = document.getElementById('project-autocomplete-input'); + if (projInput) projInput.value = ''; + const projClear = document.getElementById('project-search-clear-btn'); + if (projClear) projClear.style.display = 'none'; + // Reset category view document.querySelectorAll('.filter-options[data-field="category"] .filter-option[data-parent-id]:not([data-parent-id=""])').forEach(ch => { ch.style.display = 'none'; @@ -582,6 +602,180 @@ function setupFilterForm() { } filterCategoriesByThematics(); + setupProjectAutocomplete(); +} + +function setupProjectAutocomplete() { + const input = document.getElementById('project-autocomplete-input'); + const dropdown = document.getElementById('project-autocomplete-dropdown'); + const container = document.getElementById('selected-projects-container'); + const clearBtn = document.getElementById('project-search-clear-btn'); + const clearAllBtn = document.getElementById('clear-all-projects-btn'); + + if (!input || !dropdown || !container) return; + + let debounceTimer = null; + let activeIndex = -1; + + function closeDropdown() { + dropdown.classList.add('d-none'); + dropdown.innerHTML = ''; + activeIndex = -1; + } + + function getSelectedProjectIds() { + return Array.from(container.querySelectorAll('input[name="project"]')).map(inp => String(inp.value)); + } + + function updateEmptyMessage() { + const hasChips = container.querySelectorAll('.project-chip').length > 0; + let msg = document.getElementById('no-project-selected-msg'); + if (!hasChips) { + if (!msg) { + msg = document.createElement('div'); + msg.id = 'no-project-selected-msg'; + msg.className = 'text-muted small align-self-center ps-1'; + msg.textContent = gettext('Aucun projet sélectionné (tous les projets sont inclus)'); + container.appendChild(msg); + } else { + msg.style.display = ''; + } + } else if (msg) { + msg.style.display = 'none'; + } + } + + function addProjectChip(id, name) { + const currentIds = getSelectedProjectIds(); + if (currentIds.includes(String(id))) { + return; // already added + } + + const chip = document.createElement('div'); + chip.className = 'project-chip badge bg-white text-dark border d-inline-flex align-items-center gap-1 py-1 px-2'; + chip.setAttribute('data-project-id', id); + chip.innerHTML = ` + + ${name} + + + `; + + container.appendChild(chip); + updateEmptyMessage(); + } + + // Remove project chip handler (delegated) + container.addEventListener('click', (e) => { + const removeBtn = e.target.closest('.remove-project-btn'); + if (removeBtn) { + const chip = removeBtn.closest('.project-chip'); + if (chip) { + chip.remove(); + updateEmptyMessage(); + } + } + }); + + // Clear all projects button + clearAllBtn?.addEventListener('click', (e) => { + e.preventDefault(); + container.querySelectorAll('.project-chip').forEach(chip => chip.remove()); + updateEmptyMessage(); + }); + + // Autocomplete search + input.addEventListener('input', () => { + const q = input.value.trim(); + if (clearBtn) clearBtn.style.display = q ? '' : 'none'; + + clearTimeout(debounceTimer); + if (q.length < 1) { + closeDropdown(); + return; + } + + debounceTimer = setTimeout(() => { + if (typeof projectsAutocompleteUrl === 'undefined') return; + + fetch(`${projectsAutocompleteUrl}?q=${encodeURIComponent(q)}`) + .then(res => res.json()) + .then(data => { + if (!data || !data.length) { + dropdown.innerHTML = `
${gettext('Aucun projet trouvé')}
`; + dropdown.classList.remove('d-none'); + return; + } + + const selectedIds = getSelectedProjectIds(); + dropdown.innerHTML = ''; + data.forEach((p, idx) => { + const isSelected = selectedIds.includes(String(p.id)); + const item = document.createElement('button'); + item.type = 'button'; + item.className = `list-group-item list-group-item-action d-flex justify-content-between align-items-center ${isSelected ? 'selected-project' : ''}`; + item.setAttribute('data-project-id', p.id); + item.setAttribute('data-project-name', p.name); + item.setAttribute('data-index', idx); + item.innerHTML = ` + ${p.label} + ${isSelected ? `${gettext('Ajouté')}` : ``} + `; + item.addEventListener('click', () => { + addProjectChip(p.id, p.name); + input.value = ''; + if (clearBtn) clearBtn.style.display = 'none'; + closeDropdown(); + }); + dropdown.appendChild(item); + }); + dropdown.classList.remove('d-none'); + }) + .catch(() => closeDropdown()); + }, 200); + }); + + // Keyboard navigation in autocomplete + input.addEventListener('keydown', (e) => { + const items = dropdown.querySelectorAll('.list-group-item-action'); + if (!items.length || dropdown.classList.contains('d-none')) { + if (e.key === 'Escape') closeDropdown(); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + activeIndex = (activeIndex + 1) % items.length; + items.forEach((it, i) => it.classList.toggle('active-item', i === activeIndex)); + items[activeIndex]?.scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + activeIndex = (activeIndex - 1 + items.length) % items.length; + items.forEach((it, i) => it.classList.toggle('active-item', i === activeIndex)); + items[activeIndex]?.scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (activeIndex >= 0 && items[activeIndex]) { + items[activeIndex].click(); + } + } else if (e.key === 'Escape') { + closeDropdown(); + } + }); + + clearBtn?.addEventListener('click', () => { + input.value = ''; + clearBtn.style.display = 'none'; + closeDropdown(); + input.focus(); + }); + + // Close on click outside + document.addEventListener('click', (e) => { + if (!input.contains(e.target) && !dropdown.contains(e.target)) { + closeDropdown(); + } + }); } function filterCategoriesByThematics() { @@ -768,7 +962,7 @@ function actionsFormatter(value, row) { function codeFormatter(value, row) { const label = value || '-'; - return `${label}`; + return `${label}`; } function descriptionFormatter(value, row) { @@ -783,7 +977,7 @@ function thematicFormatter(value, row) { return '-'; } const icon = row.thematic_icon ? `` : ''; - return `${icon}${value}`; + return `${icon}${value}`; } function statusFormatter(value, row) { diff --git a/loko/controls/templates/controls/controls_index.html b/loko/controls/templates/controls/controls_index.html index 39df0f0..94418d2 100644 --- a/loko/controls/templates/controls/controls_index.html +++ b/loko/controls/templates/controls/controls_index.html @@ -100,26 +100,37 @@ - - {% if project_choices %} +
- - +
-
- {% for id, name in project_choices %} -
- - +
+
+ + +
+
+
+
+ {% for project in selected_projects_objs %} +
+ + {{ project.name }} + + +
+ {% empty %} +
{% translate "Aucun projet sélectionné (tous les projets sont inclus)" %}
{% endfor %}
- {% endif %}
@@ -273,6 +284,7 @@ const controlsgeojsonUrl = "{% url 'controls:controls_geojson' %}"; const controlsIndexUrl = "{% url 'controls:controls_index' %}"; const controlsExportExcelUrl = "{% url 'controls:controls_export_excel' %}"; + const projectsAutocompleteUrl = "{% url 'controls:projects_autocomplete' %}"; const savePreferencesUrl = "{% url 'common:save_control_list_preferences' %}"; const projectsDetailBaseUrl = "{% url 'projects:projects_index' %}"; const SAVED_SORT = {{ saved_sort|default:"{}"|safe }}; diff --git a/loko/controls/tests.py b/loko/controls/tests.py index 0a017cb..265962d 100644 --- a/loko/controls/tests.py +++ b/loko/controls/tests.py @@ -216,3 +216,22 @@ class ControlsIndexTests(TestCase): self.internal_config.refresh_from_db() self.assertEqual(self.internal_config.control_list_filters, payload["filters"]) self.assertEqual(self.internal_config.control_list_sort, payload["sort"]) + + def test_projects_autocomplete(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:projects_autocomplete"), {"q": "Test"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(len(data) >= 1) + self.assertEqual(data[0]["id"], self.project.id) + self.assertEqual(data[0]["name"], "Projet Test") + + def test_controls_index_selected_projects_objs(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_index"), {"project": [str(self.project.id)]}) + self.assertEqual(response.status_code, 200) + self.assertIn("selected_projects_objs", response.context) + selected_objs = response.context["selected_projects_objs"] + self.assertEqual(len(selected_objs), 1) + self.assertEqual(selected_objs[0].id, self.project.id) + diff --git a/loko/controls/urls.py b/loko/controls/urls.py index a559ce7..8888818 100644 --- a/loko/controls/urls.py +++ b/loko/controls/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("", views.controls_index, name="controls_index"), path("geojson/", views.controls_geojson, name="controls_geojson"), path("export-excel/", views.controls_export_excel, name="controls_export_excel"), + path("projects-autocomplete/", views.projects_autocomplete, name="projects_autocomplete"), path("service-worker.js", views.controls_service_worker, name="controls_service_worker"), path("offline/", views.controls_offline, name="controls_offline"), path('/', views.controls_detail, name='controls_detail'), diff --git a/loko/controls/views.py b/loko/controls/views.py index 157f7d9..81d57b2 100644 --- a/loko/controls/views.py +++ b/loko/controls/views.py @@ -391,6 +391,9 @@ def controls_index(request): } return render(request, 'controls/controls_index_mobile.html', context) + valid_selected_project_ids = [int(x) for x in selected_projects if str(x).isdigit()] + selected_projects_objs = list(viewable_projects.filter(id__in=valid_selected_project_ids)) if valid_selected_project_ids else [] + context = { 'thematic_choices': [(t.code, t.name, t.icon) for t in thematics], 'selected_thematics': selected_thematics, @@ -405,6 +408,7 @@ def controls_index(request): 'selected_types': selected_types, 'project_choices': [(p.id, p.name) for p in viewable_projects], 'selected_projects': selected_projects, + 'selected_projects_objs': selected_projects_objs, 'search_query': search_query, 'show_mine_only': show_mine_only, 'date_from': date_from, @@ -416,6 +420,40 @@ def controls_index(request): return render(request, 'controls/controls_index.html', context) +@require_GET +@login_required +def projects_autocomplete(request): + """ + API endpoint pour l'autocomplétion des projets dans les filtres des contrôles. + Renvoie les projets consultables filtrés par nom ou code. + """ + q = request.GET.get('q', '').strip() + if not q: + return JsonResponse([], safe=False) + + viewable_projects = filter_viewable_projects_for_user(request.user) + + search_terms = q.split() + query = Q() + for term in search_terms: + if term: + query &= (Q(name__icontains=term) | Q(code__icontains=term)) + + projects = viewable_projects.filter(query).order_by('name')[:30] + + results = [] + for p in projects: + code_prefix = f"[{p.code}] " if p.code else "" + results.append({ + 'id': p.id, + 'code': p.code or '', + 'name': p.name, + 'label': f"{code_prefix}{p.name}", + }) + + return JsonResponse(results, safe=False) + + @require_GET @login_required def controls_geojson(request):