mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-25 00:12:08 +08:00
convert utilities.js to .ts and fix jests
improve typing in several places
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import "jest-location-mock";
|
||||
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { createPinia } from "pinia";
|
||||
import { getLocalVue } from "tests/jest/helpers";
|
||||
import VueRouter from "vue-router";
|
||||
|
||||
@@ -11,19 +12,22 @@ localVue.use(VueRouter);
|
||||
const router = new VueRouter();
|
||||
|
||||
describe("ToolSearch", () => {
|
||||
it("test tools advanced filter panel", async () => {
|
||||
it("test tools advanced filter panel navigation", async () => {
|
||||
const pinia = createPinia();
|
||||
const wrapper = mount(ToolSearch, {
|
||||
propsData: {
|
||||
currentPanelView: "default",
|
||||
enableAdvanced: false,
|
||||
showAdvanced: false,
|
||||
toolbox: [],
|
||||
toolsList: [],
|
||||
currentPanel: {},
|
||||
},
|
||||
localVue,
|
||||
router,
|
||||
stubs: {
|
||||
icon: { template: "<div></div>" },
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
const $router = wrapper.vm.$router;
|
||||
|
||||
@@ -33,13 +37,6 @@ describe("ToolSearch", () => {
|
||||
expect(wrapper.find("[data-description='wide toggle advanced search']").exists()).toBe(true);
|
||||
expect(wrapper.find("[data-description='advanced filters']").exists()).toBe(true);
|
||||
|
||||
// Test: changing panel view should change search by section field to search by ontology
|
||||
expect(wrapper.find("[placeholder='any section']").exists()).toBe(true);
|
||||
await wrapper.setProps({ currentPanelView: "ontology:edam_operations" });
|
||||
expect(wrapper.find("[placeholder='any section']").exists()).toBe(false);
|
||||
expect(wrapper.find("[placeholder='any ontology']").exists()).toBe(true);
|
||||
await wrapper.setProps({ currentPanelView: "default" });
|
||||
|
||||
// Test: keyup.esc (should toggle the view out) --- doesn't work from name (DelayedInput) field
|
||||
const sectionField = wrapper.find("[placeholder='any section']");
|
||||
expect(wrapper.emitted()["update:show-advanced"]).toBeUndefined();
|
||||
@@ -52,6 +49,7 @@ describe("ToolSearch", () => {
|
||||
const filterInputs = {
|
||||
"[placeholder='any name']": "name-filter",
|
||||
"[placeholder='any section']": "section-filter",
|
||||
"[placeholder='any ontology']": "ontology-filter",
|
||||
"[placeholder='any id']": "id-filter",
|
||||
"[placeholder='any repository owner']": "owner-filter",
|
||||
"[placeholder='any help text']": "help-filter",
|
||||
@@ -72,6 +70,7 @@ describe("ToolSearch", () => {
|
||||
const filterSettings = {
|
||||
name: "name-filter",
|
||||
section: "section-filter",
|
||||
ontology: "ontology-filter",
|
||||
id: "id-filter",
|
||||
owner: "owner-filter",
|
||||
help: "help-filter",
|
||||
|
||||
@@ -7,12 +7,16 @@ import { type Tool, type ToolSection, useToolStore } from "@/stores/toolStore";
|
||||
import Filtering, { contains, type ValidFilter } from "@/utils/filtering";
|
||||
import _l from "@/utils/localization";
|
||||
|
||||
import { type ToolSearchKeys } from "../utilities";
|
||||
|
||||
import DelayedInput from "@/components/Common/DelayedInput.vue";
|
||||
import FilterMenu from "@/components/Common/FilterMenu.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const KEYS = { exact: 4, startsWith: 3, name: 2, description: 1, combined: 0 };
|
||||
// Note: These are ordered by result priority (exact matches very top; words matches very bottom)
|
||||
const KEYS: ToolSearchKeys = { exact: 5, startsWith: 4, name: 3, description: 2, combined: 1, wordMatch: 0 };
|
||||
|
||||
const FAVORITES = ["#favs", "#favorites", "#favourites"];
|
||||
const MIN_QUERY_LENGTH = 3;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { useConfig } from "composables/config";
|
||||
import { createPinia } from "pinia";
|
||||
import { getLocalVue } from "tests/jest/helpers";
|
||||
|
||||
import ToolSection from "./ToolSection";
|
||||
@@ -13,6 +14,11 @@ useConfig.mockReturnValue({
|
||||
});
|
||||
|
||||
const localVue = getLocalVue();
|
||||
const pinia = createPinia();
|
||||
|
||||
function sectionIsOpened(wrapper) {
|
||||
return wrapper.find("[data-description='opened tool panel section']").exists();
|
||||
}
|
||||
|
||||
describe("ToolSection", () => {
|
||||
test("test tool section", () => {
|
||||
@@ -23,6 +29,7 @@ describe("ToolSection", () => {
|
||||
},
|
||||
},
|
||||
localVue,
|
||||
pinia,
|
||||
});
|
||||
const nameElement = wrapper.findAll(".name");
|
||||
expect(nameElement.at(0).text()).toBe("name");
|
||||
@@ -46,8 +53,9 @@ describe("ToolSection", () => {
|
||||
},
|
||||
},
|
||||
localVue,
|
||||
pinia,
|
||||
});
|
||||
expect(wrapper.vm.opened).toBe(false);
|
||||
expect(sectionIsOpened(wrapper)).toBe(false);
|
||||
const $sectionName = wrapper.find(".name");
|
||||
expect($sectionName.text()).toBe("tool_section");
|
||||
await $sectionName.trigger("click");
|
||||
@@ -76,22 +84,23 @@ describe("ToolSection", () => {
|
||||
queryFilter: "test",
|
||||
},
|
||||
localVue,
|
||||
pinia,
|
||||
});
|
||||
expect(wrapper.vm.opened).toBe(true);
|
||||
expect(sectionIsOpened(wrapper)).toBe(true);
|
||||
const $sectionName = wrapper.find(".name");
|
||||
await $sectionName.trigger("click");
|
||||
expect(wrapper.vm.opened).toBe(false);
|
||||
expect(sectionIsOpened(wrapper)).toBe(false);
|
||||
await wrapper.setProps({ queryFilter: "" });
|
||||
expect(wrapper.vm.opened).toBe(false);
|
||||
expect(sectionIsOpened(wrapper)).toBe(false);
|
||||
await wrapper.setProps({ queryFilter: "test" });
|
||||
expect(wrapper.vm.opened).toBe(true);
|
||||
expect(sectionIsOpened(wrapper)).toBe(true);
|
||||
await wrapper.setProps({ disableFilter: true });
|
||||
expect(wrapper.vm.opened).toBe(true);
|
||||
expect(sectionIsOpened(wrapper)).toBe(true);
|
||||
await wrapper.setProps({ queryFilter: "" });
|
||||
expect(wrapper.vm.opened).toBe(false);
|
||||
expect(sectionIsOpened(wrapper)).toBe(false);
|
||||
await $sectionName.trigger("click");
|
||||
expect(wrapper.vm.opened).toBe(true);
|
||||
expect(sectionIsOpened(wrapper)).toBe(true);
|
||||
await wrapper.setProps({ queryFilter: "test" });
|
||||
expect(wrapper.vm.opened).toBe(false);
|
||||
expect(sectionIsOpened(wrapper)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,8 @@ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import { eventHub } from "@/components/plugins/eventHub.js";
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { type Tool as ToolType, type ToolSectionLabel as LabelType } from "@/stores/toolStore";
|
||||
import { ToolSectionLabel } from "@/stores/toolStore";
|
||||
import { useToolStore } from "@/stores/toolStore";
|
||||
import { type Workflow } from "@/stores/workflowStore";
|
||||
import ariaAlert from "@/utils/ariaAlert";
|
||||
|
||||
import Tool from "./Tool.vue";
|
||||
@@ -13,8 +12,8 @@ import ToolPanelLabel from "./ToolPanelLabel.vue";
|
||||
import ToolPanelLinks from "./ToolPanelLinks.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "onClick", tool: ToolType | Workflow, evt: Event): void;
|
||||
(e: "onOperation", tool: ToolType | Workflow, evt: Event): void;
|
||||
(e: "onClick", tool: any, evt: Event): void;
|
||||
(e: "onOperation", tool: any, evt: Event): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps({
|
||||
@@ -70,7 +69,7 @@ const elems = computed(() => {
|
||||
const tool = toolStore.getToolForId(toolId);
|
||||
if (!tool && toolId.startsWith("panel_label_") && props.category.panel_labels) {
|
||||
const labelId = toolId.split("panel_label_")[1];
|
||||
return props.category.panel_labels.find((label: LabelType) => label.id === labelId);
|
||||
return props.category.panel_labels.find((label: ToolSectionLabel) => label.id === labelId);
|
||||
} else {
|
||||
return tool;
|
||||
}
|
||||
@@ -97,7 +96,7 @@ const sortedElements = computed(() => {
|
||||
isConfigLoaded.value &&
|
||||
config.value.toolbox_auto_sort === true &&
|
||||
props.sortItems === true &&
|
||||
!elems.value.some((el: ToolType) => el.text !== undefined && el.text !== "")
|
||||
!elems.value.some((el: ToolSectionLabel) => el.text !== undefined && el.text !== "")
|
||||
) {
|
||||
const elements = [...elems.value];
|
||||
const sorted = elements.sort((a, b) => {
|
||||
@@ -150,10 +149,10 @@ function openToolSection(sectionId: string) {
|
||||
function checkFilter() {
|
||||
return !props.disableFilter && !!props.queryFilter;
|
||||
}
|
||||
function onClick(tool: ToolType | Workflow, evt: Event) {
|
||||
function onClick(tool: any, evt: Event) {
|
||||
emit("onClick", tool, evt);
|
||||
}
|
||||
function onOperation(tool: ToolType | Workflow, evt: Event) {
|
||||
function onOperation(tool: any, evt: Event) {
|
||||
emit("onOperation", tool, evt);
|
||||
}
|
||||
function toggleMenu(nextState = !opened.value) {
|
||||
@@ -175,7 +174,7 @@ function toggleMenu(nextState = !opened.value) {
|
||||
</a>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div v-if="opened">
|
||||
<div v-if="opened" data-description="opened tool panel section">
|
||||
<template v-for="[key, el] in sortedElements">
|
||||
<ToolPanelLabel
|
||||
v-if="category.text || el.model_class === 'ToolSectionLabel'"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import toolsList from "components/ToolsView/testData/toolsList";
|
||||
import toolsListInPanel from "components/ToolsView/testData/toolsListInPanel";
|
||||
import { useConfig } from "composables/config";
|
||||
|
||||
import toolsList from "./testToolsList";
|
||||
import { filterTools, filterToolSections } from "./utilities";
|
||||
import { createSortedResultObject, filterTools } from "./utilities";
|
||||
|
||||
jest.mock("composables/config");
|
||||
useConfig.mockReturnValue({
|
||||
@@ -14,8 +15,9 @@ useConfig.mockReturnValue({
|
||||
});
|
||||
|
||||
describe("ToolBox", () => {
|
||||
const toolsMock = toolsList;
|
||||
const resultsMock = ["join1", "join_collections", "find1"];
|
||||
const toolsMock = toolsList.tools;
|
||||
const toolPanelMock = toolsListInPanel.default;
|
||||
const resultsMock = ["liftOver1", "__FILTER_EMPTY_DATASETS__", "__UNZIP_COLLECTION__"];
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -25,12 +27,20 @@ describe("ToolBox", () => {
|
||||
it("test filter functions correctly matching: (1) Tools store array-of-objects with (2) Results array", async () => {
|
||||
axiosMock
|
||||
.onGet(`/api/tools`)
|
||||
.replyOnce(200, toolsListInPanel)
|
||||
.onGet(`/api/tools?in_panel=False`)
|
||||
.replyOnce(200, toolsMock)
|
||||
.onGet(/api\/tools?.*/)
|
||||
.replyOnce(200, resultsMock);
|
||||
const toolsResults = filterTools(toolsMock, resultsMock);
|
||||
const toolsResultsSection = filterToolSections(toolsMock, resultsMock);
|
||||
expect(toolsResults.length).toBe(3);
|
||||
expect(toolsResultsSection.length).toBe(2);
|
||||
const resultIds = Object.keys(toolsResults);
|
||||
expect(resultIds.length).toBe(3);
|
||||
const matchedTools = resultIds.map((id) => {
|
||||
return { id: id, sections: [], order: 0 };
|
||||
});
|
||||
const toolsResultsSection = createSortedResultObject(matchedTools, toolPanelMock);
|
||||
expect(toolsResultsSection.idResults).toEqual(resultIds);
|
||||
const resultSectionIds = Object.keys(toolsResultsSection.resultPanel);
|
||||
expect(resultSectionIds.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,24 +3,24 @@ import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, type PropType, type Ref, ref } from "vue";
|
||||
import { computed, ComputedRef, type PropType, type Ref, ref } from "vue";
|
||||
import { useRouter } from "vue-router/composables";
|
||||
|
||||
import { getGalaxyInstance } from "@/app";
|
||||
import { useGlobalUploadModal } from "@/composables/globalUploadModal";
|
||||
import { getAppRoot } from "@/onload/loadConfig";
|
||||
import { type Tool, type ToolSection as ToolSectionType, type ToolSectionLabel } from "@/stores/toolStore";
|
||||
import { type Tool, type ToolSection as ToolSectionType } from "@/stores/toolStore";
|
||||
import { useToolStore } from "@/stores/toolStore";
|
||||
import { Workflow, type Workflow as WorkflowType } from "@/stores/workflowStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import { filterTools, isToolObject } from "./utilities.js";
|
||||
import { filterTools, getValidPanelItems, getValidToolsInCurrentView, getValidToolsInEachSection } from "./utilities";
|
||||
|
||||
import ToolSearch from "./Common/ToolSearch.vue";
|
||||
import ToolSection from "./Common/ToolSection.vue";
|
||||
import UploadButton from "@/components/Upload/UploadButton.vue";
|
||||
|
||||
const SECTION_IDS_TO_EXCLUDE = ["expression_tools"];
|
||||
const SECTION_IDS_TO_EXCLUDE = ["expression_tools"]; // if this isn't the Workflow Editor panel
|
||||
|
||||
const { openGlobalUploadModal } = useGlobalUploadModal();
|
||||
const router = useRouter();
|
||||
@@ -41,7 +41,7 @@ const props = defineProps({
|
||||
panelQuery: { type: String, required: true },
|
||||
editorWorkflows: { type: Array, default: null },
|
||||
dataManagers: { type: Array, default: null },
|
||||
moduleSections: { type: Array as PropType<Tool[]>, default: null },
|
||||
moduleSections: { type: Array as PropType<Record<string, any>>, default: null },
|
||||
});
|
||||
|
||||
library.add(faEye, faEyeSlash);
|
||||
@@ -92,25 +92,12 @@ const dataManagerSection = computed(() => {
|
||||
|
||||
/** `toolsById` from `toolStore`, except it only has valid tools for `props.workflow` value */
|
||||
const localToolsById = computed(() => {
|
||||
const addedToolTexts: string[] = [];
|
||||
if (toolStore.toolsById && Object.keys(toolStore.toolsById).length > 0) {
|
||||
const toolEntries = Object.entries(toolStore.toolsById).filter(([_, tool]: [string, any]) => {
|
||||
// filter out duplicate tools (different ids, same exact name+description)
|
||||
// related ticket: https://github.com/galaxyproject/galaxy/issues/16145
|
||||
const toolText: string = tool.name + tool.description;
|
||||
if (addedToolTexts.includes(toolText)) {
|
||||
return false;
|
||||
} else {
|
||||
addedToolTexts.push(toolText);
|
||||
}
|
||||
// filter on non-hidden, non-disabled, and workflow compatibile (based on props.workflow)
|
||||
return (
|
||||
!tool.hidden &&
|
||||
tool.disabled !== true &&
|
||||
(props.workflow ? tool.is_workflow_compatible : !SECTION_IDS_TO_EXCLUDE.includes(tool.panel_section_id))
|
||||
);
|
||||
});
|
||||
return Object.fromEntries(toolEntries);
|
||||
return getValidToolsInCurrentView(
|
||||
toolStore.toolsById,
|
||||
props.workflow,
|
||||
!props.workflow ? SECTION_IDS_TO_EXCLUDE : []
|
||||
);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
@@ -118,37 +105,16 @@ const localToolsById = computed(() => {
|
||||
/** `currentPanel` from `toolStore`, except it only has valid tools and sections for `props.workflow` value */
|
||||
const localSectionsById = computed(() => {
|
||||
const validToolIdsInCurrentView = Object.keys(localToolsById.value);
|
||||
// for all values that are `ToolSection`s, filter out tools that aren't in `localToolsById`
|
||||
const sectionEntries = Object.entries(currentPanel.value).map(([id, section]: [string, any]) => {
|
||||
if (section.tools && Array.isArray(section.tools)) {
|
||||
section.tools = section.tools.filter((toolId: string) => {
|
||||
if (validToolIdsInCurrentView.includes(toolId)) {
|
||||
return true;
|
||||
} else if (toolId.startsWith("panel_label_") && section.panel_labels) {
|
||||
// panel_label_ is a special case where there is a label within a section
|
||||
const labelId = toolId.split("panel_label_")[1];
|
||||
return section.panel_labels.find((label: ToolSectionLabel) => label.id === labelId) !== undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return [id, section];
|
||||
});
|
||||
|
||||
const updatedSectionEntries = sectionEntries.filter(([id, section]) => {
|
||||
if (isToolObject(section) && validToolIdsInCurrentView.includes(id)) {
|
||||
// is a `Tool` and is in `localToolsById`
|
||||
return true;
|
||||
} else if (section.tools === undefined) {
|
||||
// is neither a `Tool` nor a `ToolSection`, maybe a `ToolSectionLabel`
|
||||
return true;
|
||||
} else if (section.tools.length > 0 && (props.workflow || !SECTION_IDS_TO_EXCLUDE.includes(id))) {
|
||||
// is a `ToolSection` with tools; check workflow compatibility (based on props.workflow)
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return Object.fromEntries(updatedSectionEntries);
|
||||
// Looking within each `ToolSection`, and filtering on child elements
|
||||
const sectionEntries = getValidToolsInEachSection(validToolIdsInCurrentView, currentPanel.value);
|
||||
|
||||
// Looking at each item in the panel now (not within each child)
|
||||
return getValidPanelItems(
|
||||
sectionEntries,
|
||||
validToolIdsInCurrentView,
|
||||
!props.workflow ? SECTION_IDS_TO_EXCLUDE : []
|
||||
) as Record<string, Tool | ToolSectionType>;
|
||||
});
|
||||
|
||||
const toolsList = computed(() => Object.values(localToolsById.value));
|
||||
@@ -159,73 +125,69 @@ const toolsList = computed(() => Object.values(localToolsById.value));
|
||||
* If we have results for search, we show tools in sections or just tools,
|
||||
* based on whether `showSections` is true or false
|
||||
*/
|
||||
const localPanel = computed(() => {
|
||||
const localPanel: ComputedRef<Record<string, Tool | ToolSectionType> | null> = computed(() => {
|
||||
if (hasResults.value) {
|
||||
if (showSections.value) {
|
||||
return resultPanel.value;
|
||||
} else {
|
||||
return filterTools(localToolsById.value, results.value);
|
||||
return filterTools(localToolsById.value, results.value) as Record<string, Tool | ToolSectionType>;
|
||||
}
|
||||
} else {
|
||||
return localSectionsById.value;
|
||||
}
|
||||
});
|
||||
|
||||
const sectionIds = computed(() => Object.keys(localPanel.value));
|
||||
const sectionIds = computed(() => Object.keys(localPanel.value || {}));
|
||||
|
||||
const workflows = computed(() => {
|
||||
if (props.workflow && props.editorWorkflows) {
|
||||
return props.editorWorkflows;
|
||||
} else {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const storedWorkflowMenuEntries = Galaxy && Galaxy.config.stored_workflow_menu_entries;
|
||||
if (storedWorkflowMenuEntries) {
|
||||
const returnedWfs = [];
|
||||
if (!props.workflow) {
|
||||
returnedWfs.push({
|
||||
title: localize("All workflows"),
|
||||
href: `${getAppRoot()}workflows/list`,
|
||||
id: "list",
|
||||
});
|
||||
}
|
||||
const storedWfs = [
|
||||
...storedWorkflowMenuEntries.map((menuEntry: Workflow) => {
|
||||
return {
|
||||
id: menuEntry.id,
|
||||
title: menuEntry.name,
|
||||
href: `${getAppRoot()}workflows/run?id=${menuEntry.id}`,
|
||||
};
|
||||
}),
|
||||
];
|
||||
return returnedWfs.concat(storedWfs);
|
||||
} else {
|
||||
return [];
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const storedWorkflowMenuEntries = Galaxy && Galaxy.config.stored_workflow_menu_entries;
|
||||
if (storedWorkflowMenuEntries) {
|
||||
const returnedWfs = [];
|
||||
if (!props.workflow) {
|
||||
returnedWfs.push({
|
||||
title: localize("All workflows") as string,
|
||||
href: `${getAppRoot()}workflows/list`,
|
||||
id: "list",
|
||||
});
|
||||
}
|
||||
const storedWfs = [
|
||||
...storedWorkflowMenuEntries.map((menuEntry: Workflow) => {
|
||||
return {
|
||||
id: menuEntry.id,
|
||||
title: menuEntry.name,
|
||||
href: `${getAppRoot()}workflows/run?id=${menuEntry.id}`,
|
||||
};
|
||||
}),
|
||||
];
|
||||
return returnedWfs.concat(storedWfs);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const workflowSection = computed(() => {
|
||||
return {
|
||||
name: localize("Workflows"),
|
||||
elems: workflows.value,
|
||||
elems: props.workflow && props.editorWorkflows,
|
||||
};
|
||||
});
|
||||
|
||||
function onInsertModule(module: Tool, event: Event) {
|
||||
function onInsertModule(module: Record<string, any>, event: Event) {
|
||||
event.preventDefault();
|
||||
emit("onInsertModule", module.name, module.title);
|
||||
}
|
||||
|
||||
function onInsertWorkflow(workflow: WorkflowType | Tool, event: Event) {
|
||||
function onInsertWorkflow(workflow: WorkflowType, event: Event) {
|
||||
event.preventDefault();
|
||||
emit("onInsertWorkflow", workflow.latest_id, workflow.name);
|
||||
}
|
||||
|
||||
function onInsertWorkflowSteps(workflow: WorkflowType | Tool) {
|
||||
function onInsertWorkflowSteps(workflow: WorkflowType) {
|
||||
emit("onInsertWorkflowSteps", workflow.id, workflow.step_count);
|
||||
}
|
||||
|
||||
function onToolClick(tool: Tool | WorkflowType, evt: Event) {
|
||||
function onToolClick(tool: Tool, evt: Event) {
|
||||
if (!props.workflow) {
|
||||
if (tool.id === "upload1") {
|
||||
evt.preventDefault();
|
||||
@@ -324,21 +286,21 @@ function setButtonText() {
|
||||
:category="category"
|
||||
tool-key="name"
|
||||
:section-name="category.name"
|
||||
:query-filter="queryFilter"
|
||||
:query-filter="queryFilter || undefined"
|
||||
:disable-filter="true"
|
||||
@onClick="onInsertModule" />
|
||||
</div>
|
||||
<ToolSection
|
||||
v-if="hasDataManagerSection"
|
||||
:category="dataManagerSection"
|
||||
:query-filter="queryFilter"
|
||||
:query-filter="queryFilter || undefined"
|
||||
:disable-filter="true"
|
||||
@onClick="onToolClick" />
|
||||
<div v-for="(sectionId, key) in sectionIds" :key="key">
|
||||
<ToolSection
|
||||
v-if="localPanel[sectionId]"
|
||||
:category="localPanel[sectionId] || {}"
|
||||
:query-filter="queryFilter"
|
||||
:query-filter="queryFilter || undefined"
|
||||
@onClick="onToolClick" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,7 +313,7 @@ function setButtonText() {
|
||||
:sort-items="false"
|
||||
operation-icon="fa fa-files-o"
|
||||
operation-title="Insert individual steps."
|
||||
:query-filter="queryFilter"
|
||||
:query-filter="queryFilter || undefined"
|
||||
:disable-filter="true"
|
||||
@onClick="onInsertWorkflow"
|
||||
@onOperation="onInsertWorkflowSteps" />
|
||||
@@ -360,7 +322,9 @@ function setButtonText() {
|
||||
<div id="internal-workflows" class="toolSectionBody">
|
||||
<div class="toolSectionBg" />
|
||||
<div v-for="wf in workflows" :key="wf.id" class="toolTitle">
|
||||
<a class="title-link" :href="wf.href">{{ wf.title }}</a>
|
||||
<a class="title-link" href="javascript:void(0)" @click="router.push(wf.href)">{{
|
||||
wf.title
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
[
|
||||
{
|
||||
"model_class": "ToolSection",
|
||||
"id": "group_1",
|
||||
"name": "Group 1",
|
||||
"elems": [
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Join",
|
||||
"id": "join1",
|
||||
"description": "two files",
|
||||
"panel_section_id": "group_1",
|
||||
"panel_section_name": "Group 1"
|
||||
},
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Remove",
|
||||
"id": "remove1",
|
||||
"description": "lines",
|
||||
"panel_section_id": "group_1",
|
||||
"panel_section_name": "Group 1"
|
||||
},
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Cut",
|
||||
"id": "cut1",
|
||||
"description": "lines",
|
||||
"panel_section_id": "group_1",
|
||||
"panel_section_name": "Group 1"
|
||||
},
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Find",
|
||||
"id": "find1",
|
||||
"description": "joined dataset",
|
||||
"panel_section_id": "group_1",
|
||||
"panel_section_name": "Group 1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model_class": "ToolSection",
|
||||
"id": "group_2",
|
||||
"name": "Group 2",
|
||||
"elems": [
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Join Collections",
|
||||
"id": "join_collections",
|
||||
"description": "",
|
||||
"panel_section_id": "group_2",
|
||||
"panel_section_name": "Group 2"
|
||||
},
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Create Collections",
|
||||
"id": "create_collections",
|
||||
"description": "from datasets",
|
||||
"panel_section_id": "group_2",
|
||||
"panel_section_name": "Group 2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"model_class": "Tool",
|
||||
"name": "Remove duplicate",
|
||||
"id": "remove_duplicate",
|
||||
"description": "lines from dataset",
|
||||
"panel_section_id": "remove_duplicate",
|
||||
"panel_section_name": "Remove duplicate"
|
||||
}
|
||||
]
|
||||
@@ -1,11 +1,11 @@
|
||||
import toolsList from "components/ToolsView/testData/toolsList";
|
||||
import toolsListInPanel from "components/ToolsView/testData/toolsListInPanel";
|
||||
|
||||
import {
|
||||
createSortedResultObject,
|
||||
createWhooshQuery,
|
||||
determineWidth,
|
||||
filterTools,
|
||||
filterToolSections,
|
||||
flattenTools,
|
||||
searchToolsByKeys,
|
||||
} from "./utilities";
|
||||
|
||||
@@ -18,27 +18,35 @@ describe("test helpers in tool searching utilities and panel handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const tempToolsList = [
|
||||
{
|
||||
elems: [
|
||||
{
|
||||
panel_section_name: "FASTA/FASTQ",
|
||||
description: "Extract UMI from fastq files",
|
||||
id: "toolshed.g2.bx.psu.edu/repos/iuc/umi_tools_extract/umi_tools_extract/1.1.2+galaxy2",
|
||||
name: "UMI-tools extract",
|
||||
},
|
||||
{
|
||||
panel_section_name: "FASTA/FASTQ",
|
||||
description: "Extract UMI from (fasta files)",
|
||||
id: "umi_tools_reduplicate",
|
||||
name: "UMI-tools reduplicate",
|
||||
},
|
||||
],
|
||||
model_class: "ToolSection",
|
||||
id: "fasta/fastq",
|
||||
name: "FASTA/FASTQ",
|
||||
const tempToolPanel = {
|
||||
default: {
|
||||
"fasta/fastq": {
|
||||
tools: [
|
||||
"toolshed.g2.bx.psu.edu/repos/iuc/umi_tools_extract/umi_tools_extract/1.1.2+galaxy2",
|
||||
"umi_tools_reduplicate",
|
||||
],
|
||||
model_class: "ToolSection",
|
||||
id: "fasta/fastq",
|
||||
name: "FASTA/FASTQ",
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
const tempToolsList = {
|
||||
tools: {
|
||||
"toolshed.g2.bx.psu.edu/repos/iuc/umi_tools_extract/umi_tools_extract/1.1.2+galaxy2": {
|
||||
panel_section_name: "FASTA/FASTQ",
|
||||
description: "Extract UMI from fastq files",
|
||||
id: "toolshed.g2.bx.psu.edu/repos/iuc/umi_tools_extract/umi_tools_extract/1.1.2+galaxy2",
|
||||
name: "UMI-tools extract",
|
||||
},
|
||||
umi_tools_reduplicate: {
|
||||
panel_section_name: "FASTA/FASTQ",
|
||||
description: "Extract UMI from (fasta files)",
|
||||
id: "umi_tools_reduplicate",
|
||||
name: "UMI-tools reduplicate",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("test helpers in tool searching utilities", () => {
|
||||
it("test parsing helper that converts settings to whoosh query", async () => {
|
||||
@@ -72,7 +80,8 @@ describe("test helpers in tool searching utilities", () => {
|
||||
"__ZIP_COLLECTION__",
|
||||
],
|
||||
keys: { description: 1, name: 0 },
|
||||
list: toolsList,
|
||||
tools: Object.values(toolsList.tools),
|
||||
panel: toolsListInPanel.default,
|
||||
},
|
||||
{
|
||||
// name prioritized
|
||||
@@ -84,42 +93,48 @@ describe("test helpers in tool searching utilities", () => {
|
||||
"__FILTER_EMPTY_DATASETS__",
|
||||
],
|
||||
keys: { description: 0, name: 1 },
|
||||
list: toolsList,
|
||||
tools: Object.values(toolsList.tools),
|
||||
panel: toolsListInPanel.default,
|
||||
},
|
||||
{
|
||||
// whitespace precedes to ensure query.trim() works
|
||||
q: " filter empty datasets",
|
||||
expectedResults: ["__FILTER_EMPTY_DATASETS__"],
|
||||
keys: { description: 1, name: 2, combined: 0 },
|
||||
list: toolsList,
|
||||
tools: Object.values(toolsList.tools),
|
||||
panel: toolsListInPanel.default,
|
||||
},
|
||||
{
|
||||
// hyphenated tool-name is searchable
|
||||
q: "uMi tools extract ",
|
||||
expectedResults: ["toolshed.g2.bx.psu.edu/repos/iuc/umi_tools_extract/umi_tools_extract/1.1.2+galaxy2"],
|
||||
keys: { description: 1, name: 2 },
|
||||
list: tempToolsList,
|
||||
tools: Object.values(tempToolsList.tools),
|
||||
panel: tempToolPanel.default,
|
||||
},
|
||||
{
|
||||
// parenthesis is searchable
|
||||
q: "from FASTA files",
|
||||
// parenthesis (and other chars) are searchable
|
||||
q: "from FASTA:files",
|
||||
expectedResults: ["umi_tools_reduplicate"],
|
||||
keys: { description: 1, name: 2 },
|
||||
list: tempToolsList,
|
||||
tools: Object.values(tempToolsList.tools),
|
||||
panel: tempToolPanel.default,
|
||||
},
|
||||
{
|
||||
// id is not searchable if not identified by colon
|
||||
q: "__ZIP_COLLECTION__",
|
||||
expectedResults: [],
|
||||
keys: { description: 1, name: 2 },
|
||||
list: toolsList,
|
||||
tools: Object.values(toolsList.tools),
|
||||
panel: toolsListInPanel.default,
|
||||
},
|
||||
{
|
||||
// id is searchable if provided "id:"
|
||||
q: "id:__ZIP_COLLECTION__",
|
||||
expectedResults: ["__ZIP_COLLECTION__"],
|
||||
keys: { description: 1, name: 2 },
|
||||
list: toolsList,
|
||||
tools: Object.values(toolsList.tools),
|
||||
panel: toolsListInPanel.default,
|
||||
},
|
||||
{
|
||||
// id is searchable if provided "tool_id:"
|
||||
@@ -129,11 +144,20 @@ describe("test helpers in tool searching utilities", () => {
|
||||
"umi_tools_reduplicate",
|
||||
],
|
||||
keys: { description: 1, name: 2 },
|
||||
list: tempToolsList,
|
||||
tools: Object.values(tempToolsList.tools),
|
||||
panel: tempToolPanel.default,
|
||||
},
|
||||
// if at least couple words match, return results
|
||||
{
|
||||
q: "filter datasets",
|
||||
expectedResults: ["__FILTER_FAILED_DATASETS__", "__FILTER_EMPTY_DATASETS__"],
|
||||
keys: { combined: 1, wordMatch: 0 },
|
||||
tools: Object.values(toolsList.tools),
|
||||
panel: toolsListInPanel.default,
|
||||
},
|
||||
];
|
||||
searches.forEach((search) => {
|
||||
const { results } = searchToolsByKeys(flattenTools(search.list), search.keys, search.q);
|
||||
const { results } = searchToolsByKeys(search.tools, search.keys, search.q, "default", search.panel);
|
||||
expect(results).toEqual(search.expectedResults);
|
||||
});
|
||||
});
|
||||
@@ -143,13 +167,25 @@ describe("test helpers in tool searching utilities", () => {
|
||||
const keys = { description: 1, name: 2, combined: 0 };
|
||||
const filterQueries = ["Fillter", "FILYER", " Fitler", " filtr"];
|
||||
filterQueries.forEach((q) => {
|
||||
const { results, closestTerm } = searchToolsByKeys(flattenTools(toolsList), keys, q);
|
||||
const { results, closestTerm } = searchToolsByKeys(
|
||||
Object.values(toolsList.tools),
|
||||
keys,
|
||||
q,
|
||||
"default",
|
||||
toolsListInPanel.default
|
||||
);
|
||||
expect(results).toEqual(expectedResults);
|
||||
expect(closestTerm).toEqual("filter");
|
||||
});
|
||||
const queries = ["datases from a collection", "from a colleection"];
|
||||
queries.forEach((q) => {
|
||||
const { results } = searchToolsByKeys(flattenTools(toolsList), keys, q);
|
||||
const { results } = searchToolsByKeys(
|
||||
Object.values(toolsList.tools),
|
||||
keys,
|
||||
q,
|
||||
"default",
|
||||
toolsListInPanel.default
|
||||
);
|
||||
expect(results).toEqual(expectedResults);
|
||||
});
|
||||
});
|
||||
@@ -157,12 +193,16 @@ describe("test helpers in tool searching utilities", () => {
|
||||
it("test tool filtering helpers on toolsList given list of ids", async () => {
|
||||
const ids = ["__FILTER_FAILED_DATASETS__", "liftOver1"];
|
||||
// check length of first section from imported const toolsList
|
||||
expect(toolsList.find((section) => section.id == "collection_operations").elems).toHaveLength(4);
|
||||
expect(toolsListInPanel.default["collection_operations"].tools).toHaveLength(4);
|
||||
// check length of same section from filtered toolsList
|
||||
expect(
|
||||
filterToolSections(toolsList, ids).find((section) => section.id == "collection_operations").elems
|
||||
).toHaveLength(1);
|
||||
// check length of filtered tools without sections
|
||||
expect(filterTools(toolsList, ids)).toHaveLength(2);
|
||||
const matchedTools = ids.map((id) => {
|
||||
return { id: id, sections: [], order: 0 };
|
||||
});
|
||||
const toolResultsPanel = createSortedResultObject(matchedTools, toolsListInPanel.default);
|
||||
const toolResultsSection = toolResultsPanel.resultPanel["collection_operations"];
|
||||
expect(toolResultsSection.tools).toHaveLength(1);
|
||||
// check length of filtered tools (regardless of sections)
|
||||
const filteredToolIds = Object.keys(filterTools(toolsList.tools, ids));
|
||||
expect(filteredToolIds).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* 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 {
|
||||
type FilterSettings as ToolFilters,
|
||||
type Tool,
|
||||
type ToolSection,
|
||||
type ToolSectionLabel,
|
||||
} from "@/stores/toolStore";
|
||||
import levenshteinDistance from "@/utils/levenshtein";
|
||||
|
||||
const TOOL_ID_KEYS = ["id", "tool_id"];
|
||||
const STRING_REPLACEMENTS: string[] = [" ", "-", "\\(", "\\)", "'", ":", `"`];
|
||||
const MINIMUM_DL_LENGTH = 5; // for Demerau-Levenshtein distance
|
||||
const MINIMUM_WORD_MATCH = 2; // for word match
|
||||
const UNSECTIONED_SECTION = {
|
||||
// to return a section for unsectioned tools
|
||||
model_class: "ToolSection",
|
||||
id: "unsectioned",
|
||||
name: "Unsectioned Tools",
|
||||
description: "Tools that don't appear under any section in the unsearched panel",
|
||||
};
|
||||
const VALID_SECTION_TYPES = ["edam_operations", "edam_topics"]; // other than `default`
|
||||
|
||||
export interface ToolSearchKeys {
|
||||
[key: string | keyof Tool]: number | undefined;
|
||||
exact?: number;
|
||||
startsWith?: number;
|
||||
name?: number;
|
||||
description?: number;
|
||||
combined?: number;
|
||||
wordMatch?: number;
|
||||
}
|
||||
|
||||
interface SearchMatch {
|
||||
id: string;
|
||||
sections: (string | undefined)[];
|
||||
order: number;
|
||||
}
|
||||
|
||||
// Converts filterSettings { key: value } to query = "key:value"
|
||||
export function createWorkflowQuery(filterSettings: Record<string, string | boolean>) {
|
||||
let query = "";
|
||||
query = Object.entries(filterSettings)
|
||||
.filter(([, value]) => value)
|
||||
.map(([filter, value]) => {
|
||||
if (value === true) {
|
||||
return `is:${filter}`;
|
||||
}
|
||||
return `${filter}:${value}`;
|
||||
})
|
||||
.join(" ");
|
||||
if (Object.keys(filterSettings).length == 1 && filterSettings.name) {
|
||||
return filterSettings.name as string;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Converts filters into tool search backend whoosh query.
|
||||
* @param filterSettings e.g.: {"name": "Tool Name", "section": "Collection", ...}
|
||||
* @returns parsed Whoosh `query`
|
||||
* @example
|
||||
* createWhooshQuery(filterSettings = {"name": "skew", "ontology": "topic_0797"})
|
||||
* return query = "(name:(skew) name_exact:(skew) description:(skew)) AND (edam_topics:(topic_0797) AND )"
|
||||
*/
|
||||
export function createWhooshQuery(filterSettings: ToolFilters) {
|
||||
let query = "(";
|
||||
// add description+name_exact fields = name, to do a combined OrGroup at backend
|
||||
const name = filterSettings["name"];
|
||||
if (name) {
|
||||
query += "name:(" + name + ") ";
|
||||
query += "name_exact:(" + name + ") ";
|
||||
query += "description:(" + name + ")";
|
||||
}
|
||||
query += ") AND (";
|
||||
for (const [key, filterValue] of Object.entries(filterSettings)) {
|
||||
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") {
|
||||
query += key + ":(" + filterValue + ") AND ";
|
||||
}
|
||||
}
|
||||
query += ")";
|
||||
return query;
|
||||
}
|
||||
|
||||
// Determines width given the root and draggable element, smallest and largest size and the current position
|
||||
export function determineWidth(
|
||||
rectRoot: { left: number; right: number },
|
||||
rectDraggable: { left: number },
|
||||
minWidth: number,
|
||||
maxWidth: number,
|
||||
direction: string,
|
||||
positionX: number
|
||||
) {
|
||||
let newWidth = null;
|
||||
if (direction === "right") {
|
||||
const offset = rectRoot.left - rectDraggable.left;
|
||||
newWidth = rectRoot.right - positionX - offset;
|
||||
} else {
|
||||
const offset = rectRoot.right - rectDraggable.left;
|
||||
newWidth = positionX - rectRoot.left + offset;
|
||||
}
|
||||
return Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param toolsById - all tools, keyed by id
|
||||
* @param results - list of result tool ids
|
||||
* @returns filtered tool results (by id)
|
||||
*/
|
||||
export function filterTools(toolsById: Record<string, Tool>, results: string[]) {
|
||||
const filteredTools: Record<string, Tool> = {};
|
||||
for (const id of results) {
|
||||
const localTool = toolsById[id];
|
||||
if (localTool !== undefined) {
|
||||
filteredTools[id] = localTool;
|
||||
}
|
||||
}
|
||||
return filteredTools;
|
||||
}
|
||||
|
||||
/** Returns a `toolsById` object containing tools that meet required conditions
|
||||
* based on params.
|
||||
* @param toolsById - object of tools, keyed by id
|
||||
* @param isWorkflowPanel - whether or not the ToolPanel is in Workflow Editor
|
||||
* @param excludedSectionIds - ids for sections whose tools will be excluded
|
||||
*/
|
||||
export function getValidToolsInCurrentView(
|
||||
toolsById: Record<string, Tool>,
|
||||
isWorkflowPanel = false,
|
||||
excludedSectionIds: string[] = []
|
||||
) {
|
||||
const addedToolTexts: string[] = [];
|
||||
const toolEntries = Object.entries(toolsById).filter(([, tool]) => {
|
||||
// filter out duplicate tools (different ids, same exact name+description)
|
||||
// related ticket: https://github.com/galaxyproject/galaxy/issues/16145
|
||||
const toolText = tool.name + tool.description;
|
||||
if (addedToolTexts.includes(toolText)) {
|
||||
return false;
|
||||
} else {
|
||||
addedToolTexts.push(toolText);
|
||||
}
|
||||
// filter on non-hidden, non-disabled, and workflow compatibile (based on props.workflow)
|
||||
return (
|
||||
!tool.hidden &&
|
||||
tool.disabled !== true &&
|
||||
!(isWorkflowPanel && !tool.is_workflow_compatible) &&
|
||||
!excludedSectionIds.includes(tool.panel_section_id || "")
|
||||
);
|
||||
});
|
||||
return Object.fromEntries(toolEntries);
|
||||
}
|
||||
|
||||
/** Looks in each section of `currentPanel` and filters `section.tools` on `validToolIdsInCurrentView` */
|
||||
export function getValidToolsInEachSection(
|
||||
validToolIdsInCurrentView: string[],
|
||||
currentPanel: Record<string, Tool | ToolSection>
|
||||
) {
|
||||
return Object.entries(currentPanel).map(([id, section]) => {
|
||||
const validatedSection = { ...section } as ToolSection;
|
||||
if (validatedSection.tools && Array.isArray(validatedSection.tools)) {
|
||||
// filter on valid tools and panel_labels in this section
|
||||
validatedSection.tools = validatedSection.tools.filter((toolId) => {
|
||||
if (validToolIdsInCurrentView.includes(toolId)) {
|
||||
return true;
|
||||
} else if (toolId.startsWith("panel_label_") && validatedSection.panel_labels) {
|
||||
// panel_label_ is a special id where there is a label within a section
|
||||
const labelId = toolId.split("panel_label_")[1];
|
||||
return validatedSection.panel_labels.find((label) => label.id === labelId) !== undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return [id, validatedSection];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param items - `[id, PanelItem]` entries (from the `currentPanel` object)
|
||||
* @param validToolIdsInCurrentView - tool ids that are valid in current view
|
||||
* @param excludedSectionIds - any section ids to exclude
|
||||
* @returns a `currentPanel` object containing sections/tools/labels that meet required conditions
|
||||
*/
|
||||
export function getValidPanelItems(
|
||||
items: (string | ToolSection)[][],
|
||||
validToolIdsInCurrentView: string[],
|
||||
excludedSectionIds: string[] = []
|
||||
) {
|
||||
const validEntries = items.filter(([id, item]) => {
|
||||
id = id as string;
|
||||
item = item as Tool | ToolSection;
|
||||
if (isToolObject(item as Tool) && validToolIdsInCurrentView.includes(id)) {
|
||||
// is a `Tool` and is in `localToolsById`
|
||||
return true;
|
||||
} else if (item.tools === undefined) {
|
||||
// is neither a `Tool` nor a `ToolSection`, maybe a `ToolSectionLabel`
|
||||
return true;
|
||||
} else if (item.tools && item.tools.length > 0 && !excludedSectionIds.includes(id)) {
|
||||
// is a `ToolSection` with tools; is not an excluded section
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return Object.fromEntries(validEntries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given toolbox, keys to sort/search results by and a search query,
|
||||
* Does a direct string.match() comparison to find results,
|
||||
* If that produces nothing, runs DL distance alg to allow misspells
|
||||
*
|
||||
* @param tools - toolbox
|
||||
* @param keys - keys to sort and search results by
|
||||
* @param query - a search query
|
||||
* @param panelView - panel view, to find section_id for each tool
|
||||
* @param currentPanel - current ToolPanel with { section_id: { tools: [tool ids] }, ... }
|
||||
* @param usesDL - Optional: used for recursive call with DL if no string.match()
|
||||
* @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: Tool[],
|
||||
keys: ToolSearchKeys,
|
||||
query: string,
|
||||
panelView = "default",
|
||||
currentPanel: Record<string, Tool | ToolSection>,
|
||||
usesDL = false
|
||||
): {
|
||||
results: string[];
|
||||
resultPanel: Record<string, Tool | ToolSection>;
|
||||
closestTerm: string | null;
|
||||
} {
|
||||
const matchedTools: SearchMatch[] = [];
|
||||
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 queryWords = query.trim().toLowerCase().split(" ");
|
||||
const queryValue = sanitizeString(query.trim().toLowerCase(), STRING_REPLACEMENTS);
|
||||
for (const tool of tools) {
|
||||
for (const key of Object.keys(keys)) {
|
||||
if (tool[key as keyof Tool] || key === "combined") {
|
||||
let actualValue = "";
|
||||
// key = "combined" is a special case for searching name + description
|
||||
if (key === "combined") {
|
||||
actualValue = `${tool.name.trim()} ${tool.description.trim()}`.toLowerCase();
|
||||
} else {
|
||||
actualValue = (tool[key as keyof Tool] as string)?.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 ?
|
||||
let order =
|
||||
keys.exact !== undefined && actualValue === queryValue
|
||||
? (keys.exact as number)
|
||||
: (keys[key] as number);
|
||||
// do we care for startsWith && does it actualValue start with query ?
|
||||
order =
|
||||
keys.startsWith !== undefined &&
|
||||
order !== keys.exact &&
|
||||
key === "name" &&
|
||||
actualValue.startsWith(queryValue)
|
||||
? keys.startsWith
|
||||
: order;
|
||||
|
||||
const wordMatches = actualValueWords.filter((word) => queryWords.includes(word));
|
||||
if (!usesDL) {
|
||||
if (actualValue.match(queryValue)) {
|
||||
// if string.match() returns true, matching tool found
|
||||
matchedTools.push({ id: tool.id, sections: getPanelSectionsForTool(tool, panelView), order });
|
||||
break;
|
||||
} else if (
|
||||
key === "combined" &&
|
||||
keys.wordMatch !== undefined &&
|
||||
wordMatches.length >= MINIMUM_WORD_MATCH
|
||||
) {
|
||||
// we are looking at combined name+description, and there are enough word matches
|
||||
matchedTools.push({
|
||||
id: tool.id,
|
||||
sections: getPanelSectionsForTool(tool, panelView),
|
||||
order: keys.wordMatch,
|
||||
});
|
||||
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 >= MINIMUM_DL_LENGTH) {
|
||||
substring = closestSubstring(queryValue, actualValue);
|
||||
}
|
||||
// there is a closestSubstring: matching tool found
|
||||
if (substring) {
|
||||
// get the closest matching term for substring
|
||||
const foundTerm = matchingTerm(actualValueWords, substring);
|
||||
if (foundTerm && (!closestTerm || (closestTerm && foundTerm.length < closestTerm.length))) {
|
||||
closestTerm = foundTerm;
|
||||
}
|
||||
matchedTools.push({
|
||||
id: tool.id,
|
||||
sections: getPanelSectionsForTool(tool, panelView),
|
||||
order,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// no results with string.match(): recursive call with usesDL
|
||||
if (!id && !usesDL && matchedTools.length == 0) {
|
||||
return searchToolsByKeys(tools, keys, query, panelView, currentPanel, true);
|
||||
}
|
||||
const { idResults, resultPanel } = createSortedResultObject(matchedTools, currentPanel);
|
||||
return { results: idResults, resultPanel: resultPanel, closestTerm: closestTerm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the matchedTools by order of keys that are being searched, and creates a resultPanel
|
||||
* @param matchedTools containing { id: tool id, sections: [section ids], order: order }
|
||||
* @param 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
|
||||
*/
|
||||
export function createSortedResultObject(
|
||||
matchedTools: SearchMatch[],
|
||||
currentPanel: Record<string, Tool | ToolSection>
|
||||
) {
|
||||
const idResults: string[] = [];
|
||||
// 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: Record<string, Tool | ToolSection>, match: SearchMatch) => {
|
||||
// we either found specific section(s) for tool, or we need to search all sections
|
||||
const sections: (string | undefined)[] =
|
||||
match.sections.length !== 0 ? match.sections : Object.keys(currentPanel);
|
||||
for (const section of sections) {
|
||||
let toolAdded = false;
|
||||
const existingPanelItem = section ? currentPanel[section] : undefined;
|
||||
if (existingPanelItem && section) {
|
||||
if (
|
||||
(existingPanelItem as ToolSection).tools &&
|
||||
(existingPanelItem as ToolSection).tools?.includes(match.id)
|
||||
) {
|
||||
// it has tools so is a section, and it has the tool we're looking for
|
||||
|
||||
// if we haven't seen this section yet, create it in the resultPanel
|
||||
let existingSection = acc[section] as ToolSection;
|
||||
if (!existingSection) {
|
||||
existingSection = { ...existingPanelItem };
|
||||
existingSection.tools = [];
|
||||
}
|
||||
existingSection.tools?.push(match.id);
|
||||
acc[section] = existingSection;
|
||||
toolAdded = true;
|
||||
} else if (isToolObject(existingPanelItem as Tool) && existingPanelItem.id === match.id) {
|
||||
// it is a tool, and it is the tool we're looking for
|
||||
|
||||
// put in it the "Unsectioned Tools" section (if it doesn't exist, create it)
|
||||
const unsectionedId = UNSECTIONED_SECTION.id;
|
||||
let unsectionedSection = acc[unsectionedId] as ToolSection;
|
||||
if (!unsectionedSection) {
|
||||
unsectionedSection = { ...UNSECTIONED_SECTION };
|
||||
unsectionedSection.tools = [];
|
||||
}
|
||||
unsectionedSection.tools?.push(match.id);
|
||||
acc[unsectionedId] = unsectionedSection;
|
||||
toolAdded = true;
|
||||
}
|
||||
if (toolAdded && !idResults.includes(match.id)) {
|
||||
idResults.push(match.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
* @param panelView
|
||||
* @returns Array of section ids
|
||||
*/
|
||||
function getPanelSectionsForTool(tool: Tool, panelView: string) {
|
||||
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] as keyof Tool;
|
||||
if (
|
||||
sectionType &&
|
||||
VALID_SECTION_TYPES.includes(sectionType as string) &&
|
||||
(tool[sectionType] as string[])?.length !== 0
|
||||
) {
|
||||
return tool[sectionType] as string[];
|
||||
} else {
|
||||
return ["uncategorized"];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param query
|
||||
* @param actualStr
|
||||
* @returns substring with smallest DL distance, or null
|
||||
*/
|
||||
function closestSubstring(query: string, actualStr: string) {
|
||||
// Max distance a query and substring can be apart
|
||||
const maxDistance = 1;
|
||||
// Create an array of all actualStr substrings that are query length, query length -1, and query length + 1
|
||||
const substrings = Array.from({ length: actualStr.length - query.length + 1 }, (_, i) =>
|
||||
actualStr.substr(i, query.length)
|
||||
);
|
||||
if (query.length > 1) {
|
||||
substrings.push(
|
||||
...Array.from({ length: actualStr.length - query.length + 2 }, (_, i) =>
|
||||
actualStr.substr(i, query.length - 1)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (actualStr.length > query.length) {
|
||||
substrings.push(
|
||||
...Array.from({ length: actualStr.length - query.length }, (_, i) => actualStr.substr(i, query.length + 1))
|
||||
);
|
||||
}
|
||||
// check to see if any substrings have a levenshtein distance less than the max distance
|
||||
for (const substring of substrings) {
|
||||
if (levenshteinDistance(query, substring, true) <= maxDistance) {
|
||||
return substring;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isToolObject(tool: Tool | ToolSection | ToolSectionLabel) {
|
||||
// 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 as ToolSectionLabel).text &&
|
||||
tool.model_class !== "ToolSectionLabel" &&
|
||||
tool.model_class !== "ToolSection" &&
|
||||
(tool as ToolSection).tools === undefined
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// given array and a substring, get the closest matching term for substring
|
||||
function matchingTerm(termArray: string[], substring: string) {
|
||||
const sanitized = sanitizeString(substring);
|
||||
|
||||
for (const i in termArray) {
|
||||
const term = termArray[i];
|
||||
if (term?.match(sanitized)) {
|
||||
return term;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value - to be sanitized
|
||||
* @param targets - Optional: characters to replace
|
||||
* @param substitute - Optional: replacement character
|
||||
* @returns sanitized string
|
||||
*/
|
||||
function sanitizeString(value: string, targets: string[] = [], substitute = "") {
|
||||
let sanitized = value;
|
||||
targets.forEach((rep) => {
|
||||
sanitized = sanitized.replace(new RegExp(rep, "g"), substitute);
|
||||
});
|
||||
|
||||
return sanitized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* If the query is of the form "id:1234" (or "tool_id:1234"), return the id.
|
||||
* Otherwise, return null.
|
||||
* @param query - the raw query
|
||||
* @param keys - Optional: keys to check for (default: ["id"])
|
||||
* @returns id or null
|
||||
*/
|
||||
function processForId(query: string, keys = ["id"]) {
|
||||
for (const key of keys) {
|
||||
if (query.includes(`${key}:`)) {
|
||||
return query.split(`${key}:`)[1]?.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ describe("InstallationSettings", () => {
|
||||
changesetRevision: "changesetRevision",
|
||||
requiresPanel: true,
|
||||
toolshedUrl: "toolshedUrl",
|
||||
currentPanel: {},
|
||||
},
|
||||
localVue,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { type Ref, ref } from "vue";
|
||||
import { useRouter } from "vue-router/composables";
|
||||
|
||||
import { createWorkflowQuery } from "@/components/Panels/utilities";
|
||||
import _l from "@/utils/localization";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
type FilterSettings = {
|
||||
name?: string;
|
||||
tag?: string;
|
||||
published?: boolean;
|
||||
shared_with_me?: boolean;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
const options = [
|
||||
{ text: "Yes", value: true },
|
||||
{ text: "No", value: false },
|
||||
];
|
||||
|
||||
const filterSettings: Ref<FilterSettings> = ref({
|
||||
published: false,
|
||||
shared_with_me: false,
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
function onSearch() {
|
||||
const query = createWorkflowQuery(filterSettings.value as Record<string, string | boolean>);
|
||||
const path = "/workflows/list";
|
||||
const routerParams = query ? { path, query: { query } } : { path };
|
||||
router.push(routerParams);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div description="advanced workflow filters" @keyup.enter="onSearch">
|
||||
<small class="mt-1">Filter by name:</small>
|
||||
<b-form-input v-model="filterSettings.name" size="sm" placeholder="any name" />
|
||||
<small class="mt-1">Filter by tag:</small>
|
||||
<b-form-input v-model="filterSettings.tag" size="sm" placeholder="any tag" />
|
||||
<small>Search published workflows:</small>
|
||||
<b-form-group class="m-0">
|
||||
<b-form-radio-group
|
||||
v-model="filterSettings.published"
|
||||
:options="options"
|
||||
size="sm"
|
||||
buttons
|
||||
description="filter published" />
|
||||
</b-form-group>
|
||||
<small>Search shared workflows:</small>
|
||||
<b-form-group class="m-0">
|
||||
<b-form-radio-group
|
||||
v-model="filterSettings.shared_with_me"
|
||||
:options="options"
|
||||
size="sm"
|
||||
buttons
|
||||
description="filter shared" />
|
||||
</b-form-group>
|
||||
<small>Search deleted workflows:</small>
|
||||
<b-form-group class="m-0">
|
||||
<b-form-radio-group
|
||||
v-model="filterSettings.deleted"
|
||||
:options="options"
|
||||
size="sm"
|
||||
buttons
|
||||
description="filter deleted" />
|
||||
</b-form-group>
|
||||
<div class="mt-3">
|
||||
<b-button class="mr-1" size="sm" variant="primary" @click="onSearch">
|
||||
<icon icon="search" />
|
||||
<span>{{ _l("Search") }}</span>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,25 +10,28 @@ import { createWhooshQuery, filterTools } from "@/components/Panels/utilities";
|
||||
import { useUserLocalStorage } from "@/composables/userLocalStorage";
|
||||
import { getAppRoot } from "@/onload/loadConfig";
|
||||
|
||||
/* interfaces */
|
||||
export interface Tool {
|
||||
model_class: string;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
labels: string[];
|
||||
edam_operations: string[];
|
||||
edam_topics: string[];
|
||||
hidden: "" | boolean;
|
||||
is_workflow_compatible: boolean;
|
||||
panel_section_name: string;
|
||||
panel_section_id: string;
|
||||
xrefs: string[];
|
||||
config_file: string;
|
||||
link: string;
|
||||
min_width: number;
|
||||
target: string;
|
||||
panel_section_id: string | null;
|
||||
panel_section_name: string | null;
|
||||
form_style: string;
|
||||
text?: string;
|
||||
current_section_ids?: string[]; // for purely setting it = currentPanelView section id
|
||||
title?: string;
|
||||
latest_id?: string;
|
||||
step_count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/* interfaces */
|
||||
export interface ToolSection {
|
||||
model_class: string;
|
||||
id: string;
|
||||
@@ -37,8 +40,8 @@ export interface ToolSection {
|
||||
description?: string;
|
||||
links?: Record<string, string>;
|
||||
tools?: string[];
|
||||
elems?: ToolSection[];
|
||||
panel_labels?: string[];
|
||||
elems?: (Tool | ToolSection)[];
|
||||
panel_labels?: any[];
|
||||
}
|
||||
|
||||
export interface ToolSectionLabel {
|
||||
@@ -50,7 +53,6 @@ export interface ToolSectionLabel {
|
||||
links?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
// TODO: Use this in ToolSearch.vue
|
||||
export interface FilterSettings {
|
||||
[key: string]: any;
|
||||
name?: string;
|
||||
@@ -89,8 +91,7 @@ export const useToolStore = defineStore("toolStore", () => {
|
||||
return toolsById.value;
|
||||
} else {
|
||||
const q = createWhooshQuery(filterSettings);
|
||||
console.log("getToolsById", toolResults.value[q]);
|
||||
return filterTools(toolsById.value, toolResults.value[q]);
|
||||
return filterTools(toolsById.value, toolResults.value[q] || []);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Steps } from "@/stores/workflowStepStore";
|
||||
|
||||
export interface Workflow {
|
||||
name: string;
|
||||
[index: string]: any;
|
||||
id: string;
|
||||
steps: Steps;
|
||||
step_count?: number;
|
||||
latest_id?: string;
|
||||
|
||||
Reference in New Issue
Block a user