diff --git a/client/src/components/Panels/ToolPanel.test.ts b/client/src/components/Panels/ToolPanel.test.ts index 1541fa67be4e..d7775e48cc58 100644 --- a/client/src/components/Panels/ToolPanel.test.ts +++ b/client/src/components/Panels/ToolPanel.test.ts @@ -6,18 +6,32 @@ import MockAdapter from "axios-mock-adapter"; import flushPromises from "flush-promises"; import { createPinia } from "pinia"; import { getLocalVue } from "tests/jest/helpers"; +import { ref } from "vue"; import toolsList from "@/components/ToolsView/testData/toolsList.json"; import toolsListInPanel from "@/components/ToolsView/testData/toolsListInPanel.json"; +import { useUserLocalStorage } from "@/composables/userLocalStorage"; +import { useToolStore } from "@/stores/toolStore"; -import viewsList from "./testData/viewsList.json"; +import viewsListJson from "./testData/viewsList.json"; import { types_to_icons } from "./utilities"; import ToolPanel from "./ToolPanel.vue"; +interface ToolPanelView { + id: string; + model_class: string; + name: string; + description: string | null; + view_type: string; + searchable: boolean; +} + const localVue = getLocalVue(); const TEST_PANELS_URI = "/api/tool_panels"; +const DEFAULT_VIEW_ID = "default"; +const PANEL_VIEW_ERR_MSG = "Error loading panel view"; jest.mock("@/composables/config", () => ({ useConfig: jest.fn(() => ({ @@ -26,16 +40,59 @@ jest.mock("@/composables/config", () => ({ })), })); +jest.mock("@/composables/userLocalStorage", () => ({ + useUserLocalStorage: jest.fn(() => ref(DEFAULT_VIEW_ID)), +})); + describe("ToolPanel", () => { - it("test navigation of tool panel views menu", async () => { + const viewsList = viewsListJson as Record; + + /** Mocks and stores a non-default panel view as the current panel view */ + function storeNonDefaultView() { + // find a view in object viewsList that is not DEFAULT_VIEW_ID + const viewKey = Object.keys(viewsList).find((id) => id !== DEFAULT_VIEW_ID); + if (!viewKey) { + throw new Error("No non-default view found in viewsList"); + } + const view = viewsList[viewKey]; + if (!view) { + throw new Error(`View with key ${viewKey} not found in viewsList`); + } + // ref and useUserLocalStorage are already imported at the top + (useUserLocalStorage as jest.Mock).mockImplementation(() => ref(viewKey)); + return { viewKey, view }; + } + + /** + * Sets up wrapper for ToolPanel component + * @param {String} errorView If provided, we mock an error for this view + * @param {Boolean} failDefault If true and error view is provided, we + * mock an error for the default view as well + * @returns wrapper + */ + async function createWrapper(errorView = "", failDefault = false) { const axiosMock = new MockAdapter(axios); axiosMock - .onGet(/\/api\/tool_panels\/.*/) - .reply(200, toolsListInPanel) .onGet(`/api/tools?in_panel=False`) .replyOnce(200, toolsList) .onGet(TEST_PANELS_URI) - .reply(200, { default_panel_view: "default", views: viewsList }); + .reply(200, { default_panel_view: DEFAULT_VIEW_ID, views: viewsList }); + + if (errorView) { + axiosMock.onGet(`/api/tool_panels/${errorView}`).reply(400, { err_msg: PANEL_VIEW_ERR_MSG }); + if (errorView !== DEFAULT_VIEW_ID && !failDefault) { + axiosMock.onGet(`/api/tool_panels/${DEFAULT_VIEW_ID}`).reply(200, toolsListInPanel); + } else if (failDefault) { + axiosMock.onGet(`/api/tool_panels/${DEFAULT_VIEW_ID}`).reply(400, { err_msg: PANEL_VIEW_ERR_MSG }); + } + } else { + // mock response for all panel views + axiosMock.onGet(/\/api\/tool_panels\/.*/).reply(200, toolsListInPanel); + } + + // setting this because for the default view, we just show "Tools" as the name + // even though the backend returns "Full Tool Panel" + viewsList[DEFAULT_VIEW_ID]!.name = "Tools"; const pinia = createPinia(); const wrapper = mount(ToolPanel as object, { @@ -55,12 +112,17 @@ describe("ToolPanel", () => { await flushPromises(); + return { wrapper }; + } + + it("test navigation of tool panel views menu", async () => { + const { wrapper } = await createWrapper(); // there is a panel view selector initially collapsed expect(wrapper.find(".panel-view-selector").exists()).toBe(true); expect(wrapper.find(".dropdown-menu.show").exists()).toBe(false); // Test: starts up with a default panel view, click to open menu - expect(wrapper.find("#toolbox-heading").text()).toBe("Tools"); + expect(wrapper.find("#toolbox-heading").text()).toBe(viewsList[DEFAULT_VIEW_ID]!.name); await wrapper.find("#toolbox-heading").trigger("click"); await flushPromises(); @@ -75,7 +137,7 @@ describe("ToolPanel", () => { for (const [key, value] of Object.entries(viewsList)) { // find dropdown item const currItem = dropdownMenu.find(`[data-panel-id='${key}']`); - if (key !== "default") { + if (key !== DEFAULT_VIEW_ID) { // Test: check if the panel view has appropriate description const description = currItem.attributes().title || null; expect(description).toBe(value.description); @@ -92,7 +154,7 @@ describe("ToolPanel", () => { expect(panelViewIcon.classes()).toContain( `fa-${types_to_icons[value.view_type as keyof typeof types_to_icons]}` ); - expect(wrapper.find("#toolbox-heading").text()).toBe(value.name); + expect(wrapper.find("#toolbox-heading").text()).toBe(value!.name); } else { // Test: check if the default panel view is already selected, and no icon expect(currItem.find(".fa-check").exists()).toBe(true); @@ -100,4 +162,42 @@ describe("ToolPanel", () => { } } }); + + it("initializes non default current panel view correctly", async () => { + const { viewKey, view } = storeNonDefaultView(); + + const { wrapper } = await createWrapper(); + + // starts up with a non default panel view + expect(wrapper.find("#toolbox-heading").text()).toBe(view!.name); + const toolStore = useToolStore(); + expect(toolStore.currentPanelView).toBe(viewKey); + }); + + it("changes panel to default if current panel view throws error", async () => { + const { viewKey, view } = storeNonDefaultView(); + + const { wrapper } = await createWrapper(viewKey); + + // does not initialize non default panel view, and changes to default + expect(wrapper.find("#toolbox-heading").text()).not.toBe(view!.name); + expect(wrapper.find("#toolbox-heading").text()).toBe(viewsList[DEFAULT_VIEW_ID]!.name); + const toolStore = useToolStore(); + expect(toolStore.currentPanelView).toBe(DEFAULT_VIEW_ID); + + // toolbox loaded + expect(wrapper.find('[data-description="panel toolbox"]').exists()).toBe(true); + }); + + it("simply shows error if even default panel view throws error", async () => { + const { viewKey } = storeNonDefaultView(); + + const { wrapper } = await createWrapper(viewKey, true); + + // toolbox not loaded + expect(wrapper.find('[data-description="panel toolbox"]').exists()).toBe(false); + + // error message shown + expect(wrapper.find('[data-description="tool panel error message"]').text()).toBe(PANEL_VIEW_ERR_MSG); + }); }); diff --git a/client/src/components/Panels/ToolPanel.vue b/client/src/components/Panels/ToolPanel.vue index 55bc38f28694..cf0fa3f4fcd5 100644 --- a/client/src/components/Panels/ToolPanel.vue +++ b/client/src/components/Panels/ToolPanel.vue @@ -2,10 +2,11 @@ import { faCaretDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { storeToRefs } from "pinia"; -import { computed, onMounted, ref, watch } from "vue"; +import { computed, ref, watch } from "vue"; import { useToolStore } from "@/stores/toolStore"; import localize from "@/utils/localization"; +import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error"; import { types_to_icons } from "./utilities"; @@ -36,17 +37,20 @@ const { currentPanelView, defaultPanelView, isPanelPopulated, loading, panel, pa const loadingView = ref(undefined); const query = ref(""); const showAdvanced = ref(false); +const errorMessage = ref(undefined); -onMounted(async () => { +initializeToolPanel(); +async function initializeToolPanel() { try { await toolStore.fetchPanelViews(); await initializeTools(); } catch (error) { console.error(error); + errorMessage.value = errorMessageAsString(error); } finally { arePanelsFetched.value = true; } -}); +} watch( () => currentPanelView.value, @@ -117,6 +121,8 @@ async function initializeTools() { await toolStore.initCurrentPanelView(defaultPanelView.value); } catch (error: any) { console.error("ToolPanel - Intialize error:", error); + errorMessage.value = errorMessageAsString(error); + rethrowSimple(error); } } @@ -204,6 +210,11 @@ watch( @onInsertModule="onInsertModule" @onInsertWorkflow="onInsertWorkflow" @onInsertWorkflowSteps="onInsertWorkflowSteps" /> +
+ + {{ errorMessage }} + +
diff --git a/client/src/stores/toolStore.ts b/client/src/stores/toolStore.ts index e0e2b1439c6f..a7214b1aabe1 100644 --- a/client/src/stores/toolStore.ts +++ b/client/src/stores/toolStore.ts @@ -158,6 +158,7 @@ export const useToolStore = defineStore("toolStore", () => { } async function fetchTools(filterSettings?: FilterSettings) { + // This is if we are performing a backend search if (filterSettings && Object.keys(filterSettings).length !== 0) { // Parsing filterSettings to Whoosh query const q = createWhooshQuery(filterSettings); @@ -165,21 +166,25 @@ export const useToolStore = defineStore("toolStore", () => { if (toolResults.value[q]) { return; } - const { data } = await axios.get(`${getAppRoot()}api/tools`, { params: { q } }); - saveToolResults(q, data); + try { + const { data } = await axios.get(`${getAppRoot()}api/tools`, { params: { q } }); + saveToolResults(q, data); + } catch (e) { + rethrowSimple(e); + } } + + // This is if we are fetching all tools by ids if (!loading.value && !allToolsByIdFetched.value) { loading.value = true; - await axios - .get(`${getAppRoot()}api/tools?in_panel=False`) - .then(({ data }) => { - saveAllTools(data as Tool[]); - loading.value = false; - }) - .catch((error) => { - console.error(error); - loading.value = false; - }); + try { + const { data } = await axios.get(`${getAppRoot()}api/tools?in_panel=False`); + saveAllTools(data as Tool[]); + } catch (e) { + rethrowSimple(e); + } finally { + loading.value = false; + } } } @@ -204,23 +209,24 @@ export const useToolStore = defineStore("toolStore", () => { async function initCurrentPanelView(siteDefaultPanelView: string) { if (!loading.value && !isPanelPopulated.value) { loading.value = true; - const panelView = currentPanelView.value || siteDefaultPanelView; - if (currentPanelView.value == "") { - currentPanelView.value = panelView; + currentPanelView.value = currentPanelView.value || siteDefaultPanelView; + try { + if (!currentPanelView.value) { + throw new Error("No valid panel view found."); + } + const { data } = await axios.get(`${getAppRoot()}api/tool_panels/${currentPanelView.value}`); + savePanelView(currentPanelView.value, data); + loading.value = false; + } catch (e) { + loading.value = false; + + if (currentPanelView.value !== siteDefaultPanelView) { + // If the stored panelView failed to load, try the default panel for this site. + await setCurrentPanelView(siteDefaultPanelView); + } else { + rethrowSimple(e); + } } - await axios - .get(`${getAppRoot()}api/tool_panels/${panelView}`) - .then(({ data }) => { - loading.value = false; - savePanelView(panelView, data); - }) - .catch(async (error) => { - loading.value = false; - if (error.response && error.response.status == 400) { - // Assume the stored panelView disappeared, revert to the panel default for this site. - await setCurrentPanelView(siteDefaultPanelView); - } - }); } } @@ -235,18 +241,21 @@ export const useToolStore = defineStore("toolStore", () => { const { data } = await axios.get(`${getAppRoot()}api/tool_panels/${panelView}`); currentPanelView.value = panelView; savePanelView(panelView, data); - loading.value = false; } catch (e) { - const error = e as { response: { data: { err_msg: string } } }; - console.error("Could not change panel view", error.response.data.err_msg ?? error.response); + rethrowSimple(e); + } finally { loading.value = false; } } } async function fetchPanel(panelView: string) { - const { data } = await axios.get(`${getAppRoot()}api/tool_panels/${panelView}`); - savePanelView(panelView, data); + try { + const { data } = await axios.get(`${getAppRoot()}api/tool_panels/${panelView}`); + savePanelView(panelView, data); + } catch (e) { + rethrowSimple(e); + } } function saveToolForId(toolId: string, toolData: Tool) { diff --git a/lib/galaxy/util/config_templates.py b/lib/galaxy/util/config_templates.py index 7f09b3fec667..f193d762d20e 100644 --- a/lib/galaxy/util/config_templates.py +++ b/lib/galaxy/util/config_templates.py @@ -66,7 +66,7 @@ class StrictModel(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True) class BaseTemplateVariable(StrictModel):