mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
Merge pull request #5288 from nesquena/release/stage-4881
Release v0.51.774 — Kanban modal fields (#4881)
This commit is contained in:
@@ -35,6 +35,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Kanban "New task" modal now exposes skills, max runtime, and parent dependencies.** The WebUI task creator reaches parity with the CLI Kanban fields: a Skills input (comma-separated, e.g. `python,git`), a Max runtime (seconds) field (empty = unlimited), and a Parent dependencies (task ID) field. Max-runtime input is validated (`/^[1-9]\d*$/`) with an inline localized error so a junk value can't silently become 1 or unlimited; all fields are XSS-safe. Thanks @rodboev. (#4881, #4470)
|
||||
|
||||
- **Mermaid diagrams in chat now have on-diagram zoom / pan / fit / fullscreen controls.** A GitHub-style toolbar (zoom in, zoom out, reset, fit, fullscreen) mounts on each rendered Mermaid diagram so large flowcharts are navigable in place instead of static; zoom is clamped 0.25×–8×, a drag is suppressed from opening the lightbox accidentally while a clean click still opens it, and the Mermaid source is HTML-escaped (no XSS). Thanks @rodboev. (#4871, #4814)
|
||||
|
||||
- **Mobile titlebar now prioritizes the session title, with tap-to-reveal and long-press actions.** On mobile the titlebar gives the session title priority (ellipsized when long) over surrounding chrome; tapping the title reveals the full title in a popover, and a long-press opens the session actions. Listener-leak-safe (tap wired once per element, outside-click handler cleaned up on dismiss/switch; long-press guarded against double-fire, cancelled on touchmove). Read-only sessions are skipped, and desktop is untouched (touch-gated). Thanks @rodboev. (#4574, #4520)
|
||||
|
||||
@@ -961,6 +961,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -2609,6 +2616,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -4248,6 +4262,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -5589,6 +5610,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -7165,6 +7193,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -8719,6 +8754,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -10328,6 +10370,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -12194,6 +12243,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -13721,6 +13777,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -15258,6 +15321,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -16915,6 +16985,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Répertoire',
|
||||
kanban_workspace_path_required: 'Le chemin de l\'espace de travail est requis pour les espaces de travail non-brouillon.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Ajouter une dépendance',
|
||||
kanban_remove_dependency: 'Supprimer la dépendance',
|
||||
kanban_dependency_placeholder: 'ID de la tâche à lier',
|
||||
@@ -18515,6 +18592,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Directory',
|
||||
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Add dependency',
|
||||
kanban_remove_dependency: 'Remove dependency',
|
||||
kanban_dependency_placeholder: 'Task ID to link',
|
||||
@@ -20169,6 +20253,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Katalog',
|
||||
kanban_workspace_path_required: 'Ścieżka do obszaru roboczego jest wymagana dla obszarów innych niż tymczasowe (scratch).',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Dodaj zależność',
|
||||
kanban_remove_dependency: 'Usuń zależność',
|
||||
kanban_dependency_placeholder: 'Identyfikator zadania do powiązania',
|
||||
@@ -21653,6 +21744,13 @@ const LOCALES = {
|
||||
kanban_workspace_worktree: 'Worktree',
|
||||
kanban_workspace_dir: 'Thư mục',
|
||||
kanban_workspace_path_required: 'Workspace không phải tạm thời cần có đường dẫn.',
|
||||
kanban_skills: 'Skills',
|
||||
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
|
||||
kanban_max_runtime_seconds: 'Max runtime (seconds)',
|
||||
kanban_max_runtime_hint: 'Leave empty for unlimited.',
|
||||
kanban_max_runtime_invalid: 'Max runtime must be a whole number of seconds greater than 0.',
|
||||
kanban_parents_modal: 'Parent dependencies',
|
||||
kanban_parents_placeholder: 'Parent task ID',
|
||||
kanban_add_dependency: 'Thêm phụ thuộc',
|
||||
kanban_remove_dependency: 'Xóa phụ thuộc',
|
||||
kanban_dependency_placeholder: 'ID tác vụ cần liên kết',
|
||||
|
||||
+17
-1
@@ -1748,6 +1748,7 @@
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanTaskModalPriority" data-i18n="kanban_priority">Priority</label>
|
||||
<input type="number" id="kanbanTaskModalPriority" value="0" min="-100" max="100" step="1">
|
||||
<div class="kanban-modal-hint" id="kanbanTaskModalPriorityHint" data-i18n="kanban_priority_hint">Higher numbers run first. Default 0.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kanban-modal-row">
|
||||
@@ -1772,7 +1773,22 @@
|
||||
</div>
|
||||
<div class="kanban-modal-row" id="kanbanTaskModalWorkspacePathRow" style="display:none">
|
||||
<label for="kanbanTaskModalWorkspacePath" data-i18n="kanban_workspace_path">Workspace path</label>
|
||||
<input type="text" id="kanbanTaskModalWorkspacePath" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_workspace_path_placeholder" placeholder="Path to workspace">
|
||||
<input type="text" id="kanbanTaskModalWorkspacePath" list="kanbanTaskModalWorkspacePathList" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_workspace_path_placeholder" placeholder="Path to workspace">
|
||||
<datalist id="kanbanTaskModalWorkspacePathList"></datalist>
|
||||
</div>
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanTaskModalSkills" data-i18n="kanban_skills">Skills</label>
|
||||
<input type="text" id="kanbanTaskModalSkills" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_skills_placeholder" placeholder="Comma-separated skill names, e.g. python,git">
|
||||
</div>
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanTaskModalMaxRuntimeSeconds" data-i18n="kanban_max_runtime_seconds">Max runtime (seconds)</label>
|
||||
<input type="number" id="kanbanTaskModalMaxRuntimeSeconds" min="1" step="1" autocomplete="off">
|
||||
<div class="kanban-modal-hint" data-i18n="kanban_max_runtime_hint">Leave empty for unlimited.</div>
|
||||
</div>
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanTaskModalParents" data-i18n="kanban_parents_modal">Parent dependencies</label>
|
||||
<input type="text" id="kanbanTaskModalParents" list="kanbanTaskModalParentsList" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_parents_placeholder" placeholder="Parent task ID">
|
||||
<datalist id="kanbanTaskModalParentsList"></datalist>
|
||||
</div>
|
||||
<div class="kanban-modal-error" id="kanbanTaskModalError" aria-live="polite"></div>
|
||||
<div class="kanban-modal-actions">
|
||||
|
||||
+58
-8
@@ -2784,6 +2784,8 @@ function openKanbanCreate(){
|
||||
}
|
||||
});
|
||||
_kanbanPopulateTenantDatalist();
|
||||
_kanbanPopulateWorkspacePathDatalist();
|
||||
_kanbanPopulateParentsDatalist();
|
||||
modal.hidden = false;
|
||||
if (_kanbanTaskModalFocusCleanup) {
|
||||
_kanbanTaskModalFocusCleanup();
|
||||
@@ -2874,6 +2876,9 @@ function _kanbanResetTaskModalFields(values){
|
||||
// .value before the options exist would silently fail.
|
||||
set('kanbanTaskModalTenant', v.tenant || '');
|
||||
set('kanbanTaskModalPriority', v.priority != null ? v.priority : 0);
|
||||
set('kanbanTaskModalSkills', Array.isArray(v.skills) ? v.skills.join(', ') : (v.skills || ''));
|
||||
set('kanbanTaskModalMaxRuntimeSeconds', v.max_runtime_seconds != null ? v.max_runtime_seconds : '');
|
||||
set('kanbanTaskModalParents', '');
|
||||
const errEl = document.getElementById('kanbanTaskModalError');
|
||||
if (errEl) { errEl.textContent = ''; delete errEl.dataset.warningShown; }
|
||||
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
|
||||
@@ -2883,20 +2888,25 @@ function _kanbanResetTaskModalFields(values){
|
||||
function _kanbanSetTaskModalLabels(mode){
|
||||
const titleH = document.getElementById('kanbanTaskModalTitle');
|
||||
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
|
||||
const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind');
|
||||
const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath');
|
||||
if (mode === 'edit') {
|
||||
if (titleH) titleH.textContent = t('kanban_edit_task') || 'Edit task';
|
||||
if (submitBtn) submitBtn.textContent = t('save') || 'Save';
|
||||
// Disable workspace fields during edit since they are not handled by the backend
|
||||
if (workspaceKindEl) workspaceKindEl.disabled = true;
|
||||
if (workspacePathEl) workspacePathEl.disabled = true;
|
||||
} else {
|
||||
if (titleH) titleH.textContent = t('kanban_new_task') || 'New task';
|
||||
if (submitBtn) submitBtn.textContent = t('create') || 'Create';
|
||||
// Enable workspace fields during create
|
||||
if (workspaceKindEl) workspaceKindEl.disabled = false;
|
||||
if (workspacePathEl) workspacePathEl.disabled = false;
|
||||
}
|
||||
// Workspace and new backend fields are create-only; backend patch doesn't handle them.
|
||||
const createOnlyIds = [
|
||||
'kanbanTaskModalWorkspaceKind',
|
||||
'kanbanTaskModalWorkspacePath',
|
||||
'kanbanTaskModalSkills',
|
||||
'kanbanTaskModalMaxRuntimeSeconds',
|
||||
'kanbanTaskModalParents',
|
||||
];
|
||||
const disabled = mode === 'edit';
|
||||
for (const id of createOnlyIds) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2919,6 +2929,27 @@ function _kanbanPopulateTenantDatalist(){
|
||||
if (tList) tList.innerHTML = tenants.map(v => `<option value="${esc(v)}"></option>`).join('');
|
||||
}
|
||||
|
||||
function _kanbanPopulateWorkspacePathDatalist(){
|
||||
const cols = (_kanbanBoard && _kanbanBoard.columns) || [];
|
||||
const seen = new Set();
|
||||
const opts = [];
|
||||
for (const col of cols) {
|
||||
for (const task of (col.tasks || [])) {
|
||||
const path = task && task.workspace_path;
|
||||
if (!path || seen.has(path)) continue;
|
||||
seen.add(path);
|
||||
opts.push(`<option value="${esc(path)}"></option>`);
|
||||
}
|
||||
}
|
||||
const list = document.getElementById('kanbanTaskModalWorkspacePathList');
|
||||
if (list) list.innerHTML = opts.join('');
|
||||
}
|
||||
|
||||
function _kanbanPopulateParentsDatalist(){
|
||||
const list = document.getElementById('kanbanTaskModalParentsList');
|
||||
if (list) list.innerHTML = _kanbanLinkableTaskOptions(null);
|
||||
}
|
||||
|
||||
function _trapModalFocus(modalEl){
|
||||
if (!modalEl) return () => {};
|
||||
const selector = 'a[href], button, textarea, input, select, summary, [tabindex]:not([tabindex="-1"])';
|
||||
@@ -3007,6 +3038,9 @@ async function submitKanbanTaskModal(){
|
||||
const priorityEl = document.getElementById('kanbanTaskModalPriority');
|
||||
const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind');
|
||||
const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath');
|
||||
const skillsEl = document.getElementById('kanbanTaskModalSkills');
|
||||
const maxRuntimeEl = document.getElementById('kanbanTaskModalMaxRuntimeSeconds');
|
||||
const parentsEl = document.getElementById('kanbanTaskModalParents');
|
||||
const errEl = document.getElementById('kanbanTaskModalError');
|
||||
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
|
||||
const title = titleEl ? titleEl.value.trim() : '';
|
||||
@@ -3035,6 +3069,9 @@ async function submitKanbanTaskModal(){
|
||||
const statusVal = statusEl ? statusEl.value : '';
|
||||
const priorityRaw = priorityEl ? priorityEl.value : '';
|
||||
const workspacePathVal = workspacePathEl ? workspacePathEl.value.trim() : '';
|
||||
const skillsRaw = skillsEl ? skillsEl.value.trim() : '';
|
||||
const maxRuntimeRaw = maxRuntimeEl ? maxRuntimeEl.value.trim() : '';
|
||||
const parentsRaw = parentsEl ? parentsEl.value.trim() : '';
|
||||
if (isEdit) {
|
||||
payload.body = bodyVal;
|
||||
payload.assignee = assigneeVal || null;
|
||||
@@ -3061,6 +3098,19 @@ async function submitKanbanTaskModal(){
|
||||
}
|
||||
payload.workspace_kind = workspaceKind;
|
||||
if (workspacePathVal) payload.workspace_path = workspacePathVal;
|
||||
if (skillsRaw) {
|
||||
payload.skills = skillsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (maxRuntimeRaw) {
|
||||
if (!/^[1-9]\d*$/.test(maxRuntimeRaw)) {
|
||||
if (errEl) errEl.textContent = t('kanban_max_runtime_invalid')
|
||||
|| 'Max runtime must be a whole number of seconds greater than 0.';
|
||||
if (maxRuntimeEl) maxRuntimeEl.focus();
|
||||
return;
|
||||
}
|
||||
payload.max_runtime_seconds = Number(maxRuntimeRaw);
|
||||
}
|
||||
if (parentsRaw) payload.parents = [parentsRaw];
|
||||
}
|
||||
// Soft warning: a Ready task with the explicit "Unassigned" option will sit
|
||||
// forever because the dispatcher skips unassigned rows (kanban_db.py:3567).
|
||||
|
||||
@@ -90,8 +90,9 @@ class TestWorkspacePathValidation:
|
||||
"""Modal must disable workspace fields when editing to prevent confusion."""
|
||||
# The _kanbanSetTaskModalLabels function must disable fields during edit
|
||||
assert "_kanbanSetTaskModalLabels" in PANELS_JS
|
||||
assert "workspaceKindEl.disabled = true" in PANELS_JS
|
||||
assert "workspacePathEl.disabled = true" in PANELS_JS
|
||||
assert "kanbanTaskModalWorkspaceKind" in PANELS_JS
|
||||
assert "kanbanTaskModalWorkspacePath" in PANELS_JS
|
||||
assert "el.disabled = disabled" in PANELS_JS
|
||||
|
||||
|
||||
class TestDependencyControls:
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Source-inspection and behavioral tests for Kanban modal fields added in #4470."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
KANBAN_BRIDGE = (ROOT / "api" / "kanban_bridge.py").read_text(encoding="utf-8")
|
||||
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def test_modal_html_exposes_new_fields():
|
||||
for field_id in (
|
||||
"kanbanTaskModalSkills",
|
||||
"kanbanTaskModalMaxRuntimeSeconds",
|
||||
"kanbanTaskModalParents",
|
||||
"kanbanTaskModalParentsList",
|
||||
"kanbanTaskModalWorkspacePathList",
|
||||
"kanbanTaskModalPriorityHint",
|
||||
):
|
||||
assert f'id="{field_id}"' in INDEX_HTML, f"Missing: {field_id}"
|
||||
assert 'list="kanbanTaskModalParentsList"' in INDEX_HTML
|
||||
assert 'list="kanbanTaskModalWorkspacePathList"' in INDEX_HTML
|
||||
assert 'data-i18n="kanban_priority_hint"' in INDEX_HTML
|
||||
|
||||
|
||||
def _submit_body():
|
||||
m = re.search(
|
||||
r"async function submitKanbanTaskModal\(\)\s*\{(.*?)\n\}",
|
||||
PANELS_JS, re.DOTALL,
|
||||
)
|
||||
assert m, "submitKanbanTaskModal() not found"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_submit_reads_and_sends_skills_as_array():
|
||||
body = _submit_body()
|
||||
assert "kanbanTaskModalSkills" in body
|
||||
assert "payload.skills" in body
|
||||
assert ".split(',')" in body or ".split(', ')" in body
|
||||
|
||||
|
||||
def test_submit_reads_and_sends_max_runtime_as_integer():
|
||||
body = _submit_body()
|
||||
assert "kanbanTaskModalMaxRuntimeSeconds" in body
|
||||
assert "payload.max_runtime_seconds" in body
|
||||
assert "/^[1-9]\\d*$/" in body
|
||||
assert "Number(maxRuntimeRaw)" in body
|
||||
assert "kanban_max_runtime_invalid" in body
|
||||
|
||||
|
||||
def test_submit_reads_and_sends_parents_as_list():
|
||||
body = _submit_body()
|
||||
assert "kanbanTaskModalParents" in body
|
||||
assert "payload.parents" in body
|
||||
assert "[parentsRaw]" in body or "payload.parents = [" in body
|
||||
|
||||
|
||||
def test_new_fields_only_in_create_branch():
|
||||
body = _submit_body()
|
||||
edit_match = re.search(r"if \(isEdit\) \{(.*?)\} else \{", body, re.DOTALL)
|
||||
assert edit_match, "isEdit...else block not found"
|
||||
edit_branch = edit_match.group(1)
|
||||
assert "payload.skills" not in edit_branch
|
||||
assert "payload.max_runtime_seconds" not in edit_branch
|
||||
assert "payload.parents" not in edit_branch
|
||||
|
||||
|
||||
def test_reset_handler_covers_new_fields():
|
||||
m = re.search(
|
||||
r"function _kanbanResetTaskModalFields\([^)]*\)\s*\{(.*?)\n\}",
|
||||
PANELS_JS, re.DOTALL,
|
||||
)
|
||||
assert m, "_kanbanResetTaskModalFields() not found"
|
||||
body = m.group(1)
|
||||
assert "kanbanTaskModalSkills" in body
|
||||
assert "kanbanTaskModalMaxRuntimeSeconds" in body
|
||||
assert "kanbanTaskModalParents" in body
|
||||
|
||||
|
||||
def test_labels_handler_disables_new_fields_in_edit():
|
||||
m = re.search(
|
||||
r"function _kanbanSetTaskModalLabels\([^)]*\)\s*\{(.*?)\n\}",
|
||||
PANELS_JS, re.DOTALL,
|
||||
)
|
||||
assert m, "_kanbanSetTaskModalLabels() not found"
|
||||
body = m.group(1)
|
||||
assert "kanbanTaskModalSkills" in body
|
||||
assert "kanbanTaskModalMaxRuntimeSeconds" in body
|
||||
assert "kanbanTaskModalParents" in body
|
||||
assert "disabled" in body
|
||||
|
||||
|
||||
def test_datalist_helpers_exist_and_are_called():
|
||||
fn_wp = "function _kanbanPopulateWorkspacePathDatalist"
|
||||
fn_par = "function _kanbanPopulateParentsDatalist"
|
||||
assert fn_wp in PANELS_JS
|
||||
assert "kanbanTaskModalWorkspacePathList" in PANELS_JS
|
||||
# Check that workspace_path appears inside the function definition body
|
||||
fn_start = PANELS_JS.find(fn_wp)
|
||||
assert "workspace_path" in PANELS_JS[fn_start:fn_start + 500], \
|
||||
"workspace_path not found in _kanbanPopulateWorkspacePathDatalist body"
|
||||
assert fn_par in PANELS_JS
|
||||
assert "_kanbanLinkableTaskOptions(null)" in PANELS_JS
|
||||
|
||||
m = re.search(r"function openKanbanCreate\(\)\s*\{(.*?)\n\}", PANELS_JS, re.DOTALL)
|
||||
assert m, "openKanbanCreate() not found"
|
||||
body = m.group(1)
|
||||
assert "_kanbanPopulateWorkspacePathDatalist()" in body
|
||||
assert "_kanbanPopulateParentsDatalist()" in body
|
||||
|
||||
|
||||
def test_i18n_new_keys_in_all_locales():
|
||||
required = [
|
||||
"kanban_priority_hint",
|
||||
"kanban_skills",
|
||||
"kanban_skills_placeholder",
|
||||
"kanban_max_runtime_seconds",
|
||||
"kanban_max_runtime_hint",
|
||||
"kanban_max_runtime_invalid",
|
||||
"kanban_parents_modal",
|
||||
"kanban_parents_placeholder",
|
||||
]
|
||||
for key in required:
|
||||
count = I18N_JS.count(f"{key}:")
|
||||
assert count == 14, f"Key '{key}' appears {count} times, expected 14 (one per locale)"
|
||||
|
||||
|
||||
def test_backend_already_accepts_new_fields():
|
||||
assert "skills=body.get(" in KANBAN_BRIDGE
|
||||
assert "max_runtime_seconds=body.get(" in KANBAN_BRIDGE
|
||||
assert "parents=body.get(" in KANBAN_BRIDGE
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
||||
def test_submit_payload_behavioral_and_validation():
|
||||
"""Node.js execution: extract submitKanbanTaskModal and verify payload construction."""
|
||||
fn_start = PANELS_JS.find("async function submitKanbanTaskModal(")
|
||||
assert fn_start >= 0, "submitKanbanTaskModal not found"
|
||||
brace_start = PANELS_JS.index("{", fn_start)
|
||||
depth = 0
|
||||
fn_end = brace_start
|
||||
for i in range(brace_start, len(PANELS_JS)):
|
||||
if PANELS_JS[i] == "{":
|
||||
depth += 1
|
||||
elif PANELS_JS[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
fn_end = i + 1
|
||||
break
|
||||
fn_body = PANELS_JS[fn_start:fn_end]
|
||||
|
||||
harness = (
|
||||
"// Stub minimal DOM\n"
|
||||
"let lastFocused = null;\n"
|
||||
"function field(id, value = '') {\n"
|
||||
" return { value, disabled: false, focus() { lastFocused = id; } };\n"
|
||||
"}\n"
|
||||
"const elements = {\n"
|
||||
" kanbanTaskModalTitleInput: field('kanbanTaskModalTitleInput', 'Test Task'),\n"
|
||||
" kanbanTaskModalBody: field('kanbanTaskModalBody', ''),\n"
|
||||
" kanbanTaskModalStatus: field('kanbanTaskModalStatus', 'triage'),\n"
|
||||
" kanbanTaskModalAssignee: field('kanbanTaskModalAssignee', 'agent1'),\n"
|
||||
" kanbanTaskModalTenant: field('kanbanTaskModalTenant', ''),\n"
|
||||
" kanbanTaskModalPriority: field('kanbanTaskModalPriority', '0'),\n"
|
||||
" kanbanTaskModalWorkspaceKind: field('kanbanTaskModalWorkspaceKind', 'scratch'),\n"
|
||||
" kanbanTaskModalWorkspacePath: field('kanbanTaskModalWorkspacePath', ''),\n"
|
||||
" kanbanTaskModalSkills: field('kanbanTaskModalSkills', 'python, git'),\n"
|
||||
" kanbanTaskModalMaxRuntimeSeconds: field('kanbanTaskModalMaxRuntimeSeconds', '120'),\n"
|
||||
" kanbanTaskModalParents: field('kanbanTaskModalParents', 't_abc'),\n"
|
||||
" kanbanTaskModalError: { textContent: '', dataset: {} },\n"
|
||||
" kanbanTaskModalSubmit: { disabled: false },\n"
|
||||
"};\n"
|
||||
"global.document = { getElementById: (id) => elements[id] || null };\n"
|
||||
"function t(k) { return k; }\n"
|
||||
"let capturedPayload = null;\n"
|
||||
"async function api(url, opts) {\n"
|
||||
" capturedPayload = opts ? JSON.parse(opts.body) : null;\n"
|
||||
" return { task: { id: 't_new' } };\n"
|
||||
"}\n"
|
||||
"async function loadKanban() {}\n"
|
||||
"async function loadKanbanTask() {}\n"
|
||||
"function _kanbanBoardQuery() { return ''; }\n"
|
||||
"function closeKanbanTaskModal() {}\n"
|
||||
"let _kanbanTaskModalMode = 'create';\n"
|
||||
"let _kanbanTaskModalEditingId = null;\n"
|
||||
"let _kanbanTaskModalInitialDisplayedStatus = null;\n"
|
||||
+ fn_body + "\n"
|
||||
"function resetForCase(maxRuntimeValue) {\n"
|
||||
" capturedPayload = null;\n"
|
||||
" lastFocused = null;\n"
|
||||
" elements.kanbanTaskModalError.textContent = '';\n"
|
||||
" delete elements.kanbanTaskModalError.dataset.warningShown;\n"
|
||||
" elements.kanbanTaskModalSubmit.disabled = false;\n"
|
||||
" elements.kanbanTaskModalTitleInput.value = 'Test Task';\n"
|
||||
" elements.kanbanTaskModalBody.value = '';\n"
|
||||
" elements.kanbanTaskModalStatus.value = 'triage';\n"
|
||||
" elements.kanbanTaskModalAssignee.value = 'agent1';\n"
|
||||
" elements.kanbanTaskModalTenant.value = '';\n"
|
||||
" elements.kanbanTaskModalPriority.value = '0';\n"
|
||||
" elements.kanbanTaskModalWorkspaceKind.value = 'scratch';\n"
|
||||
" elements.kanbanTaskModalWorkspacePath.value = '';\n"
|
||||
" elements.kanbanTaskModalSkills.value = 'python, git';\n"
|
||||
" elements.kanbanTaskModalMaxRuntimeSeconds.value = maxRuntimeValue;\n"
|
||||
" elements.kanbanTaskModalParents.value = 't_abc';\n"
|
||||
"}\n"
|
||||
"async function runCase(maxRuntimeValue) {\n"
|
||||
" resetForCase(maxRuntimeValue);\n"
|
||||
" await submitKanbanTaskModal();\n"
|
||||
" return {\n"
|
||||
" payload: capturedPayload,\n"
|
||||
" error: elements.kanbanTaskModalError.textContent,\n"
|
||||
" focused: lastFocused,\n"
|
||||
" submitDisabled: elements.kanbanTaskModalSubmit.disabled,\n"
|
||||
" };\n"
|
||||
"}\n"
|
||||
"(async () => {\n"
|
||||
" const results = [];\n"
|
||||
" for (const value of ['120', '', '0', '-5', '1.5', '1e2', 'abc']) {\n"
|
||||
" results.push(await runCase(value));\n"
|
||||
" }\n"
|
||||
" console.log(JSON.stringify(results));\n"
|
||||
"})().catch(e => { process.stderr.write(String(e)); process.exit(1); });\n"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", harness],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, f"Node error: {result.stderr}"
|
||||
output = result.stdout.strip()
|
||||
assert output, f"No payload captured. stderr: {result.stderr}"
|
||||
results = json.loads(output)
|
||||
|
||||
valid, empty, zero, negative, decimal, scientific, alpha = results
|
||||
valid_payload = valid["payload"]
|
||||
assert valid_payload.get("skills") == ["python", "git"], f"skills wrong: {valid_payload.get('skills')}"
|
||||
assert valid_payload.get("max_runtime_seconds") == 120, f"max_runtime wrong: {valid_payload.get('max_runtime_seconds')}"
|
||||
assert valid_payload.get("parents") == ["t_abc"], f"parents wrong: {valid_payload.get('parents')}"
|
||||
assert valid["error"] == ""
|
||||
assert valid["focused"] is None
|
||||
|
||||
empty_payload = empty["payload"]
|
||||
assert "max_runtime_seconds" not in empty_payload, f"empty max runtime should be omitted: {empty_payload}"
|
||||
assert empty["error"] == ""
|
||||
assert empty["focused"] is None
|
||||
|
||||
for invalid_case in (zero, negative, decimal, scientific, alpha):
|
||||
assert invalid_case["payload"] is None, f"invalid runtime should not submit: {invalid_case}"
|
||||
assert invalid_case["error"] == "kanban_max_runtime_invalid", f"wrong validation message: {invalid_case}"
|
||||
assert invalid_case["focused"] == "kanbanTaskModalMaxRuntimeSeconds", f"wrong focus target: {invalid_case}"
|
||||
assert invalid_case["submitDisabled"] is False, f"submit button should stay enabled: {invalid_case}"
|
||||
Reference in New Issue
Block a user