From bfd7bfcc14f227bdeaab608b15a8f7541e55d533 Mon Sep 17 00:00:00 2001 From: Giorgio Date: Thu, 27 Aug 2026 14:10:40 +0200 Subject: [PATCH] fix(web): manual entry cascading pickers, inline create flow, dark-mode comboboxes (#728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 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, and 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's default rate. Inline task creation creates the task immediately from the combobox "Create …" row (no confirmation modal) for the preselected project. Combobox dropdowns built in JS used utility classes that Tailwind purged, leaving white-on-white rows in dark mode; the dropdown now uses always-emitted component classes 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. ## Type of change - [x] Bug fix (non-breaking change that fixes an issue) ## Checklist - [x] My code follows the project's style guidelines (Black, flake8). - [x] I have added/updated tests for my changes. - [x] All tests pass locally (e.g. `pytest`). - [x] I have updated the documentation if needed. - [x] For user-facing changes, I have added an entry to the **Unreleased** section of CHANGELOG.md. ## Related issues Fixes #728 --- CHANGELOG.md | 5 + app/static/inline-create.js | 185 +++++++++++++++++- app/static/searchable-select.js | 52 +++-- app/static/src/input.css | 26 +++ app/templates/base.html | 4 +- app/templates/components/client_select.html | 14 +- .../inline_create_project_modal.html | 16 +- .../components/inline_create_task_modal.html | 2 +- app/templates/main/dashboard.html | 2 +- app/templates/partials/_head.html | 2 +- app/templates/timer/manual_entry.html | 33 ++-- app/utils/assets.py | 21 +- tests/test_issue_728_client_only_edit.py | 32 +++ 13 files changed, 349 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a002d73a..5696ae03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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. + ## [5.13.1] - 2026-08-26 ### Changed 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 @@

{{ _('AI H task: {{ _('Create task')|tojson }} }; - - + + {% block scripts_extra %}{% endblock %} {% block extra_js %}{% endblock %} {% include 'partials/_bottom_nav.html' %} diff --git a/app/templates/components/client_select.html b/app/templates/components/client_select.html index 74887c80..daa3cbde 100644 --- a/app/templates/components/client_select.html +++ b/app/templates/components/client_select.html @@ -1,19 +1,21 @@ -{# Client select component - when only one client exists, pre-fill and gray out per Issue #467 #} -{% macro client_select(name, clients, selected_id=None, required=False, only_one_client=False, single_client=None, id=None, searchable=False, can_create=False) %} +{# Client select component - when only one client exists, pre-fill and gray out per Issue #467. + Pass force_selectable=True to keep a real dropdown even with a single client + (cascading create flows where the user may pick another client or create one). #} +{% macro client_select(name, clients, selected_id=None, required=False, only_one_client=False, single_client=None, id=None, searchable=False, can_create=False, force_selectable=False) %} {% set input_id = id or name %} {% set locked_client = get_locked_client() %} {% if locked_client %} -{% elif (not only_one_client) and (single_client is none) and clients and (clients|length == 1) %} +{% elif (not force_selectable) and (not only_one_client) and (single_client is none) and clients and (clients|length == 1) %} {# Fallback: if route didn't pass only_one_client/single_client, infer it from the list #} -{% elif only_one_client and single_client %} +{% elif (not force_selectable) and only_one_client and single_client %} - {% else %} {% endif %} diff --git a/app/templates/components/inline_create_project_modal.html b/app/templates/components/inline_create_project_modal.html index cfde6a97..b4a1de02 100644 --- a/app/templates/components/inline_create_project_modal.html +++ b/app/templates/components/inline_create_project_modal.html @@ -24,18 +24,30 @@

{{ _('Create Project') }}

-
+

{{ _('Projects must belong to a client. Other details can be filled later.') }}

+ +
diff --git a/app/templates/components/inline_create_task_modal.html b/app/templates/components/inline_create_task_modal.html index fab38e9d..657cdf42 100644 --- a/app/templates/components/inline_create_task_modal.html +++ b/app/templates/components/inline_create_task_modal.html @@ -24,7 +24,7 @@

{{ _('Create Task') }}

-
+
-

{{ _('Filtered by client. Leave empty for a client-only entry.') }}

+

{{ _('Pick a client first, then choose one of their projects. Leave empty for a client-only entry.') }}

@@ -423,17 +424,27 @@

' + noTaskText + ''; - taskSelect.disabled = true; - if (window.ttEnhanceSearchableSelects) window.ttEnhanceSearchableSelects(); + projectSelect.disabled = true; + try { projectSelect.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {} + } else if (projectSelect.disabled) { + projectSelect.disabled = false; + } + if (projectSelect.value) { + const opt = projectSelect.options[projectSelect.selectedIndex]; + const projectClientId = opt ? (opt.getAttribute('data-client-id') || '') : ''; + if (cid && projectClientId && projectClientId !== cid) { + projectSelect.value = ''; + try { projectSelect.dispatchEvent(new Event('change', { bubbles: true })); } catch (_) {} } } } diff --git a/app/utils/assets.py b/app/utils/assets.py index e409ca23..3b0d3a1c 100644 --- a/app/utils/assets.py +++ b/app/utils/assets.py @@ -95,6 +95,25 @@ def asset_url(name: str, fallback: Optional[str] = None) -> str: return url_for("static", filename=path) +def static_url(filename: str) -> str: + """Cache-busted URL for source-controlled static files. + + Unlike the hashed dist bundles, files such as searchable-select.js are + served unhashed under a stable URL. Appending the file's mtime as a query + parameter makes browsers pick up edits without waiting for a release + version bump (the failure mode behind the stale dark-mode combobox CSS). + """ + import os + + try: + path = os.path.join(current_app.static_folder or "static", filename) + version = int(os.stat(path).st_mtime) + except OSError: + version = 0 + return url_for("static", filename=filename, v=version) + + def register_asset_helpers(app) -> None: - """Expose ``asset_url`` to templates.""" + """Expose ``asset_url`` / ``static_url`` to templates.""" app.jinja_env.globals["asset_url"] = asset_url + app.jinja_env.globals["static_url"] = static_url diff --git a/tests/test_issue_728_client_only_edit.py b/tests/test_issue_728_client_only_edit.py index 4833cf46..c98a0d82 100644 --- a/tests/test_issue_728_client_only_edit.py +++ b/tests/test_issue_728_client_only_edit.py @@ -270,3 +270,35 @@ def test_child_template_blocks_exist_in_base(): # Explicitly assert the regression that shipped broken assert "extra_js" in base_blocks assert "scripts_extra" in base_blocks + + +@pytest.mark.integration +@pytest.mark.routes +def test_manual_entry_project_gated_on_client_and_modal_rate(app, authenticated_client, project): + """Manual entry follows the client -> project -> task cascade (#728). + + - Project picker is disabled until a client is selected. + - Inline create-project modal carries a client default rate and a locked + client display so the project attaches to the chosen client. + - Client options expose data-default-rate for the JS prefill. + """ + manual = authenticated_client.get("/timer/manual") + assert manual.status_code == 200 + html = manual.get_data(as_text=True) + + # Project select disabled without a selected client (JS re-enables it) + project_select = re.search(r']*id="project_id".*?', html, re.S) + assert project_select, "project select missing on manual entry" + assert "disabled" in project_select.group(0) + assert 'data-filter-by="client_id"' in project_select.group(0) + + # Create-project modal: rate field + locked client display + assert 'id="inlineProjectRateField"' in html + assert 'id="inline_project_hourly_rate"' in html + assert 'id="inlineProjectClientDisplay"' in html + assert 'id="inline_project_client_id_locked"' in html + + # Client options carry their default rate for the prefill + assert re.search(r'