mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 05:10:26 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user