fix(time-entry): improve task dropdown and filter robustness (fixes #489)

Log Time - Task dropdown:
- Detect session expiry when API returns HTML instead of JSON
- Show clearer error message suggesting page refresh
- Include HTTP status in error messages
- Add console.error with URL and project ID for debugging

Time Entries - Filters:
- Fallback to explicitly read select/hidden inputs in getFilterParams
- Add console.debug for filter params and URL (helps diagnose issues)
- Use DOMParser as fallback when response parsing fails
- Only set lastUrl on successful parse to allow retries
- Trigger filter apply on text input change (custom fields)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dries Peeters
2026-02-04 21:23:36 +01:00
parent 0e9f318f07
commit 50a0c848a7
2 changed files with 72 additions and 34 deletions
+30 -16
View File
@@ -258,20 +258,23 @@ document.addEventListener('DOMContentLoaded', async function(){
const onlyOneClient = {{ 'true' if only_one_client else 'false' }};
const singleClientId = {{ ('"' ~ single_client.id ~ '"') if single_client else 'null' }};
// Function to ensure only one of project or client is selected
function syncProjectClientSelection() {
if (!projectSelect || !clientSelect) return;
// Task loading: attach when project and task exist (independent of client)
if (projectSelect && taskSelect) {
projectSelect.addEventListener('change', function() {
if (this.value) {
if (this.value && clientSelect) {
clientSelect.value = '';
} else if (onlyOneClient && singleClientId) {
clientSelect.value = singleClientId;
}
loadTasks(this.value);
});
}
// Client/project mutual exclusivity (when client select exists)
if (clientSelect) {
clientSelect.addEventListener('change', function() {
if (this.value) {
projectSelect.value = '';
if (projectSelect) projectSelect.value = '';
if (taskSelect) {
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
@@ -295,18 +298,29 @@ document.addEventListener('DOMContentLoaded', async function(){
taskSelect.disabled = true;
return;
}
const url = buildTasksUrl(projectId);
try{
const resp = await fetch(buildTasksUrl(projectId), {
const resp = await fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
cache: 'no-store'
});
if (!resp.ok) throw new Error(failedToLoadTasksText);
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) {
// Common failure mode: session expired -> redirected to HTML login page.
throw new Error(failedToLoadTasksText);
const isJson = ct.includes('application/json');
// Detect auth redirect: got HTML instead of JSON (session expired -> login page) (Issue #489)
if (!isJson) {
const sessionExpiredMsg = '{{ _("Session may have expired. Please refresh the page and try again.") }}';
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(sessionExpiredMsg, '{{ _("Error") }}', 6000);
} else {
alert(sessionExpiredMsg);
}
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
return;
}
if (!resp.ok) throw new Error(failedToLoadTasksText + ' (HTTP ' + resp.status + ')');
const data = await resp.json();
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
@@ -327,8 +341,7 @@ document.addEventListener('DOMContentLoaded', async function(){
taskSelect.innerHTML = '<option value="">' + noTaskText + '</option>';
taskSelect.disabled = true;
try {
// Provide user feedback (previously silent failure)
const msg = failedToLoadTasksText || 'Failed to load tasks';
const msg = e && e.message ? e.message : (failedToLoadTasksText || 'Failed to load tasks');
if (window.toastManager && typeof window.toastManager.error === 'function') {
window.toastManager.error(msg, '{{ _("Error") }}', 5000);
} else if (window.toastManager && typeof window.toastManager.show === 'function') {
@@ -337,13 +350,14 @@ document.addEventListener('DOMContentLoaded', async function(){
alert(msg);
}
} catch (_) {}
try { console.error('Failed to load tasks for project', projectId, e); } catch (_) {}
if (typeof console !== 'undefined' && console.error) {
console.error('[Log Time] Task dropdown failed. Project:', projectId, 'URL:', url, 'Error:', e);
}
}
}
syncProjectClientSelection();
if (projectSelect){
if (projectSelect.value){ loadTasks(projectSelect.value); }
if (projectSelect && projectSelect.value) {
loadTasks(projectSelect.value);
}
// Keep worked time in sync with start/end inputs
+42 -18
View File
@@ -407,7 +407,7 @@ function confirmBulkDelete() {
const params = {};
// Get search input value directly (more reliable than FormData for text inputs)
// Get search input value directly (more reliable than FormData for text inputs) (Issue #489)
const searchInput = form.querySelector('input[name="search"], input#search');
if (searchInput) {
const searchValue = searchInput.value.trim();
@@ -416,26 +416,33 @@ function confirmBulkDelete() {
}
}
// Get other form fields from FormData
// Collect from FormData; also explicitly read select/input values as fallback for edge cases
const formData = new FormData(form);
for (const [key, value] of formData.entries()) {
// Skip search as we already handled it above
if (key === 'search') {
continue;
}
if (key === 'search') continue;
const trimmed = String(value || '').trim();
if (trimmed && trimmed !== '') {
params[key] = trimmed;
}
}
// Fallback: ensure selects and hidden inputs are captured (client_select, etc.)
form.querySelectorAll('select, input[type="hidden"]').forEach(function(el) {
const name = el.name;
if (!name || params[name] !== undefined) return;
const val = (el.value || '').trim();
if (val) params[name] = val;
});
if (typeof console !== 'undefined' && console.debug) {
console.debug('[Time Entries] Filter params:', params);
}
return params;
}
function buildFilterUrl() {
const params = getFilterParams();
const queryString = new URLSearchParams(params).toString();
return `/time-entries?${queryString}`;
return queryString ? `/time-entries?${queryString}` : '/time-entries';
}
function applyFilters() {
@@ -443,9 +450,12 @@ function confirmBulkDelete() {
const container = document.getElementById('timeEntriesListContainer');
if (!container) {
console.error('timeEntriesListContainer not found');
console.error('[Time Entries] timeEntriesListContainer not found');
return;
}
if (typeof console !== 'undefined' && console.debug) {
console.debug('[Time Entries] Applying filters, URL:', url);
}
// Avoid duplicate requests: prevent re-fetching the same URL while it's already in flight,
// and also skip if we already successfully loaded this URL.
@@ -498,23 +508,37 @@ function confirmBulkDelete() {
const newContainer = tempDiv.querySelector('#timeEntriesListContainer');
let parsed = false;
if (newContainer) {
container.innerHTML = newContainer.innerHTML;
parsed = true;
} else {
// Fallback: try to extract content using regex
const match = html.trim().match(/<div[^>]*id=["']timeEntriesListContainer["'][^>]*>([\s\S]*?)<\/div>\s*$/);
if (match && match[1]) {
container.innerHTML = match[1];
} else {
container.innerHTML = html;
// Fallback: try DOMParser for full HTML documents (Issue #489)
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const fallbackContainer = doc.getElementById('timeEntriesListContainer');
if (fallbackContainer) {
container.innerHTML = fallbackContainer.innerHTML;
parsed = true;
} else {
if (typeof console !== 'undefined' && console.warn) {
console.warn('[Time Entries] Filter response missing #timeEntriesListContainer');
}
if (inFlightUrl === url) inFlightUrl = null;
}
} catch (parseErr) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('[Time Entries] Failed to parse filter response:', parseErr);
}
if (inFlightUrl === url) inFlightUrl = null;
}
}
// Re-initialize bulk actions after content update
updateBulkActions();
// Mark as last successful URL so we can skip duplicate loads.
lastUrl = url;
if (parsed) lastUrl = url;
})
.catch(error => {
if (error && error.name === 'AbortError') {
@@ -564,11 +588,11 @@ function confirmBulkDelete() {
// Initialize export link based on current URL
try { updateExportLink(window.location.pathname + window.location.search); } catch (_) {}
// Event delegation so filters keep working even if inputs are re-rendered/replaced.
// Event delegation so filters keep working (Issue #489: include text inputs for custom fields)
form.addEventListener('change', (e) => {
const t = e.target;
if (!t || !(t instanceof Element)) return;
if (t.matches('select') || t.matches('input[type="date"]')) {
if (t.matches('select') || t.matches('input[type="date"]') || t.matches('input[type="text"]')) {
debouncedApplyFilters(100);
}
});