feat: server-side sort in admin UI with Recently Used / Most Used and run badges
- New web/js/prompt-list-sort.js owns the sort options, validation and request URLs; admin.js fills the dropdown from it and re-fetches from the server on change, so every sort spans all pages (the old client-side sort only reordered the current page). - Default sort is Recently Used; prompt cards show a run-count badge with the last-used time on hover. - The node's Recent button now lists recently used prompts. - Contract test keeps the UI sort keys and the node's key in sync with the server whitelist. Refs #90
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const sort = require("../../web/js/prompt-list-sort.js");
|
||||
|
||||
test("default sort is recently used and is a listed option", () => {
|
||||
assert.equal(sort.DEFAULT_SORT, "last_used_desc");
|
||||
assert.ok(sort.SORT_OPTIONS.some((o) => o.value === sort.DEFAULT_SORT));
|
||||
});
|
||||
|
||||
test("options include Recently used and Most used, each with a label", () => {
|
||||
const values = sort.SORT_OPTIONS.map((o) => o.value);
|
||||
assert.ok(values.includes("last_used_desc"));
|
||||
assert.ok(values.includes("run_count_desc"));
|
||||
for (const option of sort.SORT_OPTIONS) assert.ok(option.label.trim(), option.value);
|
||||
assert.equal(new Set(values).size, values.length, "values are unique");
|
||||
});
|
||||
|
||||
test("options cannot be mutated by callers", () => {
|
||||
assert.throws(() => sort.SORT_OPTIONS.push({ value: "x", label: "x" }));
|
||||
// Non-strict code fails silently on frozen objects, so check the value survived
|
||||
const first = sort.SORT_OPTIONS[0].value;
|
||||
sort.SORT_OPTIONS[0].value = "hacked";
|
||||
assert.equal(sort.SORT_OPTIONS[0].value, first);
|
||||
assert.ok(sort.SORT_OPTIONS.every(Object.isFrozen));
|
||||
});
|
||||
|
||||
test("normalizeSort keeps known values and falls back for anything else", () => {
|
||||
assert.equal(sort.normalizeSort("run_count_desc"), "run_count_desc");
|
||||
for (const bad of [undefined, null, "", "created_at; DROP TABLE prompts", 42, "LAST_USED_DESC"]) {
|
||||
assert.equal(sort.normalizeSort(bad), sort.DEFAULT_SORT, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test("buildRecentUrl computes offset from page and always sends a valid sort", () => {
|
||||
assert.equal(
|
||||
sort.buildRecentUrl({ page: 3, limit: 25, sort: "rating_desc" }),
|
||||
"/prompt_manager/recent?limit=25&offset=50&page=3&sort=rating_desc",
|
||||
);
|
||||
assert.equal(
|
||||
sort.buildRecentUrl({ sort: "bogus" }),
|
||||
"/prompt_manager/recent?limit=50&offset=0&page=1&sort=last_used_desc",
|
||||
);
|
||||
});
|
||||
|
||||
test("withSort returns a copy with the sort set and leaves the input untouched", () => {
|
||||
const params = new URLSearchParams({ text: "castle", sort: "text_asc" });
|
||||
const result = sort.withSort(params, "run_count_desc");
|
||||
assert.equal(result.get("sort"), "run_count_desc");
|
||||
assert.equal(result.get("text"), "castle");
|
||||
assert.equal(params.get("sort"), "text_asc");
|
||||
assert.equal(sort.withSort(new URLSearchParams(), "nope").get("sort"), sort.DEFAULT_SORT);
|
||||
});
|
||||
|
||||
test("formatRunCount labels zero, one and many runs", () => {
|
||||
assert.equal(sort.formatRunCount(0), "Never run");
|
||||
assert.equal(sort.formatRunCount(1), "1 run");
|
||||
assert.equal(sort.formatRunCount(642), "642 runs");
|
||||
assert.equal(sort.formatRunCount(1234), "1,234 runs");
|
||||
});
|
||||
|
||||
test("formatRunCount hides the badge for missing or invalid counts", () => {
|
||||
for (const bad of [undefined, null, -1, 1.5, "3", NaN]) {
|
||||
assert.equal(sort.formatRunCount(bad), "", String(bad));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
"""The UI's sort options must match the server's whitelist exactly."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from database.operations import SORT_ORDERS
|
||||
|
||||
SORT_MODULE = os.path.join(ROOT, "web", "js", "prompt-list-sort.js")
|
||||
NODE_EXTENSION = os.path.join(ROOT, "web", "prompt_manager.js")
|
||||
|
||||
|
||||
class TestSortContract(unittest.TestCase):
|
||||
def test_ui_sort_options_match_server_whitelist(self):
|
||||
with open(SORT_MODULE, encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
ui_values = set(re.findall(r'value:\s*"([a-z_]+)"', source))
|
||||
|
||||
self.assertTrue(ui_values, "no sort options found in prompt-list-sort.js")
|
||||
self.assertEqual(ui_values, set(SORT_ORDERS))
|
||||
|
||||
def test_node_recent_button_requests_recently_used(self):
|
||||
# The node extension can't import the shared module (ComfyUI load order is
|
||||
# not guaranteed), so it sends the key directly; it must be a server key.
|
||||
with open(NODE_EXTENSION, encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
match = re.search(r"/prompt_manager/recent\?[^`'\"]*sort=([a-z_]+)", source)
|
||||
|
||||
self.assertIsNotNone(match, "node Recent request does not send a sort")
|
||||
self.assertEqual(match.group(1), "last_used_desc")
|
||||
self.assertIn(match.group(1), SORT_ORDERS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+3
-6
@@ -107,12 +107,8 @@
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Sort By</label>
|
||||
<select id="sortBy"
|
||||
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
|
||||
<option value="created_desc">Newest First</option>
|
||||
<option value="created_asc">Oldest First</option>
|
||||
<option value="rating_desc">Highest Rated</option>
|
||||
<option value="rating_asc">Lowest Rated</option>
|
||||
<option value="text_asc">A-Z</option>
|
||||
<option value="text_desc">Z-A</option>
|
||||
<!-- Filled from PromptListSort.SORT_OPTIONS in admin.js -->
|
||||
<option value="last_used_desc">Recently Used</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
@@ -1391,6 +1387,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
|
||||
</div>
|
||||
|
||||
<script src="/prompt_manager/js/comfy-metadata.js"></script>
|
||||
<script src="/prompt_manager/js/prompt-list-sort.js"></script>
|
||||
<script src="/prompt_manager/js/admin.js"></script>
|
||||
<script src="/prompt_manager/js/tags-page.js"></script>
|
||||
</body>
|
||||
|
||||
+35
-40
@@ -1,6 +1,7 @@
|
||||
class PromptAdmin {
|
||||
constructor() {
|
||||
this.prompts = [];
|
||||
this.listMode = "recent"; // "recent" or "search"; a sort change reloads it
|
||||
this.selectedPrompts = new Set();
|
||||
this.settings = {
|
||||
resultTimeout: 5,
|
||||
@@ -114,8 +115,13 @@
|
||||
document.getElementById(id).addEventListener("change", () => this.search());
|
||||
});
|
||||
|
||||
// Sort dropdown change
|
||||
document.getElementById("sortBy").addEventListener("change", () => this.renderPrompts());
|
||||
// Sort dropdown: options come from PromptListSort; sorting runs on the server
|
||||
const sortSelect = document.getElementById("sortBy");
|
||||
sortSelect.innerHTML = PromptListSort.SORT_OPTIONS
|
||||
.map((o) => `<option value="${o.value}">${this.escapeHtml(o.label)}</option>`)
|
||||
.join("");
|
||||
sortSelect.value = PromptListSort.DEFAULT_SORT;
|
||||
sortSelect.addEventListener("change", () => this.reloadPrompts());
|
||||
}
|
||||
|
||||
bindModalEvents() {
|
||||
@@ -355,9 +361,13 @@
|
||||
|
||||
async loadRecentPrompts(page = 1) {
|
||||
try {
|
||||
this.listMode = "recent";
|
||||
this.pagination.currentPage = page;
|
||||
const offset = (page - 1) * this.pagination.limit;
|
||||
const response = await fetch(`/prompt_manager/recent?limit=${this.pagination.limit}&offset=${offset}&page=${page}`);
|
||||
const response = await fetch(PromptListSort.buildRecentUrl({
|
||||
page,
|
||||
limit: this.pagination.limit,
|
||||
sort: this.currentSort(),
|
||||
}));
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -424,7 +434,8 @@
|
||||
if (folder) params.append("folder", folder);
|
||||
params.append("limit", "100");
|
||||
|
||||
const response = await fetch(`/prompt_manager/search?${params}`);
|
||||
this.listMode = "search";
|
||||
const response = await fetch(`/prompt_manager/search?${PromptListSort.withSort(params, this.currentSort())}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
@@ -442,36 +453,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
sortPrompts(prompts) {
|
||||
const sortBy = document.getElementById("sortBy")?.value || "created_desc";
|
||||
const [field, direction] = sortBy.split("_");
|
||||
|
||||
return [...prompts].sort((a, b) => {
|
||||
let aVal, bVal;
|
||||
|
||||
switch (field) {
|
||||
case "rating":
|
||||
aVal = a.rating || 0;
|
||||
bVal = b.rating || 0;
|
||||
break;
|
||||
case "created":
|
||||
aVal = new Date(a.created_at);
|
||||
bVal = new Date(b.created_at);
|
||||
break;
|
||||
case "text":
|
||||
aVal = a.text.toLowerCase();
|
||||
bVal = b.text.toLowerCase();
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (direction === "asc") {
|
||||
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : aVal > bVal ? -1 : 0;
|
||||
}
|
||||
});
|
||||
currentSort() {
|
||||
return PromptListSort.normalizeSort(document.getElementById("sortBy")?.value);
|
||||
}
|
||||
|
||||
/** Re-fetch the list currently shown, e.g. after the sort changes. */
|
||||
reloadPrompts() {
|
||||
return this.listMode === "search" ? this.search() : this.loadRecentPrompts(1);
|
||||
}
|
||||
|
||||
renderPrompts() {
|
||||
@@ -493,14 +481,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort prompts before rendering
|
||||
const sortedPrompts = this.sortPrompts(this.prompts);
|
||||
container.innerHTML = sortedPrompts.map((prompt) => this.renderPromptItem(prompt)).join("");
|
||||
// Already in the selected order: the server sorts across all pages
|
||||
container.innerHTML = this.prompts.map((prompt) => this.renderPromptItem(prompt)).join("");
|
||||
this.selectedPrompts.clear();
|
||||
this.updateBulkActionButtons();
|
||||
|
||||
// Add hover behavior to all star ratings
|
||||
sortedPrompts.forEach(prompt => {
|
||||
this.prompts.forEach(prompt => {
|
||||
this.addStarHoverBehavior(prompt.id);
|
||||
});
|
||||
|
||||
@@ -514,7 +501,7 @@
|
||||
});
|
||||
|
||||
// Load film strips for each prompt (async, non-blocking)
|
||||
this.loadAllFilmStrips(sortedPrompts);
|
||||
this.loadAllFilmStrips(this.prompts);
|
||||
}
|
||||
|
||||
async loadAllFilmStrips(prompts) {
|
||||
@@ -550,6 +537,10 @@
|
||||
const category = prompt.category || "No category";
|
||||
const rating = prompt.rating || 0;
|
||||
const created = new Date(prompt.created_at).toLocaleDateString();
|
||||
const runLabel = PromptListSort.formatRunCount(prompt.run_count);
|
||||
const lastUsedTitle = prompt.last_used_at
|
||||
? `Last used ${new Date(prompt.last_used_at).toLocaleString()}`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="bg-pm-surface rounded-pm-md border border-pm hover:border-pm transition-all duration-200" data-id="${prompt.id}">
|
||||
@@ -574,6 +565,10 @@
|
||||
<span>📅</span>
|
||||
<span>${created}</span>
|
||||
</div>
|
||||
${runLabel ? `<div class="flex items-center space-x-1" title="${this.escapeHtml(lastUsedTitle)}">
|
||||
<span>🔁</span>
|
||||
<span>${this.escapeHtml(runLabel)}</span>
|
||||
</div>` : ""}
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="rating flex space-x-1" data-id="${prompt.id}" data-rating="${rating}">
|
||||
${this.renderStars(rating, prompt.id)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Prompt list sorting for the admin dashboard (3.2.4, #90.1).
|
||||
*
|
||||
* Sorting happens on the server so it spans every page; this module only owns the
|
||||
* option list, validation and request URLs. The option values must match
|
||||
* SORT_ORDERS in database/operations.py (enforced by tests/test_sort_contract.py).
|
||||
*
|
||||
* Loadable in the browser (window.PromptListSort) and in Node for tests.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const SORT_OPTIONS = Object.freeze(
|
||||
[
|
||||
{ value: "last_used_desc", label: "Recently Used" },
|
||||
{ value: "created_desc", label: "Newest First" },
|
||||
{ value: "created_asc", label: "Oldest First" },
|
||||
{ value: "run_count_desc", label: "Most Used" },
|
||||
{ value: "rating_desc", label: "Highest Rated" },
|
||||
{ value: "rating_asc", label: "Lowest Rated" },
|
||||
{ value: "text_asc", label: "A-Z" },
|
||||
{ value: "text_desc", label: "Z-A" },
|
||||
].map((option) => Object.freeze(option)),
|
||||
);
|
||||
|
||||
const DEFAULT_SORT = "last_used_desc";
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const KNOWN = new Set(SORT_OPTIONS.map((o) => o.value));
|
||||
|
||||
/** A known sort key, or DEFAULT_SORT for anything else. */
|
||||
function normalizeSort(value) {
|
||||
return typeof value === "string" && KNOWN.has(value) ? value : DEFAULT_SORT;
|
||||
}
|
||||
|
||||
/** URL for one page of /prompt_manager/recent. */
|
||||
function buildRecentUrl({ page = 1, limit = DEFAULT_LIMIT, sort } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String((page - 1) * limit),
|
||||
page: String(page),
|
||||
sort: normalizeSort(sort),
|
||||
});
|
||||
return `/prompt_manager/recent?${params}`;
|
||||
}
|
||||
|
||||
/** Copy of `params` with a validated sort key; the input is not modified. */
|
||||
function withSort(params, sort) {
|
||||
const copy = new URLSearchParams(params);
|
||||
copy.set("sort", normalizeSort(sort));
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Badge text for a prompt's run count; empty when the count is unknown. */
|
||||
function formatRunCount(count) {
|
||||
if (!Number.isInteger(count) || count < 0) return "";
|
||||
if (count === 0) return "Never run";
|
||||
if (count === 1) return "1 run";
|
||||
return `${count.toLocaleString("en-US")} runs`;
|
||||
}
|
||||
|
||||
const api = { SORT_OPTIONS, DEFAULT_SORT, normalizeSort, buildRecentUrl, withSort, formatRunCount };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.PromptListSort = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -978,7 +978,7 @@ app.registerExtension({
|
||||
url = `/prompt_manager/search?${queryParams.toString()}`;
|
||||
} else if (method === "get_recent_prompts") {
|
||||
const limit = params.limit || 20;
|
||||
url = `/prompt_manager/recent?limit=${limit}`;
|
||||
url = `/prompt_manager/recent?limit=${limit}&sort=last_used_desc`;
|
||||
} else {
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user