feat: feedback verbetering 02

This commit is contained in:
kodi
2026-03-15 13:52:48 +01:00
parent 9a7ca4e2db
commit 492082c2b7
4 changed files with 176 additions and 8 deletions
@@ -242,7 +242,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
self._extract_js_function(app_js, "activeTasksFromItems"), self._extract_js_function(app_js, "activeTasksFromItems"),
self._extract_js_function(app_js, "taskIsCancellable"), self._extract_js_function(app_js, "taskIsCancellable"),
self._extract_js_function(app_js, "cancelTaskRequest"), self._extract_js_function(app_js, "cancelTaskRequest"),
self._extract_js_function(app_js, "formatTaskOperationLabel"),
self._extract_js_function(app_js, "hasMeaningfulItemProgress"),
self._extract_js_function(app_js, "canShowChipItemProgress"),
self._extract_js_function(app_js, "compactTaskCurrentItem"),
self._extract_js_function(app_js, "activeTaskChipLabel"), self._extract_js_function(app_js, "activeTaskChipLabel"),
self._extract_js_function(app_js, "taskProgressText"),
self._extract_js_function(app_js, "taskProgressSubtext"),
self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"), self._extract_js_function(app_js, "shouldPollHeaderTasks"),
self._extract_js_function(app_js, "stopHeaderTaskPolling"), self._extract_js_function(app_js, "stopHeaderTaskPolling"),
@@ -294,6 +300,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
disabled: false, disabled: false,
onclick: null, onclick: null,
scrollTop: 0, scrollTop: 0,
title: "",
attributes: {{}}, attributes: {{}},
append(...nodes) {{ append(...nodes) {{
this.children.push(...nodes); this.children.push(...nodes);
@@ -354,7 +361,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
{{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, {{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }},
{{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, {{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }},
{{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }},
{{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop" }}, {{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop", done_items: 1, total_items: 4, current_item: "nested/final-file.txt" }},
{{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }}, {{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
{{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }}, {{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }},
@@ -366,7 +373,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active");
assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow"); assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow");
assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping"); assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping");
assert(activeTaskChipLabel(activeTasks.length) === "5 active tasks", "Chip label should reflect active task count"); assert(activeTaskChipLabel(activeTasks) === "5 active tasks", "Chip label should reflect active task count");
updateHeaderTaskState(mixedTasks); updateHeaderTaskState(mixedTasks);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks");
@@ -378,13 +385,45 @@ class UiSmokeGoldenTest(unittest.TestCase):
assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open");
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state");
assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active file-action tasks"); assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active file-action tasks");
const moveRow = elements["header-task-popover-list"].children[1];
const moveProgress = moveRow.children[3];
const moveCurrent = moveRow.children[4];
assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available");
assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item");
const cancellingRow = elements["header-task-popover-list"].children[4];
const cancellingProgress = cancellingRow.children[3];
const cancellingCurrent = cancellingRow.children[4];
const cancellingSubtext = cancellingRow.children[5];
assert(cancellingProgress.textContent === "1/4", "Cancelling tasks should keep progress visible");
assert(cancellingCurrent.textContent === "nested/final-file.txt", "Cancelling tasks should show current item");
assert(cancellingSubtext.textContent === "Stopping after current item...", "Cancelling tasks should explain stop semantics");
const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0]; const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0];
const cancellingActionButton = elements["header-task-popover-list"].children[4].children[3].children[0]; const cancellingActionButton = elements["header-task-popover-list"].children[4].children[6].children[0];
assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action"); assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action");
assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable");
assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state");
assert(cancellingActionButton.disabled, "Cancelling tasks should not expose a second stop action"); assert(cancellingActionButton.disabled, "Cancelling tasks should not expose a second stop action");
updateHeaderTaskState([
{{ id: "single-copy", operation: "copy", status: "running", source: "/src/a", destination: "/dst/a", done_items: 7, total_items: 20, current_item: "season1/episode07.mkv" }},
]);
assert(elements["header-task-chip-label"].textContent === "Copy 7/20", "Single copy task should show compact item progress in chip");
updateHeaderTaskState([
{{ id: "single-dup", operation: "duplicate", status: "running", source: "/src/a", destination: "/dst/a copy", done_items: 3, total_items: 12, current_item: "nested/file.txt" }},
]);
assert(elements["header-task-chip-label"].textContent === "Duplicate 3/12", "Single duplicate task should show compact item progress in chip");
updateHeaderTaskState([
{{ id: "single-move", operation: "move", status: "running", source: "/src/dir", destination: "/dst/dir", done_items: 0, total_items: 1, current_item: "Folder" }},
]);
assert(elements["header-task-chip-label"].textContent === "Move running", "Single move task should stay coarse in chip");
updateHeaderTaskState([
{{ id: "single-cancelling", operation: "copy", status: "cancelling", source: "/src/a", destination: "/dst/a", done_items: 2, total_items: 5, current_item: "nested/file.txt" }},
]);
assert(elements["header-task-chip-label"].textContent === "Copy cancelling", "Single cancelling task should surface cancelling state in chip");
updateHeaderTaskState([ updateHeaderTaskState([
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }},
{{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }}, {{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }},
@@ -416,7 +455,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
self._extract_js_function(app_js, "activeTasksFromItems"), self._extract_js_function(app_js, "activeTasksFromItems"),
self._extract_js_function(app_js, "taskIsCancellable"), self._extract_js_function(app_js, "taskIsCancellable"),
self._extract_js_function(app_js, "cancelTaskRequest"), self._extract_js_function(app_js, "cancelTaskRequest"),
self._extract_js_function(app_js, "formatTaskOperationLabel"),
self._extract_js_function(app_js, "hasMeaningfulItemProgress"),
self._extract_js_function(app_js, "canShowChipItemProgress"),
self._extract_js_function(app_js, "compactTaskCurrentItem"),
self._extract_js_function(app_js, "activeTaskChipLabel"), self._extract_js_function(app_js, "activeTaskChipLabel"),
self._extract_js_function(app_js, "taskProgressText"),
self._extract_js_function(app_js, "taskProgressSubtext"),
self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"), self._extract_js_function(app_js, "shouldPollHeaderTasks"),
self._extract_js_function(app_js, "stopHeaderTaskPolling"), self._extract_js_function(app_js, "stopHeaderTaskPolling"),
@@ -527,7 +572,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
{{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }}, {{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }},
]); ]);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible"); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible");
assert(elements["header-task-chip-label"].textContent === "1 active task", "Chip should show one active task"); assert(elements["header-task-chip-label"].textContent === "Copy running", "Single active task should show compact task status");
assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state"); assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state");
applyTaskSnapshot([ applyTaskSnapshot([
@@ -810,7 +855,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function activeTasksFromItems(items)', app_js) self.assertIn('function activeTasksFromItems(items)', app_js)
self.assertIn('function taskIsCancellable(task)', app_js) self.assertIn('function taskIsCancellable(task)', app_js)
self.assertIn('async function cancelTaskRequest(taskId)', app_js) self.assertIn('async function cancelTaskRequest(taskId)', app_js)
self.assertIn('function activeTaskChipLabel(count)', app_js) self.assertIn('function formatTaskOperationLabel(task)', app_js)
self.assertIn('function hasMeaningfulItemProgress(task)', app_js)
self.assertIn('function canShowChipItemProgress(task)', app_js)
self.assertIn('function compactTaskCurrentItem(task)', app_js)
self.assertIn('function activeTaskChipLabel(items)', app_js)
self.assertIn('function taskProgressText(task)', app_js)
self.assertIn('function taskProgressSubtext(task)', app_js)
self.assertIn('function shouldPollHeaderTasks()', app_js) self.assertIn('function shouldPollHeaderTasks()', app_js)
self.assertIn('function scheduleHeaderTaskPolling()', app_js) self.assertIn('function scheduleHeaderTaskPolling()', app_js)
self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js) self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js)
@@ -819,6 +870,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function updateHeaderTaskState(taskItems)', app_js) self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
self.assertIn('function applyTaskSnapshot(taskItems)', app_js) self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', app_js) self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', app_js)
self.assertIn('return task.operation === "copy" || task.operation === "duplicate";', app_js)
self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js)
self.assertIn('return `${action} running`;', app_js)
self.assertIn('return "Stopping after current item...";', app_js)
self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js) self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js)
self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js) self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js)
self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js) self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js)
+94 -2
View File
@@ -3895,8 +3895,78 @@ async function cancelTaskRequest(taskId) {
return apiRequest("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`); return apiRequest("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`);
} }
function activeTaskChipLabel(count) { function formatTaskOperationLabel(task) {
const operation = String(task?.operation || "");
if (!operation) {
return "Task";
}
return operation.charAt(0).toUpperCase() + operation.slice(1);
}
function hasMeaningfulItemProgress(task) {
return typeof task?.done_items === "number" && typeof task?.total_items === "number" && task.total_items > 0;
}
function canShowChipItemProgress(task) {
if (!hasMeaningfulItemProgress(task)) {
return false;
}
return task.operation === "copy" || task.operation === "duplicate";
}
function compactTaskCurrentItem(task) {
if (!task?.current_item) {
return "";
}
const value = String(task.current_item).replace(/\\/g, "/");
if (value.length <= 44) {
return value;
}
const parts = value.split("/").filter(Boolean);
if (parts.length >= 2) {
const shortened = `.../${parts.slice(-2).join("/")}`;
if (shortened.length <= 44) {
return shortened;
}
}
return `...${value.slice(-41)}`;
}
function activeTaskChipLabel(items) {
const count = Array.isArray(items) ? items.length : 0;
if (count !== 1) {
return `${count} active task${count === 1 ? "" : "s"}`; return `${count} active task${count === 1 ? "" : "s"}`;
}
const task = items[0];
const action = formatTaskOperationLabel(task);
if (task.status === "cancelling") {
return `${action} cancelling`;
}
if (canShowChipItemProgress(task)) {
return `${action} ${task.done_items}/${task.total_items}`;
}
if (task.status === "queued") {
return `${action} queued`;
}
return `${action} running`;
}
function taskProgressText(task) {
if (!hasMeaningfulItemProgress(task)) {
return "";
}
return `${task.done_items}/${task.total_items}`;
}
function taskProgressSubtext(task) {
if (task?.status === "cancelling") {
return "Stopping after current item...";
}
const progress = taskProgressText(task);
if (progress) {
return `${progress} items processed`;
}
return "";
} }
function headerTaskRenderKey(items) { function headerTaskRenderKey(items) {
@@ -3984,6 +4054,28 @@ function renderHeaderTaskPopover(items) {
meta.className = "header-task-item-meta"; meta.className = "header-task-item-meta";
meta.textContent = line.meta; meta.textContent = line.meta;
row.append(title, path, meta); row.append(title, path, meta);
const progressText = taskProgressText(task);
if (progressText) {
const progress = document.createElement("div");
progress.className = "header-task-item-progress";
progress.textContent = progressText;
row.append(progress);
}
const currentItem = compactTaskCurrentItem(task);
if (currentItem) {
const current = document.createElement("div");
current.className = "header-task-item-current";
current.textContent = currentItem;
current.title = String(task.current_item);
row.append(current);
}
const subtext = taskProgressSubtext(task);
if (subtext) {
const note = document.createElement("div");
note.className = "header-task-item-subtext";
note.textContent = subtext;
row.append(note);
}
if (taskIsCancellable(task) || task.status === "cancelling") { if (taskIsCancellable(task) || task.status === "cancelling") {
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "header-task-item-actions"; actions.className = "header-task-item-actions";
@@ -4020,7 +4112,7 @@ function renderHeaderTaskChip(items) {
} }
const hasActiveTasks = Array.isArray(items) && items.length > 0; const hasActiveTasks = Array.isArray(items) && items.length > 0;
elements.container.classList.toggle("hidden", !hasActiveTasks); elements.container.classList.toggle("hidden", !hasActiveTasks);
elements.chipLabel.textContent = activeTaskChipLabel(items.length); elements.chipLabel.textContent = activeTaskChipLabel(items);
if (!hasActiveTasks) { if (!hasActiveTasks) {
headerTaskState.lastRenderKey = ""; headerTaskState.lastRenderKey = "";
setHeaderTaskPopoverOpen(false); setHeaderTaskPopoverOpen(false);
+21
View File
@@ -157,6 +157,27 @@ body {
word-break: break-word; word-break: break-word;
} }
.header-task-item-progress {
margin-top: 5px;
font-size: 12px;
font-weight: 700;
color: var(--color-text-primary);
}
.header-task-item-current,
.header-task-item-subtext {
margin-top: 4px;
font-size: 12px;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-task-item-current {
color: var(--color-text-primary);
}
.header-task-item-actions { .header-task-item-actions {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;