feat: implement project selection with autocomplete, chips, and responsive column sizing in controls index view

This commit is contained in:
kdeterme 2026-08-17 11:51:33 +02:00
parent 5dc4d0615a
commit f5acbe27db
6 changed files with 357 additions and 15 deletions

View file

@ -3,7 +3,8 @@
========================================================================== */ ========================================================================== */
/* Supprime le contour bleu Bootstrap au focus sur l'input de recherche */ /* 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; outline: none;
box-shadow: none; box-shadow: none;
border-color: #dee2e6; border-color: #dee2e6;
@ -149,6 +150,83 @@
cursor: pointer; 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 */ /* Layout helpers */
.controls-layout #controls-table-container { .controls-layout #controls-table-container {
position: relative; position: relative;

View file

@ -232,8 +232,8 @@ function initControlsTable() {
$table.bootstrapTable({ $table.bootstrapTable({
columns: [ columns: [
{ field: 'actions', title: '', formatter: 'actionsFormatter', class: 'text-nowrap' }, { field: 'actions', title: '', formatter: 'actionsFormatter', class: 'text-nowrap' },
{ field: 'control_code', title: gettext('Code'), formatter: 'codeFormatter', 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: 'text-nowrap', 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: 'category_names', title: gettext('Catégories'), sortable: true },
{ field: 'control_type_label', title: gettext('Type'), formatter: 'controlTypeFormatter', class: 'text-nowrap', 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 }, { 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"]'); const mineCheck = form.querySelector('input[name="mine"]');
if (mineCheck) mineCheck.checked = false; 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 // Reset category view
document.querySelectorAll('.filter-options[data-field="category"] .filter-option[data-parent-id]:not([data-parent-id=""])').forEach(ch => { document.querySelectorAll('.filter-options[data-field="category"] .filter-option[data-parent-id]:not([data-parent-id=""])').forEach(ch => {
ch.style.display = 'none'; ch.style.display = 'none';
@ -582,6 +602,180 @@ function setupFilterForm() {
} }
filterCategoriesByThematics(); 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 = `
<i class="bi bi-folder text-primary"></i>
<span>${name}</span>
<input type="hidden" name="project" value="${id}">
<button type="button" class="btn-close remove-project-btn ms-1" style="font-size: 0.55rem;" aria-label="Supprimer"></button>
`;
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 = `<div class="list-group-item text-muted small p-2">${gettext('Aucun projet trouvé')}</div>`;
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 = `
<span><i class="bi bi-folder me-1 text-primary"></i>${p.label}</span>
${isSelected ? `<span class="badge bg-secondary small">${gettext('Ajouté')}</span>` : `<i class="bi bi-plus-circle text-success"></i>`}
`;
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() { function filterCategoriesByThematics() {
@ -768,7 +962,7 @@ function actionsFormatter(value, row) {
function codeFormatter(value, row) { function codeFormatter(value, row) {
const label = value || '-'; const label = value || '-';
return `<a href="${controlsIndexUrl}${row.id}/" class="fw-semibold text-decoration-none text-nowrap">${label}</a>`; return `<a href="${controlsIndexUrl}${row.id}/" class="fw-semibold text-decoration-none text-nowrap" title="${label}">${label}</a>`;
} }
function descriptionFormatter(value, row) { function descriptionFormatter(value, row) {
@ -783,7 +977,7 @@ function thematicFormatter(value, row) {
return '-'; return '-';
} }
const icon = row.thematic_icon ? `<i class="bi ${row.thematic_icon} me-1"></i>` : ''; const icon = row.thematic_icon ? `<i class="bi ${row.thematic_icon} me-1"></i>` : '';
return `${icon}${value}`; return `<span class="thematic-content" title="${value}">${icon}${value}</span>`;
} }
function statusFormatter(value, row) { function statusFormatter(value, row) {

View file

@ -100,26 +100,37 @@
</div> </div>
</div> </div>
<!-- Projets --> <!-- Projets avec Autocomplete & Ajout -->
{% if project_choices %}
<div class="col-12 filter-section"> <div class="col-12 filter-section">
<div class="filter-section-header"> <div class="filter-section-header">
<label>{% translate "Projets" %}</label> <label>{% translate "Projets" %}</label>
<div class="filter-section-buttons"> <div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="project">{% translate "Tous" %}</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="clear-all-projects-btn">{% translate "Effacer tout" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="project">{% translate "Aucun" %}</button>
</div> </div>
</div> </div>
<div class="filter-options" data-field="project"> <div class="position-relative mb-2">
{% for id, name in project_choices %} <div class="input-group input-group-sm">
<div class="filter-option"> <span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
<input type="checkbox" name="project" id="project_{{ id }}" value="{{ id }}" {% if id|stringformat:"s" in selected_projects %}checked{% endif %}> <input type="text" id="project-autocomplete-input" class="form-control" placeholder="{% translate 'Rechercher un projet à ajouter (nom ou code)…' %}" autocomplete="off">
<label for="project_{{ id }}"><i class="bi bi-folder me-1"></i>{{ name }}</label> <button class="btn btn-outline-secondary" type="button" id="project-search-clear-btn" style="display:none;">
<i class="bi bi-x"></i>
</button>
</div> </div>
<div id="project-autocomplete-dropdown" class="list-group position-absolute w-100 shadow-sm d-none" style="z-index: 1055; max-height: 200px; overflow-y: auto;"></div>
</div>
<div id="selected-projects-container" class="d-flex flex-wrap gap-1 p-2 bg-light rounded border" style="min-height: 44px; max-height: 160px; overflow-y: auto;">
{% for project in selected_projects_objs %}
<div class="project-chip badge bg-white text-dark border d-inline-flex align-items-center gap-1 py-1 px-2" data-project-id="{{ project.id }}">
<i class="bi bi-folder text-primary"></i>
<span>{{ project.name }}</span>
<input type="hidden" name="project" value="{{ project.id }}">
<button type="button" class="btn-close remove-project-btn ms-1" style="font-size: 0.55rem;" aria-label="Supprimer"></button>
</div>
{% empty %}
<div id="no-project-selected-msg" class="text-muted small align-self-center ps-1">{% translate "Aucun projet sélectionné (tous les projets sont inclus)" %}</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endif %}
<!-- Période et mes contrôles --> <!-- Période et mes contrôles -->
<div class="row mt-3"> <div class="row mt-3">
@ -273,6 +284,7 @@
const controlsgeojsonUrl = "{% url 'controls:controls_geojson' %}"; const controlsgeojsonUrl = "{% url 'controls:controls_geojson' %}";
const controlsIndexUrl = "{% url 'controls:controls_index' %}"; const controlsIndexUrl = "{% url 'controls:controls_index' %}";
const controlsExportExcelUrl = "{% url 'controls:controls_export_excel' %}"; const controlsExportExcelUrl = "{% url 'controls:controls_export_excel' %}";
const projectsAutocompleteUrl = "{% url 'controls:projects_autocomplete' %}";
const savePreferencesUrl = "{% url 'common:save_control_list_preferences' %}"; const savePreferencesUrl = "{% url 'common:save_control_list_preferences' %}";
const projectsDetailBaseUrl = "{% url 'projects:projects_index' %}"; const projectsDetailBaseUrl = "{% url 'projects:projects_index' %}";
const SAVED_SORT = {{ saved_sort|default:"{}"|safe }}; const SAVED_SORT = {{ saved_sort|default:"{}"|safe }};

View file

@ -216,3 +216,22 @@ class ControlsIndexTests(TestCase):
self.internal_config.refresh_from_db() self.internal_config.refresh_from_db()
self.assertEqual(self.internal_config.control_list_filters, payload["filters"]) self.assertEqual(self.internal_config.control_list_filters, payload["filters"])
self.assertEqual(self.internal_config.control_list_sort, payload["sort"]) 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)

View file

@ -8,6 +8,7 @@ urlpatterns = [
path("", views.controls_index, name="controls_index"), path("", views.controls_index, name="controls_index"),
path("geojson/", views.controls_geojson, name="controls_geojson"), path("geojson/", views.controls_geojson, name="controls_geojson"),
path("export-excel/", views.controls_export_excel, name="controls_export_excel"), 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("service-worker.js", views.controls_service_worker, name="controls_service_worker"),
path("offline/", views.controls_offline, name="controls_offline"), path("offline/", views.controls_offline, name="controls_offline"),
path('<int:control_id>/', views.controls_detail, name='controls_detail'), path('<int:control_id>/', views.controls_detail, name='controls_detail'),

View file

@ -391,6 +391,9 @@ def controls_index(request):
} }
return render(request, 'controls/controls_index_mobile.html', context) 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 = { context = {
'thematic_choices': [(t.code, t.name, t.icon) for t in thematics], 'thematic_choices': [(t.code, t.name, t.icon) for t in thematics],
'selected_thematics': selected_thematics, 'selected_thematics': selected_thematics,
@ -405,6 +408,7 @@ def controls_index(request):
'selected_types': selected_types, 'selected_types': selected_types,
'project_choices': [(p.id, p.name) for p in viewable_projects], 'project_choices': [(p.id, p.name) for p in viewable_projects],
'selected_projects': selected_projects, 'selected_projects': selected_projects,
'selected_projects_objs': selected_projects_objs,
'search_query': search_query, 'search_query': search_query,
'show_mine_only': show_mine_only, 'show_mine_only': show_mine_only,
'date_from': date_from, 'date_from': date_from,
@ -416,6 +420,40 @@ def controls_index(request):
return render(request, 'controls/controls_index.html', context) 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 @require_GET
@login_required @login_required
def controls_geojson(request): def controls_geojson(request):