Add operation category display in popovers

- Add 'Show operation category in popover' option (enabled by default)
- Display category names (excluding Favourites) in operation popovers
- Make category names clickable to open the category in operations list
- Clicking a category link clears search and expands the category
- Add UI test for category popover functionality
- Addresses issue gchq/CyberChef#1654
This commit is contained in:
Allan Leary 2026-07-14 10:31:12 +01:00
parent 002ed911b1
commit f15ad10b94
6 changed files with 180 additions and 1 deletions

View File

@ -56,7 +56,8 @@ class HTMLOperation {
if (this.description) {
const infoLink = this.infoURL ? `<hr>${titleFromWikiLink(this.infoURL)}` : "";
const content = Utils.escapeHtml(this.description + infoLink);
const categoryInfo = this.getCategoryInfo();
const content = Utils.escapeHtml(this.description + infoLink + categoryInfo);
html += ` data-container='body' data-toggle='popover' data-placement='right'
data-content="${content}" data-html='true' data-trigger='hover'
@ -75,6 +76,39 @@ class HTMLOperation {
}
/**
* Gets the category information for this operation as an HTML string.
*
* @returns {string}
*/
getCategoryInfo() {
if (!this.app.options.showOpCategories) {
return "";
}
// Find all categories this operation belongs to, excluding Favourites
const categories = [];
for (let i = 0; i < this.app.categories.length; i++) {
const cat = this.app.categories[i];
if (cat.name !== "Favourites" && cat.ops.includes(this.name)) {
categories.push(cat.name);
}
}
if (categories.length === 0) {
return "";
}
// Build the category links
const categoryLinks = categories.map(catName => {
const catId = "cat" + catName.replace(/[\s/\-:_]/g, "");
return `<a class="op-category-link" data-category="${catId}">${catName}</a>`;
}).join(", ");
return `<hr>Category: ${categoryLinks}`;
}
/**
* Renders the operation in HTML as a full operation with ingredients.
*

View File

@ -232,6 +232,8 @@ class Manager {
this.addDynamicListener(".option-item input[type=checkbox]#wordWrap", "change", this.options.setWordWrap, this.options);
this.addDynamicListener(".option-item input[type=checkbox]#useMetaKey", "change", this.bindings.updateKeybList, this.bindings);
this.addDynamicListener(".option-item input[type=checkbox]#showCatCount", "change", this.ops.setCatCount, this.ops);
this.addDynamicListener(".option-item input[type=checkbox]#showOpCategories", "change", this.ops.toggleOpCategories, this.ops);
this.addDynamicListener(".op-category-link", "click", this.ops.categoryLinkClick, this.ops);
this.addDynamicListener(".option-item input[type=number]", "keyup", this.options.numberChange, this.options);
this.addDynamicListener(".option-item input[type=number]", "change", this.options.numberChange, this.options);
this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options);

View File

@ -529,6 +529,13 @@
Show the number of operations in each category
</label>
</div>
<div class="checkbox option-item">
<label for="showOpCategories">
<input type="checkbox" option="showOpCategories" id="showOpCategories" checked>
Show operation category in popover
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="reset-options">Reset options to default</button>

View File

@ -53,6 +53,7 @@ function main() {
imagePreview: true,
syncTabs: true,
showCatCount: false,
showOpCategories: true,
};
document.removeEventListener("DOMContentLoaded", main, false);

View File

@ -326,6 +326,68 @@ class OperationsWaiter {
}
}
/**
* Handler for toggling the display of operation categories in popovers.
* Repopulates the operations list to show/hide category information.
*/
toggleOpCategories() {
// Repopulate operations list to apply the option change
this.app.populateOperationsList();
this.manager.recipe.initialiseOperationDragNDrop();
// Refresh search results if search is active
const searchInput = document.getElementById("search");
if (searchInput && searchInput.value) {
this.searchOperations({target: searchInput});
}
}
/**
* Handler for clicking a category link in an operation popover.
* Clears the search and opens the clicked category.
*
* @param {event} e
*/
categoryLinkClick(e) {
e.preventDefault();
e.stopPropagation();
const categoryId = e.target.dataset.category;
if (!categoryId) return;
// Hide all popovers
$("[data-toggle=popover]").popover("hide");
// Clear search
const searchInput = document.getElementById("search");
if (searchInput) {
searchInput.value = "";
}
// Clear search results
const searchResults = document.getElementById("search-results");
if (searchResults) {
while (searchResults.firstChild) {
try {
$(searchResults.firstChild).popover("dispose");
} catch (err) {}
searchResults.removeChild(searchResults.firstChild);
}
}
// Close all categories and open the target one
$("#categories .collapse").collapse("hide");
$(`#${categoryId}`).collapse("show");
// Scroll the category into view
const categoryElement = document.getElementById(categoryId);
if (categoryElement) {
categoryElement.scrollIntoView({behavior: "smooth", block: "nearest"});
}
}
}
export default OperationsWaiter;

View File

@ -252,6 +252,79 @@ module.exports = {
.waitForElementVisible("//ul[@id='search-results']//b[text()='MD5']", 1000);
},
"Operation category in popover": browser => {
const op = "//ul[@id='search-results']//li[contains(@class, 'operation') and contains(., 'MD5')]";
// Search for an operation
browser
.useCss()
.clearValue("#search")
.setValue("#search", "md5")
.useXpath()
.waitForElementVisible(op, 1000);
// Hover over the operation to show popover
browser
.moveToElement(op, 10, 10)
.useCss()
.waitForElementVisible(".popover-body", 1000);
// Assert that Category line appears in popover
browser
.expect.element(".popover-body").text.to.contain("Category:");
// Click the category link
browser
.click(".popover-body .op-category-link");
// Assert that search is cleared
browser
.useCss()
.expect.element("#search").value.to.equal("");
// Assert that the Hashing category is now visible
browser
.expect.element("#catHashing").to.be.visible;
// Toggle the option off
browser
.click("#options");
browser
.waitForElementVisible("#options-modal", 1000)
.click("#showOpCategories")
.pause(500)
.click("#options-modal .modal-footer .btn-secondary[data-dismiss='modal']")
.waitForElementNotVisible("#options-modal", 1000);
// Search again and verify category line doesn't appear
browser
.clearValue("#search")
.setValue("#search", "md5")
.useXpath()
.waitForElementVisible(op, 1000)
.moveToElement(op, 10, 10)
.useCss()
.waitForElementVisible(".popover-body", 1000);
// Assert that Category line does not appear
browser
.expect.element(".popover-body").text.to.not.contain("Category:");
// Reset option back to enabled
browser
.click("#options")
.waitForElementVisible("#options-modal", 1000)
.click("#showOpCategories")
.pause(500)
.click("#options-modal .modal-footer .btn-secondary[data-dismiss='modal']")
.waitForElementNotVisible("#options-modal", 1000);
// Clear search
browser
.clearValue("#search");
},
"Alert bar": browser => {
// Bake nothing to create an empty output which can be copied
utils.clear(browser);