Merge pull request #14391 from ahmedhamidawan/Impr_tool_search

Feature: Advanced Tool Search Menu
This commit is contained in:
Dannon
2022-09-08 21:31:41 -04:00
committed by GitHub
13 changed files with 536 additions and 17 deletions
@@ -6,15 +6,26 @@
class="search-query"
size="sm"
autocomplete="off"
:disabled="includeAdvBtn && showAdvanced"
:placeholder="placeholder"
@input="delayQuery"
@change="setQuery"
@keydown.esc="setQuery('')" />
<b-input-group-append>
<b-button
v-if="includeAdvBtn"
size="sm"
:pressed="showAdvanced"
:variant="showAdvanced ? 'info' : 'secondary'"
@click="onToggle">
<icon v-if="showAdvanced" icon="angle-double-up" />
<icon v-else icon="angle-double-down" />
</b-button>
<b-button
class="search-clear"
size="sm"
:title="titleClear | l"
:disabled="includeAdvBtn && showAdvanced"
data-description="reset query"
@click="clearBox">
<icon v-if="loading" icon="spinner" spin />
@@ -42,6 +53,14 @@ export default {
type: Number,
default: 1000,
},
includeAdvBtn: {
type: Boolean,
default: false,
},
showAdvanced: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -83,6 +102,9 @@ export default {
this.setQuery("");
this.$refs.toolInput.focus();
},
onToggle() {
this.$emit("onToggle", !this.showAdvanced);
},
},
};
</script>
@@ -0,0 +1,22 @@
<template>
<b-button
v-if="offset > 200"
v-b-tooltip.noninteractive.hover
class="ui-btn-back-to-top btn-circle"
title="Scroll To Top"
variant="info"
@click="$emit('click')">
<i class="fa fa-arrow-up" />
</b-button>
</template>
<script>
export default {
props: {
offset: {
type: Number,
default: 0,
},
},
};
</script>
@@ -0,0 +1,106 @@
<template>
<ToolsProvider v-slot="{ loading, result: itemsLoaded }" :filter-settings="filterSettings">
<section class="overflow-auto h-100" @scroll="onScroll">
<div class="mb-2">
<span class="row mb-1">
<span class="col">
<h4 class="d-inline-block">Advanced Tool Search Results</h4>
</span>
</span>
<span v-if="itemsLoaded.length !== 0 && hasFilters" class="row">
<span class="col d-inline-block">
Found {{ itemsLoaded.length }} tools for
<a id="popover-filters" href="javascript:void(0)">filters</a>.
<b-popover target="popover-filters" triggers="hover" placement="top">
<template v-slot:title>Filters</template>
<div v-for="(value, filter) in filterSettings" :key="filter">
<b>{{ filter }}</b
>: {{ value }}
</div>
</b-popover>
</span>
</span>
</div>
<div>
<b-alert v-if="loading" class="m-2" variant="info" show>
<LoadingSpan message="Loading Advanced Search Results" />
</b-alert>
<b-alert v-else-if="!hasFilters" class="m-2" variant="secondary" show>
Please add filters to the menu in the Tool Panel.
</b-alert>
<b-alert v-else-if="!itemsLoaded || itemsLoaded.length == 0" class="m-2" variant="info" show>
No tools found for the entered filters.
</b-alert>
<div v-else>
<ToolAdvancedSearchResults :tools="itemsLoaded" />
</div>
</div>
<ScrollToTopButton :offset="offset" @click="scrollToTop" />
</section>
</ToolsProvider>
</template>
<script>
import LoadingSpan from "components/LoadingSpan";
import { ToolsProvider } from "components/providers/storeProviders";
import ToolAdvancedSearchResults from "./ToolAdvancedSearchResults";
import ScrollToTopButton from "./ScrollToTopButton";
export default {
components: {
LoadingSpan,
ToolAdvancedSearchResults,
ToolsProvider,
ScrollToTopButton,
},
props: {
name: {
type: String,
default: "",
},
panelSectionName: {
type: String,
default: "",
},
id: {
type: String,
default: "",
},
description: {
type: String,
default: "",
},
},
data() {
return {
offset: 0,
};
},
computed: {
filterSettings() {
const newFilterSettings = {};
Object.entries(this.$props).forEach(([filter, value]) => {
if (value && value !== "") {
if (filter === "panelSectionName") {
newFilterSettings["panel_section_name"] = value;
} else {
newFilterSettings[filter] = value;
}
}
});
return newFilterSettings;
},
hasFilters() {
return Object.keys(this.filterSettings).length;
},
},
methods: {
onScroll(e) {
this.offset = e.target.scrollTop;
},
scrollToTop() {
this.$el.scrollTop = 0;
},
},
};
</script>
@@ -0,0 +1,185 @@
<template>
<div v-infinite-scroll="loadMore" infinite-scroll-disabled="busy">
<b-overlay :show="busy" opacity="0.5">
<b-table ref="toolTable" striped bordered :fields="fields" :items="buffer">
<template v-slot:cell(name)="row">
<span v-if="!row.item.help_text"
><b>{{ row.item.name }}</b> {{ row.item.description }}</span
>
<span v-else>
<b-link href="javascript:void(0)" role="button" @click="toggleHelp(row)">
<b>{{ row.item.name }}</b> {{ row.item.description }}
</b-link>
<p v-if="!row.item._showDetails && row.item.summary" v-html="row.item.summary" />
</span>
</template>
<template v-slot:row-details="row">
<b-card v-if="row.item.help_text">
<p class="mb-1" v-html="row.item.help_text" />
<a
:href="row.item.target === 'galaxy_main' ? 'javascript:void(0)' : row.item.link"
@click.stop="onOpen(row.item)">
Click here to open the tool
</a>
</b-card>
</template>
<template v-slot:cell(section)="row">
{{ row.item.panel_section_name }}
</template>
<template v-slot:cell(workflow)="row">
<span
v-if="row.item.is_workflow_compatible"
v-b-tooltip.hover
class="fa fa-check text-success"
title="Is Workflow Compatible" />
<span v-else v-b-tooltip.hover class="fa fa-times text-danger" title="Not Workflow Compatible" />
</template>
<template v-slot:cell(target)="row">
<span
v-if="row.item.target === 'galaxy_main'"
v-b-tooltip.hover
class="fa fa-check text-success"
title="Is Local" />
<span v-else v-b-tooltip.hover class="fa fa-times text-danger" title="Not Local" />
</template>
<template v-slot:cell(open)="row">
<b-button
v-b-tooltip.hover.top
:title="'Open Tool' | localize"
class="fa fa-play"
size="sm"
variant="primary"
:href="row.item.target === 'galaxy_main' ? 'javascript:void(0)' : row.item.link"
@click.stop="onOpen(row.item)" />
</template>
</b-table>
</b-overlay>
</div>
</template>
<script>
const loadTimeout = 100;
const defaultBufferLen = 4;
import _l from "utils/localization";
import infiniteScroll from "vue-infinite-scroll";
import { openGlobalUploadModal } from "components/Upload";
import { fetchData } from "./services";
export default {
directives: { infiniteScroll },
props: {
tools: {
type: Array,
default: null,
},
},
data() {
return {
buffer: {},
bufferLen: defaultBufferLen,
busy: false,
fields: [
{
key: "name",
label: _l("Name"),
sortable: true,
},
{
key: "section",
label: _l("Section"),
sortable: true,
},
{
key: "workflow",
label: _l("Workflow Compatible"),
sortable: false,
},
{
key: "target",
label: _l("Local Tool"),
sortable: false,
},
{
key: "open",
label: "",
},
],
};
},
created() {
const initData = { summary: "", help_text: "", _showDetails: false, is_fetched: false };
this.buffer = this.tools.slice(0, this.bufferLen);
this.buffer.forEach((tool) => Object.assign(tool, { ...initData }));
this.buffer.forEach(async (tool, index) => {
await this.fetchHelp(index);
});
},
methods: {
onOpen(tool) {
if (tool.id === "upload1") {
openGlobalUploadModal();
} else if (tool.form_style === "regular") {
// encode spaces in tool.id
const toolId = tool.id;
const toolVersion = tool.version;
this.$router.push({ path: `/?tool_id=${encodeURIComponent(toolId)}&version=${toolVersion}` });
}
},
async loadMore() {
if (this.buffer.length < this.tools.length) {
this.busy = true;
setTimeout(() => {
this.bufferLen += defaultBufferLen;
this.buffer = this.tools.slice(0, this.bufferLen);
this.buffer.forEach(async (tool, index) => {
await this.fetchHelp(index);
});
this.busy = false;
}, loadTimeout);
}
},
toggleHelp(row) {
row.toggleDetails();
this.$refs.toolTable.refresh();
},
async fetchHelp(index) {
const id = this.buffer[index]["id"];
if (!this.buffer[index]["is_fetched"]) {
this.buffer[index]["is_fetched"] = true; // tags the tool's help as being fetched
await fetchData(`api/tools/${id}/build`).then((response) => {
if (response.help && response.help != "\n") {
this.buffer[index]["help_text"] = response.help;
this.buffer[index]["summary"] = this.parseHelp(response.help);
} else {
this.buffer[index]["help_text"] = ""; // for cases where helpText == '\n'
}
});
this.$refs.toolTable.refresh();
}
},
parseHelp(help) {
let summary = "";
const parser = new DOMParser();
const helpDoc = parser.parseFromString(help, "text/html");
const xpaths = [
"//strong[text()='What it does']/../following-sibling::*",
"//strong[text()='What It Does']/../following-sibling::*",
"//h1[text()='Synopsis']/following-sibling::*",
"//strong[text()='Syntax']/../following-sibling::*",
];
const matches = [];
xpaths.forEach((xpath) => {
matches.push(
helpDoc.evaluate(xpath, helpDoc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
);
});
matches.forEach((match) => {
if (match) {
summary += match.innerHTML + "\n";
}
});
return summary;
},
},
};
</script>
@@ -0,0 +1,52 @@
import { mount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import ToolSearch from "./ToolSearch";
const localVue = getLocalVue();
describe("ToolSearch", () => {
it("test tools advanced filter panel", async () => {
const wrapper = mount(ToolSearch, {
propsData: {
currentPanelView: "default",
showAdvanced: false,
},
localVue,
stubs: {
icon: { template: "<div></div>" },
},
});
expect(wrapper.find("[description='advanced tool filters']").exists()).toBe(false);
await wrapper.setProps({ showAdvanced: true });
expect(wrapper.find("[description='advanced tool filters']").exists()).toBe(true);
const filterInputs = {
"[placeholder='any tool name']": "name-filter",
"[placeholder='any section']": "section-filter",
"[placeholder='any id']": "id-filter",
"[placeholder='any description']": "desc-filter",
};
// Now add filters in all input fields in the advanced menu
Object.entries(filterInputs).forEach(([selector, value]) => {
const filterInput = wrapper.find(selector);
if (filterInput.vm && filterInput.props().type == "text") {
filterInput.setValue(value);
}
});
// Test: values are stored in the filterSettings object
expect(Object.values(wrapper.vm.filterSettings)).toEqual(Object.values(filterInputs));
// Test: clearing the filters
const clearButton = wrapper.find("[description='clear filters']");
expect(Object.values(wrapper.vm.filterSettings).length).toBe(4);
await clearButton.trigger("click");
expect(Object.values(wrapper.vm.filterSettings).length).toBe(0);
// Test: keyup.esc (should toggle the view out)
const nameField = wrapper.find("[placeholder='any tool name']");
expect(wrapper.emitted()["update:show-advanced"]).toBeUndefined();
await nameField.trigger("keyup.esc");
expect(wrapper.emitted()["update:show-advanced"].length - 1).toBeFalsy();
});
});
@@ -1,5 +1,40 @@
<template>
<DelayedInput class="mb-3" :query="query" :loading="loading" :placeholder="placeholder" @change="checkQuery" />
<div>
<DelayedInput
:class="!showAdvanced && 'mb-3'"
:query="query"
:loading="loading"
:show-advanced="showAdvanced"
include-adv-btn
:placeholder="placeholder"
@change="checkQuery"
@onToggle="onToggle" />
<div
v-if="showAdvanced"
class="mt-2"
description="advanced tool filters"
@keyup.enter="onSearch"
@keyup.esc="onToggle(false)">
<small>Filter by tool name:</small>
<b-form-input v-model="filterSettings['name']" size="sm" placeholder="any tool name" />
<small class="mt-1">Filter by section:</small>
<b-form-input v-model="filterSettings['panelSectionName']" size="sm" placeholder="any section" />
<small class="mt-1">Filter by id:</small>
<b-form-input v-model="filterSettings['id']" size="sm" placeholder="any id" />
<small class="mt-1">Filter by description:</small>
<b-form-input v-model="filterSettings['description']" size="sm" placeholder="any description" />
<div class="mt-3">
<b-button class="mr-1" size="sm" variant="primary" @click="onSearch">
<icon icon="search" />
<span>{{ "Search" | localize }}</span>
</b-button>
<b-button size="sm" description="clear filters" @click="filterSettings = {}">
<icon icon="redo" />
<span>{{ "Clear" | localize }}</span>
</b-button>
</div>
</div>
</div>
</template>
<script>
@@ -26,12 +61,17 @@ export default {
type: String,
default: null,
},
showAdvanced: {
type: Boolean,
default: false,
},
},
data() {
return {
favorites: ["#favs", "#favorites", "#favourites"],
minQueryLength: 3,
loading: false,
filterSettings: {},
};
},
computed: {
@@ -65,6 +105,17 @@ export default {
this.$emit("onResults", null);
}
},
onSearch() {
for (const [filter, value] of Object.entries(this.filterSettings)) {
if (!value) {
delete this.filterSettings[filter];
}
}
this.$router.push({ path: "/tools/advanced_search", query: this.filterSettings });
},
onToggle(toggleAdvanced) {
this.$emit("update:show-advanced", toggleAdvanced);
},
},
};
</script>
@@ -0,0 +1,12 @@
import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";
export async function fetchData(url) {
try {
const { data } = await axios.get(`${getAppRoot()}${url}`);
return data;
} catch (e) {
rethrowSimple(e);
}
}
+21 -16
View File
@@ -3,8 +3,9 @@
<div unselectable="on">
<div class="unified-panel-header-inner">
<nav class="d-flex justify-content-between mx-3 my-2">
<h4 v-localize class="m-1">Tools</h4>
<div class="panel-header-buttons">
<h4 v-if="!showAdvanced" v-localize class="m-1">Tools</h4>
<h4 v-else v-localize class="m-1">Advanced Tool Search</h4>
<div v-if="!showAdvanced" class="panel-header-buttons">
<b-button-group>
<favorites-button :query="query" @onFavorites="onQuery" />
<panel-view-button
@@ -21,24 +22,27 @@
<tool-search
:current-panel-view="currentPanelView"
:placeholder="titleSearchTools"
:show-advanced.sync="showAdvanced"
:query="query"
@onQuery="onQuery"
@onResults="onResults" />
<upload-button />
<div v-if="hasResults" class="pb-2">
<b-button size="sm" class="w-100" @click="onToggle">
<span :class="buttonIcon" />
<span class="mr-1">{{ buttonText }}</span>
</b-button>
</div>
<div v-else-if="queryTooShort" class="pb-2">
<b-badge class="alert-danger w-100">Search string too short!</b-badge>
</div>
<div v-else-if="queryFinished" class="pb-2">
<b-badge class="alert-danger w-100">No results found!</b-badge>
</div>
<section v-if="!showAdvanced">
<upload-button />
<div v-if="hasResults" class="pb-2">
<b-button size="sm" class="w-100" @click="onToggle">
<span :class="buttonIcon" />
<span class="mr-1">{{ buttonText }}</span>
</b-button>
</div>
<div v-else-if="queryTooShort" class="pb-2">
<b-badge class="alert-danger w-100">Search string too short!</b-badge>
</div>
<div v-else-if="queryFinished" class="pb-2">
<b-badge class="alert-danger w-100">No results found!</b-badge>
</div>
</section>
</div>
<div class="unified-panel-body">
<div v-if="!showAdvanced" class="unified-panel-body">
<div class="toolMenuContainer">
<div class="toolMenu">
<tool-section
@@ -106,6 +110,7 @@ export default {
queryFilter: null,
queryPending: false,
showSections: false,
showAdvanced: false,
buttonText: "",
buttonIcon: "",
titleSearchTools: _l("search tools"),
@@ -190,3 +190,4 @@ export const StoreProvider = (storeAction, storeGetter, storeCountGetter = undef
export const DatasetProvider = StoreProvider("fetchDataset", "getDataset");
export const CollectionElementsProvider = StoreProvider("fetchCollectionElements", "getCollectionElements");
export const HistoryItemsProvider = StoreProvider("fetchHistoryItems", "getHistoryItems", "getTotalMatchesCount");
export const ToolsProvider = StoreProvider("fetchAllTools", "getTools");
+10
View File
@@ -37,6 +37,7 @@ import NewUserConfirmation from "components/login/NewUserConfirmation";
import NewUserWelcome from "components/NewUserWelcome/NewUserWelcome";
import Sharing from "components/Sharing/Sharing";
import StoredWorkflowInvocations from "components/Workflow/StoredWorkflowInvocations";
import ToolAdvancedSearch from "components/Panels/Common/ToolAdvancedSearch";
import ToolsJson from "components/ToolsView/ToolsSchemaJson/ToolsJson";
import ToolsView from "components/ToolsView/ToolsView";
import TourList from "components/Tour/TourList";
@@ -276,6 +277,15 @@ export function getRouter(Galaxy) {
component: TourRunner,
props: true,
},
{
path: "tools/advanced_search",
component: ToolAdvancedSearch,
props: (route) => {
return {
...route.query,
};
},
},
{
path: "tools/view",
component: ToolsView,
+43
View File
@@ -1,5 +1,6 @@
export const state = {
toolById: {},
toolsList: [],
};
import Vue from "vue";
@@ -18,6 +19,36 @@ const getters = {
return "...";
}
},
getTools:
(state) =>
({ filterSettings }) => {
// if no filters
if (Object.keys(filterSettings).length == 0) {
return [];
}
const allTools = state.toolsList;
const returnedTools = [];
for (const tool in allTools) {
let hasMatches = false;
for (const [key, filterValue] of Object.entries(filterSettings)) {
const actualValue = allTools[tool][key];
if (filterValue) {
if (!actualValue || !actualValue.toUpperCase().match(filterValue.toUpperCase())) {
hasMatches = false;
break;
} else {
hasMatches = true;
}
}
}
if (hasMatches) {
returnedTools.push(allTools[tool]);
}
}
return returnedTools;
},
};
const actions = {
@@ -26,12 +57,24 @@ const actions = {
const { data } = await axios.get(`${getAppRoot()}api/tools/${toolId}`);
commit("saveToolForId", { toolId, toolData: data });
},
fetchAllTools: async ({ state, commit }) => {
// Preventing store from being populated for every search: we fetch again only if:
// store isn't already populated (initial fetch)
if (state.toolsList.length === 0) {
console.log("fetching all tools once");
const { data } = await axios.get(`${getAppRoot()}api/tools?in_panel=False`);
commit("saveTools", { toolsData: data });
}
},
};
const mutations = {
saveToolForId: (state, { toolId, toolData }) => {
Vue.set(state.toolById, toolId, toolData);
},
saveTools: (state, { toolsData }) => {
state.toolsList = toolsData;
},
};
export const toolStore = {
+9
View File
@@ -102,6 +102,15 @@ $ui-margin-horizontal-large: $margin-v * 2;
cursor: pointer;
}
.ui-btn-back-to-top {
bottom: 2em;
position: fixed;
opacity: 0.5;
}
.ui-btn-back-to-top:hover {
opacity: 1;
}
// thumbnails
.ui-thumbnails {
.ui-thumbnails-item {
+1
View File
@@ -211,6 +211,7 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs):
webapp.add_client_route("/datatypes")
webapp.add_client_route("/login/start")
webapp.add_client_route("/login/confirm")
webapp.add_client_route("/tools/advanced_search")
webapp.add_client_route("/tools/view")
webapp.add_client_route("/tools/json")
webapp.add_client_route("/tours")