Files
TimeTracker/app/templates/components/multi_select.html
Dries Peeters 5da5c8373c feat(kanban,gantt): quick add task from board and Gantt views (#465)
- Add '+ Add task' button in Kanban and Gantt headers linking to create
  with project (and status for Kanban) pre-filled
- Support 'next' redirect after task create; validate to allowed paths
  (/kanban, /gantt, /tasks, /projects) to avoid open redirects
- Honor Initial Status on create: TaskService and form now pass status
  through; create form pre-fills status from query and hidden 'next'
- Per-column '+' on Kanban to add task into that column (status pre-set)
- Return user to same Kanban/Gantt view after creating a task
2026-01-30 17:25:56 +01:00

267 lines
11 KiB
HTML

{# ============================================
MULTI-SELECT COMPONENT
Reusable multi-select dropdown with checkboxes
============================================ #}
{% macro multi_select(
field_name,
label,
items,
selected_ids=[],
item_id_attr='id',
item_label_attr='name',
placeholder='All',
show_search=True,
form_id=None
) %}
{#
Parameters:
- field_name: Name of the hidden input field (e.g., 'project_ids')
- label: Label text for the dropdown
- items: List of items to display (e.g., projects, users)
- selected_ids: List of currently selected IDs
- item_id_attr: Attribute name for item ID (default: 'id')
- item_label_attr: Attribute name for item label (default: 'name')
- placeholder: Text to show when nothing is selected (default: 'All')
- show_search: Whether to show search box (default: True)
- form_id: Optional form ID for auto-submit
#}
<div class="multi-select-wrapper relative" data-field-name="{{ field_name }}">
<label for="{{ field_name }}_button" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ _(label) }}
</label>
{# Hidden input to store selected IDs as comma-separated values #}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}_input" value="{{ selected_ids|join(',') if selected_ids else '' }}">
{# Dropdown button #}
<button
type="button"
id="{{ field_name }}_button"
class="w-full bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark text-left flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
aria-haspopup="listbox"
aria-expanded="false"
>
<span class="multi-select-label">
{% if selected_ids %}
{{ _('Selected') }}: <span class="font-semibold">{{ selected_ids|length }}</span>
{% else %}
{{ _(placeholder) }}
{% endif %}
</span>
<i class="fas fa-chevron-down text-xs transition-transform"></i>
</button>
{# Dropdown menu #}
<div
class="multi-select-dropdown absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg hidden"
role="listbox"
aria-label="{{ _(label) }} options"
>
{# Search box #}
{% if show_search and items|length > 5 %}
<div class="p-2 border-b border-gray-200 dark:border-gray-700">
<input
type="text"
class="multi-select-search w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-text-light dark:text-text-dark focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="{{ _('Search...') }}"
aria-label="{{ _('Search') }} {{ _(label) }}"
>
</div>
{% endif %}
{# Options list #}
<div class="multi-select-options max-h-60 overflow-y-auto p-2">
{# "All" option #}
<label class="multi-select-option flex items-center px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors">
<input
type="checkbox"
class="multi-select-all h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
aria-label="{{ _('Select all') }}"
>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300 font-medium">{{ _('All') }}</span>
</label>
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
{# Individual items #}
{% for item in items %}
{% set item_id = item[item_id_attr] if item_id_attr in item.__dict__ else item.get(item_id_attr) %}
{% set item_label = item[item_label_attr] if item_label_attr in item.__dict__ else item.get(item_label_attr, item.display_name if 'display_name' in item.__dict__ else item_id) %}
<label class="multi-select-option flex items-center px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors" data-search-text="{{ item_label|lower }}">
<input
type="checkbox"
class="multi-select-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
value="{{ item_id }}"
{% if item_id in selected_ids %}checked{% endif %}
aria-label="{{ item_label }}"
>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">{{ item_label }}</span>
</label>
{% endfor %}
{% if not items %}
<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ _('No items available') }}
</div>
{% endif %}
</div>
{# Action buttons #}
<div class="p-2 border-t border-gray-200 dark:border-gray-700 flex gap-2">
<button
type="button"
class="multi-select-clear flex-1 px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
>
{{ _('Clear') }}
</button>
<button
type="button"
class="multi-select-apply flex-1 px-3 py-1.5 text-xs text-white bg-primary hover:bg-primary/90 rounded transition-colors"
>
{{ _('Apply') }}
</button>
</div>
</div>
</div>
{# JavaScript for multi-select functionality #}
<script>
(function() {
'use strict';
const wrapper = document.querySelector('.multi-select-wrapper[data-field-name="{{ field_name }}"]');
if (!wrapper) return;
const button = wrapper.querySelector('#{{ field_name }}_button');
const dropdown = wrapper.querySelector('.multi-select-dropdown');
const hiddenInput = wrapper.querySelector('#{{ field_name }}_input');
const labelSpan = wrapper.querySelector('.multi-select-label');
const searchInput = wrapper.querySelector('.multi-select-search');
const allCheckbox = wrapper.querySelector('.multi-select-all');
const checkboxes = wrapper.querySelectorAll('.multi-select-checkbox');
const clearBtn = wrapper.querySelector('.multi-select-clear');
const applyBtn = wrapper.querySelector('.multi-select-apply');
const options = wrapper.querySelectorAll('.multi-select-option');
const chevron = button.querySelector('.fa-chevron-down');
// Toggle dropdown
button.addEventListener('click', function(e) {
e.stopPropagation();
const isOpen = !dropdown.classList.contains('hidden');
// Close all other multi-selects
document.querySelectorAll('.multi-select-dropdown').forEach(d => {
if (d !== dropdown) {
d.classList.add('hidden');
const otherButton = d.parentElement.querySelector('[aria-expanded]');
if (otherButton) {
otherButton.setAttribute('aria-expanded', 'false');
const otherChevron = otherButton.querySelector('.fa-chevron-down');
if (otherChevron) otherChevron.classList.remove('rotate-180');
}
}
});
dropdown.classList.toggle('hidden');
button.setAttribute('aria-expanded', !isOpen);
chevron.classList.toggle('rotate-180');
if (!isOpen && searchInput) {
searchInput.focus();
}
});
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!wrapper.contains(e.target)) {
dropdown.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
chevron.classList.remove('rotate-180');
}
});
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
options.forEach(option => {
if (option.classList.contains('multi-select-option') && option.dataset.searchText) {
const matches = option.dataset.searchText.includes(searchTerm);
option.style.display = matches ? '' : 'none';
}
});
});
}
// Update label and hidden input
function updateSelection() {
const selected = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
hiddenInput.value = selected.join(',');
if (selected.length === 0) {
labelSpan.innerHTML = '{{ _(placeholder) }}';
if (allCheckbox) allCheckbox.checked = false;
} else if (selected.length === checkboxes.length) {
labelSpan.innerHTML = '{{ _("All") }}';
if (allCheckbox) allCheckbox.checked = true;
} else {
labelSpan.innerHTML = '{{ _("Selected") }}: <span class="font-semibold">' + selected.length + '</span>';
if (allCheckbox) allCheckbox.checked = false;
}
}
// Handle individual checkbox changes
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelection();
});
});
// Handle "All" checkbox
if (allCheckbox) {
allCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
checkboxes.forEach(cb => cb.checked = isChecked);
updateSelection();
});
}
// Clear button
if (clearBtn) {
clearBtn.addEventListener('click', function() {
checkboxes.forEach(cb => cb.checked = false);
if (allCheckbox) allCheckbox.checked = false;
updateSelection();
});
}
// Apply button - close dropdown and optionally submit form
if (applyBtn) {
applyBtn.addEventListener('click', function() {
dropdown.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
chevron.classList.remove('rotate-180');
{% if form_id %}
// Auto-submit form if form_id is provided
const form = document.getElementById('{{ form_id }}');
if (form) {
// For AJAX forms, trigger custom event
if (form.hasAttribute('data-filter-form')) {
const event = new Event('change', { bubbles: true });
hiddenInput.dispatchEvent(event);
} else {
form.submit();
}
}
{% endif %}
});
}
// Initialize "All" checkbox state
updateSelection();
})();
</script>
{% endmacro %}