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:
Vito Sansevero
2026-10-07 11:43:26 -07:00
parent 23b0162a06
commit 94a9260f89
6 changed files with 208 additions and 47 deletions
+66
View File
@@ -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));
}
});
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
+64
View File
@@ -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);
+1 -1
View File
@@ -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}`);
}