From 84961fe8806fe8061f0cd651cf296ac257ce3ecc Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Mon, 25 Sep 2023 17:13:00 -0500 Subject: [PATCH] Refactor Tool Panel views into one Toolbox Changed the structure of toolbox received from the backend ToolBox is now an object of ToolSections (and Tools) by id Also consolidated `ToolBox` and `ToolBoxWorkflow` into one `ToolBox` that is contained within a `ToolPanel`. Removed Tool panel providers and ProviderAware tool boxes --- .../components/ActivityBar/ActivityBar.vue | 4 +- .../src/components/Common/PublishedItem.vue | 4 +- .../Panels/Buttons/FavoritesButton.vue | 3 +- .../Panels/Buttons/PanelViewMenuItem.vue | 4 +- .../components/Panels/Common/ToolSearch.vue | 73 +-- .../components/Panels/Common/ToolSection.vue | 307 +++++------ .../Panels/ProviderAwareToolBox.vue | 41 -- .../Panels/ProviderAwareToolBoxWorkflow.vue | 73 --- client/src/components/Panels/ToolBox.vue | 500 +++++++++++------- .../src/components/Panels/ToolBoxWorkflow.vue | 211 -------- client/src/components/Panels/ToolPanel.vue | 121 +++++ .../components/Panels/toolSearch.worker.js | 12 +- client/src/components/Panels/utilities.js | 294 +++++----- client/src/components/ToolsList/ToolsList.vue | 228 ++++---- .../components/ToolsList/ToolsListItem.vue | 12 + .../components/ToolsList/ToolsListTable.vue | 8 +- .../Toolshed/RepositoryDetails/Index.vue | 33 +- .../InstallationSettings.vue | 6 +- .../src/components/Workflow/Editor/Index.vue | 9 +- .../Workflow/Published/WorkflowPublished.vue | 4 +- .../WorkflowInvocationStep.vue | 7 +- .../providers/ToolPanelViewProvider.js | 40 -- .../components/providers/storeProviders.js | 1 - .../src/entry/analysis/modules/Analysis.vue | 4 +- client/src/store/index.js | 2 - client/src/store/toolStore.js | 71 --- client/src/stores/toolStore.ts | 239 +++++++++ client/src/stores/workflowStore.ts | 2 + lib/galaxy/tool_util/toolbox/base.py | 33 ++ lib/galaxy/tool_util/toolbox/panel.py | 16 +- lib/galaxy/webapps/galaxy/api/tools.py | 2 +- 31 files changed, 1242 insertions(+), 1122 deletions(-) delete mode 100644 client/src/components/Panels/ProviderAwareToolBox.vue delete mode 100644 client/src/components/Panels/ProviderAwareToolBoxWorkflow.vue delete mode 100644 client/src/components/Panels/ToolBoxWorkflow.vue create mode 100644 client/src/components/Panels/ToolPanel.vue delete mode 100644 client/src/components/providers/ToolPanelViewProvider.js delete mode 100644 client/src/store/toolStore.js create mode 100644 client/src/stores/toolStore.ts diff --git a/client/src/components/ActivityBar/ActivityBar.vue b/client/src/components/ActivityBar/ActivityBar.vue index e5b99c76f057..9dc3cf03f173 100644 --- a/client/src/components/ActivityBar/ActivityBar.vue +++ b/client/src/components/ActivityBar/ActivityBar.vue @@ -17,7 +17,7 @@ import NotificationItem from "./Items/NotificationItem.vue"; import UploadItem from "./Items/UploadItem.vue"; import ContextMenu from "@/components/Common/ContextMenu.vue"; import FlexPanel from "@/components/Panels/FlexPanel.vue"; -import ToolBox from "@/components/Panels/ProviderAwareToolBox.vue"; +import ToolPanel from "@/components/Panels/ToolPanel.vue"; import WorkflowBox from "@/components/Panels/WorkflowBox.vue"; const { config, isConfigLoaded } = useConfig(); @@ -211,7 +211,7 @@ function toggleContextMenu(evt: MouseEvent) { - + diff --git a/client/src/components/Common/PublishedItem.vue b/client/src/components/Common/PublishedItem.vue index 49b9e130f2db..246cae7db0b4 100644 --- a/client/src/components/Common/PublishedItem.vue +++ b/client/src/components/Common/PublishedItem.vue @@ -6,7 +6,7 @@ import { usePanels } from "@/composables/usePanels"; import ActivityBar from "@/components/ActivityBar/ActivityBar.vue"; import LoadingSpan from "@/components/LoadingSpan.vue"; import FlexPanel from "@/components/Panels/FlexPanel.vue"; -import ToolBox from "@/components/Panels/ProviderAwareToolBox.vue"; +import ToolPanel from "@/components/Panels/ToolPanel.vue"; import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue"; interface Item { @@ -54,7 +54,7 @@ const { showActivityBar, showToolbox } = usePanels();
- +
diff --git a/client/src/components/Panels/Buttons/FavoritesButton.vue b/client/src/components/Panels/Buttons/FavoritesButton.vue index cf2d29d51e31..95eff36c7166 100644 --- a/client/src/components/Panels/Buttons/FavoritesButton.vue +++ b/client/src/components/Panels/Buttons/FavoritesButton.vue @@ -23,6 +23,7 @@ export default { props: { query: { type: String, + required: true, }, }, data() { @@ -56,7 +57,7 @@ export default { if (this.toggle) { this.$emit("onFavorites", this.searchKey); } else { - this.$emit("onFavorites", null); + this.$emit("onFavorites", ""); } }, }, diff --git a/client/src/components/Panels/Buttons/PanelViewMenuItem.vue b/client/src/components/Panels/Buttons/PanelViewMenuItem.vue index 176a2c99fedb..c895bd6e500a 100644 --- a/client/src/components/Panels/Buttons/PanelViewMenuItem.vue +++ b/client/src/components/Panels/Buttons/PanelViewMenuItem.vue @@ -1,7 +1,7 @@ diff --git a/client/src/components/Panels/Common/ToolSearch.vue b/client/src/components/Panels/Common/ToolSearch.vue index 7c1d679bd4d2..062dc3ccab57 100644 --- a/client/src/components/Panels/Common/ToolSearch.vue +++ b/client/src/components/Panels/Common/ToolSearch.vue @@ -1,31 +1,20 @@ + - - diff --git a/client/src/components/Panels/ToolBoxWorkflow.vue b/client/src/components/Panels/ToolBoxWorkflow.vue deleted file mode 100644 index c23f5c7b4e0e..000000000000 --- a/client/src/components/Panels/ToolBoxWorkflow.vue +++ /dev/null @@ -1,211 +0,0 @@ - - - diff --git a/client/src/components/Panels/ToolPanel.vue b/client/src/components/Panels/ToolPanel.vue new file mode 100644 index 000000000000..d19c4dfbf5ac --- /dev/null +++ b/client/src/components/Panels/ToolPanel.vue @@ -0,0 +1,121 @@ + + + diff --git a/client/src/components/Panels/toolSearch.worker.js b/client/src/components/Panels/toolSearch.worker.js index 83e3663ad4cd..1b70b4b084c8 100644 --- a/client/src/components/Panels/toolSearch.worker.js +++ b/client/src/components/Panels/toolSearch.worker.js @@ -8,10 +8,16 @@ import { searchToolsByKeys } from "./utilities"; self.addEventListener("message", (event) => { const { type, payload } = event.data; if (type === "searchToolsByKeys") { - const { tools, keys, query } = payload; - const { results, closestTerm } = searchToolsByKeys(tools, keys, query); + const { tools, keys, query, panelView, currentPanel } = payload; + const { results, resultPanel, closestTerm } = searchToolsByKeys(tools, keys, query, panelView, currentPanel); // send the result back to the main thread - self.postMessage({ type: "searchToolsByKeysResult", payload: results, query: query, closestTerm: closestTerm }); + self.postMessage({ + type: "searchToolsByKeysResult", + payload: results, + sectioned: resultPanel, + query: query, + closestTerm: closestTerm, + }); } else if (type === "clearFilter") { self.postMessage({ type: "clearFilterResult" }); } else if (type === "favoriteTools") { diff --git a/client/src/components/Panels/utilities.js b/client/src/components/Panels/utilities.js index 26e7823adc76..7971b4bf1e64 100644 --- a/client/src/components/Panels/utilities.js +++ b/client/src/components/Panels/utilities.js @@ -1,13 +1,23 @@ /** * Utilities file for Panel Searches (panel/client search + advanced/backend search) + * Note: Any mention of "DL" in this file refers to the Demerau-Levenshtein distance algorithm */ import { orderBy } from "lodash"; import levenshteinDistance from "utils/levenshtein"; +import { localize } from "utils/localization"; const TOOL_ID_KEYS = ["id", "tool_id"]; const TOOLS_RESULTS_SORT_LABEL = "apiSort"; const TOOLS_RESULTS_SECTIONS_HIDE = ["Expression Tools"]; const STRING_REPLACEMENTS = [" ", "-", "(", ")", "'", ":", `"`]; +const MINIMUM_DL_LENGTH = 5; // for Demerau-Levenshtein distance +const UNSECTIONED_SECTION = { + // to return a section for unsectioned tools + model_class: "ToolSection", + id: "unsectioned", + name: localize("Unsectioned Tools"), + description: localize("Tools that don't appear under any section in the unsearched panel"), +}; // Converts filterSettings { key: value } to query = "key:value" export function createWorkflowQuery(filterSettings) { @@ -36,7 +46,7 @@ export function createWorkflowQuery(filterSettings) { * createWhooshQuery(filterSettings, 'ontology:edam_topics', toolbox) * return query = "(name:(skew) name_exact:(skew) description:(skew)) AND (edam_topics:(topic_0797) AND )" */ -export function createWhooshQuery(filterSettings, panelView, toolbox) { +export function createWhooshQuery(filterSettings) { let query = "("; // add description+name_exact fields = name, to do a combined OrGroup at backend const name = filterSettings["name"]; @@ -47,20 +57,10 @@ export function createWhooshQuery(filterSettings, panelView, toolbox) { } query += ") AND ("; for (const [key, filterValue] of Object.entries(filterSettings)) { - // get ontology keys if view is not default - if (key === "section" && panelView !== "default") { - const ontology = toolbox.find(({ name }) => name && name.toLowerCase().match(filterValue.toLowerCase())); - if (ontology) { - let ontologyKey = ""; - if (panelView === "ontology:edam_operations") { - ontologyKey = "edam_operations"; - } else if (panelView === "ontology:edam_topics") { - ontologyKey = "edam_topics"; - } - query += ontologyKey + ":(" + ontology.id + ") AND "; - } else { - query += key + ":(" + filterValue + ") AND "; - } + if (key === "ontology" && filterValue.includes("operation")) { + query += "edam_operations:(" + filterValue + ") AND "; + } else if (key === "ontology" && filterValue.includes("topic")) { + query += "edam_topics:(" + filterValue + ") AND "; } else if (key == "id") { query += "id_exact:(" + filterValue + ") AND "; } else if (key != "name") { @@ -84,39 +84,16 @@ export function determineWidth(rectRoot, rectDraggable, minWidth, maxWidth, dire return Math.max(minWidth, Math.min(maxWidth, newWidth)); } -// Given toolbox and search results, returns filtered tool results -export function filterTools(tools, results) { - let toolsResults = []; - tools = flattenTools(tools); - toolsResults = mapToolsResults(tools, results); - toolsResults = sortToolsResults(toolsResults); - toolsResults = removeDuplicateResults(toolsResults); - return toolsResults; -} - -// Given toolbox and search results, returns filtered tool results by sections -export function filterToolSections(tools, results) { - let toolsResults = []; - let toolsResultsSection = []; - if (hasResults(results)) { - toolsResults = tools.map((section) => { - tools = flattenToolsSection(section); - toolsResultsSection = mapToolsResults(tools, results); - toolsResultsSection = sortToolsResults(toolsResultsSection); - return { - ...section, - elems: toolsResultsSection, - }; - }); - toolsResults = deleteEmptyToolsSections(toolsResults, results); - } else { - toolsResults = tools; +// Given toolbox and search results, returns filtered tool results (by id) +export function filterTools(toolsById, results) { + const filteredTools = {}; + for (const id of results) { + const localTool = toolsById[id]; + if (localTool !== undefined) { + filteredTools[id] = localTool; + } } - return toolsResults; -} - -export function hasResults(results) { - return Array.isArray(results) && results.length > 0; + return filteredTools; } /** @@ -127,38 +104,57 @@ export function hasResults(results) { * @param {Array} tools - toolbox * @param {Object} keys - keys to sort and search results by * @param {String} query - a search query + * @param {String} panelView - panel view, to find section_id for each tool + * @param {Object} currentPanel - current ToolPanel with { section_id: { tools: [tool ids] }, ... } * @param {Boolean} usesDL - Optional: used for recursive call with DL if no string.match() - * @returns tool ids sorted by order of keys that are being searched (+ closest matching term if DL) + * @returns an object containing + * - results: array of tool ids that match the query + * - resultPanel: a ToolPanel with only the results for the current panelView + * - closestTerm: Optional: closest matching term for DL (in case no match with query) + * + * all sorted by order of keys that are being searched (+ closest matching term if DL) */ -export function searchToolsByKeys(tools, keys, query, usesDL = false) { - let returnedTools = []; +export function searchToolsByKeys(tools, keys, query, panelView = "default", currentPanel, usesDL = false) { + const matchedTools = []; let closestTerm = null; + // if user's query = "id:1234" or "tool_id:1234", only search for id const id = processForId(query, TOOL_ID_KEYS); if (id) { query = id; keys = { id: 1 }; } const queryValue = sanitizeString(query.trim().toLowerCase(), STRING_REPLACEMENTS); - const minimumQueryLength = 5; // for DL for (const tool of tools) { for (const key of Object.keys(keys)) { if (tool[key] || key === "combined") { let actualValue = ""; + // key = "combined" is a special case for searching name + description if (key === "combined") { actualValue = (tool.name + tool.description).trim().toLowerCase(); } else { actualValue = tool[key].trim().toLowerCase(); } + + // get all (space separated) words in actualValue for tool (for DL) const actualValueWords = actualValue.split(" "); actualValue = sanitizeString(actualValue, STRING_REPLACEMENTS); + // do we care for exact matches && is it an exact match ? - const order = keys.exact && actualValue === queryValue ? keys.exact : keys[key]; + let order = keys.exact && actualValue === queryValue ? keys.exact : keys[key]; + // do we care for startsWith && does it actualValue start with query ? + order = + keys.startsWith && order !== keys.exact && key === "name" && actualValue.startsWith(queryValue) + ? keys.startsWith + : order; + if (!usesDL && actualValue.match(queryValue)) { - returnedTools.push({ id: tool.id, order }); + // if string.match() returns true, matching tool found + matchedTools.push({ id: tool.id, sections: getPanelSectionsForTool(tool, panelView), order }); break; } else if (usesDL) { + // if string.match() returns false, try DL distance once to see if there is a closestSubstring let substring = null; - if ((key == "name" || key == "description") && queryValue.length >= minimumQueryLength) { + if ((key == "name" || key == "description") && queryValue.length >= MINIMUM_DL_LENGTH) { substring = closestSubstring(queryValue, actualValue); } // there is a closestSubstring: matching tool found @@ -168,7 +164,12 @@ export function searchToolsByKeys(tools, keys, query, usesDL = false) { if (foundTerm && (!closestTerm || (closestTerm && foundTerm.length < closestTerm.length))) { closestTerm = foundTerm; } - returnedTools.push({ id: tool.id, order, closestTerm }); + matchedTools.push({ + id: tool.id, + sections: getPanelSectionsForTool(tool, panelView), + order, + closestTerm, + }); break; } } @@ -176,40 +177,95 @@ export function searchToolsByKeys(tools, keys, query, usesDL = false) { } } // no results with string.match(): recursive call with usesDL - if (!id && !usesDL && returnedTools.length == 0) { - return searchToolsByKeys(tools, keys, query, true); + if (!id && !usesDL && matchedTools.length == 0) { + return searchToolsByKeys(tools, keys, query, panelView, currentPanel, true); } - // sorting results by indexed order of keys - returnedTools = orderBy(returnedTools, ["order"], ["desc"]).map((tool) => tool.id); - return { results: returnedTools, closestTerm: closestTerm }; + const { idResults, resultPanel } = createSortedResultObject(matchedTools, currentPanel); + return { results: idResults, resultPanel: resultPanel, closestTerm: closestTerm }; } -export function flattenTools(tools) { - let normalizedTools = []; - tools.forEach((section) => { - normalizedTools = normalizedTools.concat(flattenToolsSection(section)); - }); - return normalizedTools; -} +/** + * Orders the matchedTools by order of keys that are being searched, and creates a resultPanel + * @param {Object} matchedTools containing { id: tool id, sections: [section ids], order: order } + * @param {Object} currentPanel current ToolPanel for current view + * @returns an object containing + * - idResults: array of tool ids that match the query + * - resultPanel: a ToolPanel with only the results + */ +function createSortedResultObject(matchedTools, currentPanel) { + const idResults = []; + // creating a sectioned results object ({section_id: [tool ids], ...}), keeping + // track of the best version of each tool, and also sorting by indexed order of keys + const resultPanel = orderBy(matchedTools, ["order"], ["desc"]).reduce((acc, tool) => { + // we either found specific section(s) for tool, or we need to search all sections + const sections = tool.sections.length !== 0 ? tool.sections : Object.keys(currentPanel); + for (const section of sections) { + let toolAdded = false; + const existingPanelItem = currentPanel[section]; + if (existingPanelItem) { + if (existingPanelItem.tools && existingPanelItem.tools.includes(tool.id)) { + // it has tools so is a section, and it has the tool we're looking for -export function hideToolsSection(tools) { - return tools.filter((section) => !TOOLS_RESULTS_SECTIONS_HIDE.includes(section.name)); -} + // if we haven't seen this section yet, create it in the resultPanel + if (!acc[section]) { + acc[section] = { ...existingPanelItem }; + acc[section].tools = []; + } + acc[section].tools.push(tool.id); + toolAdded = true; + } else if (isToolObject(existingPanelItem) && existingPanelItem.id === tool.id) { + // it is a tool, and it is the tool we're looking for -export function removeDisabledTools(tools) { - return tools.filter((section) => { - if (section.model_class === "ToolSectionLabel") { - return true; - } else if (!section.elems && section.disabled) { - return false; - } else if (section.elems) { - section.elems = section.elems.filter((el) => !el.disabled); - if (!section.elems.length) { - return false; + // put in it the "Unsectioned Tools" section (if it doesn't exist, create it) + const unsectionedId = UNSECTIONED_SECTION.id; + if (!acc[unsectionedId]) { + acc[unsectionedId] = { ...UNSECTIONED_SECTION }; + acc[unsectionedId].tools = []; + } + acc[unsectionedId].tools.push(tool.id); + toolAdded = true; + } + if (toolAdded && !idResults.includes(tool.id)) { + idResults.push(tool.id); + } } } - return true; - }); + return acc; + }, {}); + return { idResults, resultPanel }; +} + +/** + * Gets the section(s) a tool belongs to for a given panelView. + * Unless view=`default`, all other views must be of format `class:view_type`, + * where `Tool` object has a `view_name` key containing an array of section ids. + * e.g.: view = `ontology:edam_operations` => `Tool.edam_operations = [section ids]`. + * + * Therefore, this would not handle the case where view = `ontology:edam_merged`, since + * Tool.edam_merged is not a valid key, and we would just return `[uncategorized]`. + * + * Just prevents looking through whole panel to find section id for tool, + * we still end up doing that (in `createSortedResultObject`) in case we return [] here + * @param {Tool} tool + * @param {String} panelView + * @returns Array of section ids + */ +function getPanelSectionsForTool(tool, panelView) { + if (panelView === "default" && tool.panel_section_id) { + if (tool.panel_section_id.startsWith("tool_")) { + return [tool.panel_section_id.split("tool_")[1]]; + } else { + return [tool.panel_section_id]; + } + } else if (panelView !== "default") { + const sectionType = panelView.split(":")[1]; // e.g.: edam_operations + if (tool[sectionType] && tool[sectionType].length !== 0) { + return tool[sectionType]; + } else { + return ["uncategorized"]; + } + } + return []; } /** @@ -246,11 +302,16 @@ function closestSubstring(query, actualStr) { return null; } -function isToolObject(tool) { +export function isToolObject(tool) { // toolbox overhaul with typing will simplify this dramatically... // Right now, our shorthand is that tools have no 'text', and don't match // the model_class of the section/label. - if (!tool.text && tool.model_class !== "ToolSectionLabel" && tool.model_class !== "ToolSection") { + if ( + !tool.text && + tool.model_class !== "ToolSectionLabel" && + tool.model_class !== "ToolSection" && + tool.tools === undefined + ) { return true; } return false; @@ -300,68 +361,3 @@ function processForId(query, keys = ["id"]) { } return null; } - -function flattenToolsSection(section) { - const flattenTools = []; - if (section.elems) { - section.elems.forEach((tool) => { - if (isToolObject(tool)) { - flattenTools.push(tool); - } - }); - } else if (isToolObject(section)) { - // This might be a top-level section-less tool and not actually a - // section. - flattenTools.push(section); - } - return flattenTools; -} - -function mapToolsResults(tools, results) { - const toolsResults = tools - .filter((tool) => !tool.text && results.includes(tool.id)) - .map((tool) => { - Object.assign(tool, setSort(tool, results)); - return tool; - }); - return toolsResults; -} - -function removeDuplicateResults(results) { - const uniqueTools = []; - return results.filter((tool) => { - if (!uniqueTools.includes(tool.id)) { - uniqueTools.push(tool.id); - return true; - } else { - return false; - } - }); -} - -function setSort(tool, results) { - return { [TOOLS_RESULTS_SORT_LABEL]: results.indexOf(tool.id) }; -} - -function sortToolsResults(toolsResults) { - return orderBy(toolsResults, [TOOLS_RESULTS_SORT_LABEL], ["asc"]); -} - -function deleteEmptyToolsSections(tools, results) { - let isSection = false; - let isMatchedTool = false; - tools = tools - .filter((section) => { - isSection = section.elems && section.elems.length > 0; - isMatchedTool = !section.text && results.includes(section.id); - return isSection || isMatchedTool; - }) - .sort((sectionPrevious, sectionCurrent) => { - if (sectionPrevious.elems.length == 0 || sectionCurrent.elems.length == 0) { - return 0; - } - return results.indexOf(sectionPrevious.elems[0].id) - results.indexOf(sectionCurrent.elems[0].id); - }); - - return tools; -} diff --git a/client/src/components/ToolsList/ToolsList.vue b/client/src/components/ToolsList/ToolsList.vue index 9bbef40dd155..e0db3592ad89 100644 --- a/client/src/components/ToolsList/ToolsList.vue +++ b/client/src/components/ToolsList/ToolsList.vue @@ -1,136 +1,126 @@ - - - + +