diff --git a/CHANGELOG.md b/CHANGELOG.md index 4138f222..3ba48b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Web manual entry cascade & inline create (#728)** — Manual time logging and the dashboard start-timer modal now follow the mobile start-timer flow: pick a client first (the project picker unlocks only then), then one of that client's projects, then a task of that project. The client field stays a real searchable dropdown even when only one client exists, so another client can be picked or created inline; a newly created client is selected automatically. Inline project creation attaches automatically to the selected client — the client dropdown is replaced by a read-only display with an editable hourly rate pre-filled from the client default — instead of asking for a client again. Inline task creation creates the task immediately from the combobox "Create …" row (no confirmation modal) for the preselected project. +- **Searchable combobox dark mode & typing (#728)** — Combobox dropdowns build their DOM in JS with utility classes Tailwind purged from the stylesheet, leaving white-on-white rows in dark mode; the dropdown now uses always-emitted component classes (`tt-searchable-*`) with proper dark hover/selected states. Focusing a combobox selects its text so typing replaces the committed label instead of appending to it. Source-controlled assets (`searchable-select.js`, `inline-create.js`, `dist/output.css`) are served with mtime-based version queries via a new `static_url()` helper so edits bust the browser cache without a release bump. ### Changed - **Mobile start timer flow** — Client, project, and task selection in the start timer sheet now use one consistent searchable picker; picking a client filters the project list to that client and picking a project filters tasks accordingly. Projects can also be picked directly without choosing a client first, which then auto-fills the client. Typing an unknown name offers inline "Create …" for clients, projects, and tasks via the existing v1 endpoints. The projects screen is sorted by most recently used (`last_used_at`) so recent work can be restarted with one tap. diff --git a/app/static/inline-create.js b/app/static/inline-create.js index dc5acb96..595debe7 100644 --- a/app/static/inline-create.js +++ b/app/static/inline-create.js @@ -144,6 +144,44 @@ // ---- Project ---- var projectState = { targetSelect: null }; + function findClientOption(clientId) { + var selects = [ + document.getElementById('inline_project_client_id'), + document.querySelector('[data-inline-client-select]'), + document.getElementById('client_id'), + document.getElementById('startTimerClient'), + document.getElementById('editTimerClient'), + ]; + var found = null; + selects.forEach(function (sel) { + if (found || !sel || sel.tagName !== 'SELECT') return; + Array.prototype.forEach.call(sel.options, function (o) { + if (!found && String(o.value) === String(clientId)) found = o; + }); + }); + return found; + } + + function clientDefaultRate(clientId) { + var opt = findClientOption(clientId); + if (!opt) return ''; + var rate = opt.getAttribute('data-default-rate'); + return rate || ''; + } + + function syncProjectModalRate(clientId) { + var rateField = document.getElementById('inlineProjectRateField'); + var rateInput = document.getElementById('inline_project_hourly_rate'); + if (!rateField || !rateInput) return; + if (clientId) { + rateField.classList.remove('hidden'); + rateInput.value = clientDefaultRate(clientId); + } else { + rateField.classList.add('hidden'); + rateInput.value = ''; + } + } + function showProjectModal(opts) { var modal = document.getElementById('createProjectModal'); var form = document.getElementById('inlineCreateProjectForm'); @@ -153,6 +191,10 @@ var errorEl = document.getElementById('createProjectError'); var nameInput = document.getElementById('inline_project_name'); var clientSelect = document.getElementById('inline_project_client_id'); + var clientField = document.getElementById('inlineProjectClientField'); + var clientDisplay = document.getElementById('inlineProjectClientDisplay'); + var clientNameEl = document.getElementById('inlineProjectClientName'); + var clientLocked = document.getElementById('inline_project_client_id_locked'); modal.classList.remove('hidden'); modal.setAttribute('aria-hidden', 'false'); if (errorEl) { @@ -160,15 +202,27 @@ errorEl.textContent = ''; } var preferred = opts.clientId || ''; - if (clientSelect) { - if (preferred) { + if (preferred) { + // Client already chosen on the page: show it read-only instead of a dropdown + var clientOpt = findClientOption(preferred); + if (clientLocked) clientLocked.value = String(preferred); + if (clientNameEl) clientNameEl.textContent = clientOpt ? clientOpt.textContent : ''; + if (clientDisplay) clientDisplay.classList.remove('hidden'); + if (clientField) clientField.classList.add('hidden'); + if (clientSelect) { clientSelect.value = preferred; clientSelect.disabled = true; clientSelect.setAttribute('data-locked', '1'); - } else { + } + syncProjectModalRate(preferred); + } else { + if (clientDisplay) clientDisplay.classList.add('hidden'); + if (clientField) clientField.classList.remove('hidden'); + if (clientSelect) { clientSelect.disabled = false; clientSelect.removeAttribute('data-locked'); } + syncProjectModalRate(''); } if (nameInput && opts.name) nameInput.value = opts.name; setTimeout(function () { @@ -181,6 +235,10 @@ var form = document.getElementById('inlineCreateProjectForm'); var errorEl = document.getElementById('createProjectError'); var clientSelect = document.getElementById('inline_project_client_id'); + var clientField = document.getElementById('inlineProjectClientField'); + var clientDisplay = document.getElementById('inlineProjectClientDisplay'); + var rateField = document.getElementById('inlineProjectRateField'); + var rateInput = document.getElementById('inline_project_hourly_rate'); if (!modal) return; modal.classList.add('hidden'); modal.setAttribute('aria-hidden', 'true'); @@ -195,12 +253,29 @@ clientSelect.disabled = false; clientSelect.removeAttribute('data-locked'); } + if (clientField) clientField.classList.remove('hidden'); + if (clientDisplay) clientDisplay.classList.add('hidden'); + if (rateField) rateField.classList.add('hidden'); + if (rateInput) rateInput.value = ''; projectState.targetSelect = null; } // ---- Task ---- var taskState = { targetSelect: null }; + function pageProjectName(projectId) { + var projectSelect = + document.querySelector('[data-inline-project-select]') || + document.getElementById('project_id') || + document.getElementById('startTimerProject'); + if (!projectSelect || projectSelect.tagName !== 'SELECT') return ''; + var opt = projectSelect.options[projectSelect.selectedIndex]; + if (opt && String(opt.value) === String(projectId)) { + return (opt.textContent || '').trim(); + } + return ''; + } + function showTaskModal(opts) { var modal = document.getElementById('createTaskInlineModal'); var form = document.getElementById('inlineCreateTaskForm'); @@ -217,8 +292,29 @@ errorEl.textContent = ''; } var preferred = opts.projectId || ''; + var projectField = document.getElementById('inlineTaskProjectField'); if (projectSelect && preferred) { - projectSelect.value = preferred; + // The project is already chosen on the page — the modal only needs its + // value, so hide the picker and keep the select as a hidden holder. + var has = Array.prototype.some.call(projectSelect.options, function (o) { + return String(o.value) === String(preferred); + }); + if (!has) { + var opt = document.createElement('option'); + opt.value = String(preferred); + opt.textContent = pageProjectName(preferred) || preferred; + projectSelect.appendChild(opt); + } + projectSelect.value = String(preferred); + projectSelect.disabled = true; + projectSelect.setAttribute('data-locked', '1'); + if (projectField) projectField.classList.add('hidden'); + } else { + if (projectSelect) { + projectSelect.disabled = false; + projectSelect.removeAttribute('data-locked'); + } + if (projectField) projectField.classList.remove('hidden'); } if (nameInput && opts.name) nameInput.value = opts.name; setTimeout(function () { @@ -230,6 +326,7 @@ var modal = document.getElementById('createTaskInlineModal'); var form = document.getElementById('inlineCreateTaskForm'); var errorEl = document.getElementById('createTaskInlineError'); + var projectSelect = document.getElementById('inline_task_project_id'); if (!modal) return; modal.classList.add('hidden'); modal.setAttribute('aria-hidden', 'true'); @@ -238,6 +335,12 @@ errorEl.textContent = ''; } if (form) form.reset(); + if (projectSelect) { + projectSelect.disabled = false; + projectSelect.removeAttribute('data-locked'); + } + var projectField = document.getElementById('inlineTaskProjectField'); + if (projectField) projectField.classList.remove('hidden'); taskState.targetSelect = null; } @@ -354,9 +457,15 @@ } return; } - setLoading(form, 'submitCreateProject', form.dataset.creatingText, true); + setLoading(form, 'submitCreateProject', form.dataset.creatingText, true); try { var formData = new FormData(form); + // When the client dropdown is hidden (locked display), include its value manually + var lockedClient = document.getElementById('inline_project_client_id_locked'); + if (lockedClient && lockedClient.value) { + formData.set('client_id', lockedClient.value); + } + if (!formData.get('hourly_rate')) formData.delete('hourly_rate'); var billable = document.getElementById('inline_project_billable'); if (billable && billable.checked) { formData.set('billable', 'on'); @@ -392,6 +501,11 @@ 'data-client-id': data.client_id, 'data-parent-id': data.client_id, }); + // Keep the inline task modal's project list in sync too + var taskProjectSelect = document.getElementById('inline_task_project_id'); + if (taskProjectSelect && taskProjectSelect !== select) { + appendOption(taskProjectSelect, data.id, data.name, null); + } // Auto-select owning client on page client select (UI hierarchy) if (data.client_id) { var pageClient = @@ -500,6 +614,58 @@ }); } + // ---- Direct task creation (no modal) ---- + // Used by the searchable combobox create row: the project is already known, + // so clicking "Create task …" creates it immediately. + function createTaskDirect(name, projectId, targetSelect) { + var form = document.getElementById('inlineCreateTaskForm'); + var createUrl = (form && form.dataset.createUrl) || '/api/tasks/create'; + var createdText = (form && form.dataset.createdText) || 'Task created'; + var errorText = (form && form.dataset.errorText) || 'Could not create task. Please try again.'; + return fetch(createUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify({ name: name, project_id: parseInt(projectId, 10) }), + credentials: 'same-origin', + }) + .then(function (resp) { + if (!resp.ok) { + return resp + .json() + .catch(function () { return {}; }) + .then(function (d) { + throw new Error(d.message || d.error || errorText); + }); + } + return resp.json(); + }) + .then(function (data) { + var taskId = data.id || (data.task && data.task.id); + var taskName = data.name || (data.task && data.task.name) || name; + var select = targetSelect || taskState.targetSelect || resolveSelect('task', null); + if (select) { + select.disabled = false; + appendOption(select, taskId, taskName, { + 'data-parent-id': String(projectId), + }); + if (window.ttEnhanceSearchableSelects) { + window.ttEnhanceSearchableSelects(); + } + } + if (window.toastManager) window.toastManager.success(createdText); + return taskId; + }) + .catch(function (err) { + var msg = (err && err.message) || errorText; + if (window.toastManager) window.toastManager.error(msg); + else alert(msg); + }); + } + function init() { initClientForm(); initProjectForm(); @@ -515,6 +681,14 @@ if (closeProject) closeProject.addEventListener('click', hideProjectModal); if (cancelProject) cancelProject.addEventListener('click', hideProjectModal); + // When the client is picked manually in the modal, prefill the rate + var modalClientSelect = document.getElementById('inline_project_client_id'); + if (modalClientSelect) { + modalClientSelect.addEventListener('change', function () { + syncProjectModalRate(this.value); + }); + } + var closeTask = document.getElementById('closeCreateTaskInlineModal'); var cancelTask = document.getElementById('cancelCreateTaskInline'); if (closeTask) closeTask.addEventListener('click', hideTaskModal); @@ -577,6 +751,7 @@ showTaskModal(opts); } }, + createTaskDirect: createTaskDirect, }; if (document.readyState === 'loading') { diff --git a/app/static/searchable-select.js b/app/static/searchable-select.js index ed9945c2..2d5e12db 100644 --- a/app/static/searchable-select.js +++ b/app/static/searchable-select.js @@ -107,6 +107,16 @@ if (projectSelect && projectSelect.value) { opts.projectId = projectSelect.value; } + // The project is already chosen: create the task directly, no modal + if ( + opts.projectId && + opts.name && + window.ttInlineCreate && + typeof window.ttInlineCreate.createTaskDirect === 'function' + ) { + window.ttInlineCreate.createTaskDirect(opts.name, opts.projectId, select); + return; + } } if (window.ttInlineCreate && typeof window.ttInlineCreate.open === 'function') { @@ -211,11 +221,11 @@ input.setAttribute('aria-controls', listId); input.placeholder = select.getAttribute('data-search-placeholder') || 'Type to search…'; input.value = selectedLabel(select); + input.disabled = select.disabled; var list = document.createElement('ul'); list.id = listId; - list.className = - 'tt-searchable-list hidden absolute z-40 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-lg py-1'; + list.className = 'tt-searchable-list hidden'; list.setAttribute('role', 'listbox'); select.parentNode.insertBefore(wrapper, select); @@ -232,7 +242,7 @@ activeIndex = -1; list.querySelectorAll('[role="option"]').forEach(function (el) { el.setAttribute('aria-selected', 'false'); - el.classList.remove('bg-blue-100', 'dark:bg-blue-800'); + el.classList.remove('is-active'); }); } @@ -269,7 +279,7 @@ if (filtered.length === 0 && !(canCreate && q)) { var empty = document.createElement('li'); - empty.className = 'px-3 py-2 text-sm text-gray-500 dark:text-gray-400'; + empty.className = 'tt-searchable-empty'; empty.textContent = 'No matches'; list.appendChild(empty); } @@ -277,16 +287,13 @@ filtered.forEach(function (o, idx) { var li = document.createElement('li'); li.id = optionDomId(listId, o); - li.className = - 'px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/30 text-gray-900 dark:text-gray-100'; + li.className = 'tt-searchable-option'; + if (o.value === select.value) li.classList.add('is-selected'); li.setAttribute('role', 'option'); li.setAttribute('aria-selected', o.value === select.value ? 'true' : 'false'); li.setAttribute('data-index', String(idx)); li.setAttribute('data-value', o.value); li.textContent = o.label || '(empty)'; - if (o.value === select.value) { - li.classList.add('bg-blue-50', 'dark:bg-blue-900/40', 'font-medium'); - } li.addEventListener('mousedown', function (e) { e.preventDefault(); setValue(o.value, o.label); @@ -301,15 +308,25 @@ // For task/project create, require a parent when filter-by is set var parent = getParentSelect(select); var parentOk = !parent || !!parent.value || kind === 'client'; - // Project can be created with client from page even without filter-by parent - if (kind === 'project') parentOk = true; + if (kind === 'project') { + // Projects belong to a client: only offer creation once a client is + // chosen (page client select or filter-by parent). Fall back to the + // old permissive behaviour on pages without any client select. + var clientSelect = + parent || + document.querySelector('[data-inline-client-select]') || + document.getElementById('client_id') || + document.getElementById('startTimerClient') || + document.getElementById('editTimerClient'); + if (clientSelect) parentOk = !!clientSelect.value; + else parentOk = true; + } if (kind === 'task' && parent && !parent.value) parentOk = false; if (!exact && parentOk) { var createLi = document.createElement('li'); createLi.id = listId + '-opt-create'; - createLi.className = - 'px-3 py-2 text-sm cursor-pointer border-t border-gray-100 dark:border-gray-700 text-primary hover:bg-blue-50 dark:hover:bg-blue-900/30 font-medium'; + createLi.className = 'tt-searchable-option tt-searchable-create'; createLi.setAttribute('role', 'option'); createLi.setAttribute('aria-selected', 'false'); createLi.setAttribute('data-create', '1'); @@ -337,8 +354,7 @@ activeIndex = (activeIndex + delta + items.length) % items.length; items.forEach(function (el, i) { var isActive = i === activeIndex; - el.classList.toggle('bg-blue-100', isActive); - el.classList.toggle('dark:bg-blue-800', isActive); + el.classList.toggle('is-active', isActive); el.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); var active = items[activeIndex]; @@ -351,6 +367,11 @@ } input.addEventListener('focus', function () { + // Select-all so typing replaces the committed label/placeholder instead + // of appending to it + try { + input.select(); + } catch (_) {} renderList(input.value === selectedLabel(select) ? '' : input.value); }); input.addEventListener('input', function () { @@ -402,6 +423,7 @@ }); var mo = new MutationObserver(function () { input.value = selectedLabel(select); + input.disabled = select.disabled; }); mo.observe(select, { childList: true, subtree: true, attributes: true }); diff --git a/app/static/src/input.css b/app/static/src/input.css index 35772739..5e75a697 100644 --- a/app/static/src/input.css +++ b/app/static/src/input.css @@ -143,6 +143,7 @@ .form-label { @apply block text-sm font-medium text-text-light dark:text-text-dark mb-1.5; } + .form-section-title { @apply text-sm font-semibold text-text-light dark:text-text-dark uppercase tracking-wide flex items-center gap-2 mb-4; } @@ -679,6 +680,31 @@ } } +/* Searchable combobox dropdown (app/static/searchable-select.js). + Component classes instead of inline utilities: Tailwind's content scan + does not reliably pick variant classes out of that JS file, which left + the dropdown unstyled/white-on-white in dark mode (#728). */ +.tt-searchable-list { + @apply absolute z-40 mt-1 max-h-60 w-full overflow-auto rounded-md border shadow-lg py-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600; +} +.tt-searchable-option { + @apply px-3 py-2 text-sm cursor-pointer text-gray-900 dark:text-gray-100; +} +.tt-searchable-option:hover { + @apply bg-blue-50 dark:bg-blue-900/30; +} +.tt-searchable-option.is-selected { + @apply bg-blue-50 dark:bg-blue-900/40 font-medium; +} +.tt-searchable-option.is-active { + @apply bg-blue-100 dark:bg-blue-800; +} +.tt-searchable-empty { + @apply px-3 py-2 text-sm text-gray-500 dark:text-gray-400; +} +.tt-searchable-create { + @apply border-t border-gray-100 dark:border-gray-700 text-primary; +} /* Focus, skip link, responsive, reduced motion, high contrast (from enhanced-ui / ui-enhancements) */ :focus-visible { outline: 2px solid #4A90E2; outline-offset: 2px; } .skip-link { position: absolute; top: -40px; left: 0; background: #4A90E2; color: white; padding: 8px 16px; z-index: 100; border-radius: 0 0 4px 0; } diff --git a/app/templates/base.html b/app/templates/base.html index c4c9bdcf..77003083 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1285,8 +1285,8 @@
{{ _('Projects must belong to a client. Other details can be filled later.') }}
+ {{ _('Pre-filled with the client default rate. Change it if this project should use another rate.') }} +
+