feat: implement project selection with autocomplete, chips, and responsive column sizing in controls index view
This commit is contained in:
parent
5dc4d0615a
commit
f5acbe27db
6 changed files with 357 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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() {
|
||||
|
|
@ -768,7 +962,7 @@ function actionsFormatter(value, row) {
|
|||
|
||||
function codeFormatter(value, row) {
|
||||
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) {
|
||||
|
|
@ -783,7 +977,7 @@ function thematicFormatter(value, row) {
|
|||
return '-';
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -100,26 +100,37 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projets -->
|
||||
{% if project_choices %}
|
||||
<!-- Projets avec Autocomplete & Ajout -->
|
||||
<div class="col-12 filter-section">
|
||||
<div class="filter-section-header">
|
||||
<label>{% translate "Projets" %}</label>
|
||||
<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 deselect-all-btn" data-field="project">{% translate "Aucun" %}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-all-projects-btn">{% translate "Effacer tout" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-options" data-field="project">
|
||||
{% for id, name in project_choices %}
|
||||
<div class="filter-option">
|
||||
<input type="checkbox" name="project" id="project_{{ id }}" value="{{ id }}" {% if id|stringformat:"s" in selected_projects %}checked{% endif %}>
|
||||
<label for="project_{{ id }}"><i class="bi bi-folder me-1"></i>{{ name }}</label>
|
||||
<div class="position-relative mb-2">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search text-muted"></i></span>
|
||||
<input type="text" id="project-autocomplete-input" class="form-control" placeholder="{% translate 'Rechercher un projet à ajouter (nom ou code)…' %}" autocomplete="off">
|
||||
<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 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Période et mes contrôles -->
|
||||
<div class="row mt-3">
|
||||
|
|
@ -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 }};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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('<int:control_id>/', views.controls_detail, name='controls_detail'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue