From 5ebece7168e2e392529d34ddd56499857c4c0253 Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Mon, 20 Jan 2025 13:17:56 -0600 Subject: [PATCH] [24.1] Catch errors in `toolStore` and `ToolPanel` Fixes https://github.com/galaxyproject/galaxy/issues/19422 --- .../src/components/Panels/ToolPanel.test.js | 99 +++++++++++++++++-- client/src/components/Panels/ToolPanel.vue | 17 +++- client/src/stores/toolStore.ts | 75 +++++++------- 3 files changed, 149 insertions(+), 42 deletions(-) diff --git a/client/src/components/Panels/ToolPanel.test.js b/client/src/components/Panels/ToolPanel.test.js index 7b5575f251c9..c347b7ff7348 100644 --- a/client/src/components/Panels/ToolPanel.test.js +++ b/client/src/components/Panels/ToolPanel.test.js @@ -10,6 +10,7 @@ import { createPinia } from "pinia"; import { getLocalVue } from "tests/jest/helpers"; import { useConfig } from "@/composables/config"; +import { useToolStore } from "@/stores/toolStore"; import viewsList from "./testData/viewsList"; import ToolPanel from "./ToolPanel"; @@ -18,6 +19,8 @@ import { types_to_icons } from "./utilities"; 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.mockReturnValue({ @@ -25,16 +28,57 @@ useConfig.mockReturnValue({ isConfigLoaded: true, }); +jest.mock("@/composables/userLocalStorage", () => { + const { ref } = require("vue"); + return { + useUserLocalStorage: jest.fn(() => ref(DEFAULT_VIEW_ID)), + }; +}); + describe("ToolPanel", () => { - it("test navigation of tool panel views menu", async () => { + /** 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); + const view = viewsList[viewKey]; + + // mock pre set current panel view to be the non-default view + const { ref } = require("vue"); + const { useUserLocalStorage } = require("@/composables/userLocalStorage"); + useUserLocalStorage.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, { @@ -54,12 +98,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(); @@ -74,7 +123,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); @@ -97,4 +146,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 a478b08f7c7a..01129f389938 100644 --- a/client/src/components/Panels/ToolPanel.vue +++ b/client/src/components/Panels/ToolPanel.vue @@ -3,10 +3,11 @@ import { library } from "@fortawesome/fontawesome-svg-core"; 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"; @@ -40,17 +41,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, @@ -106,6 +110,8 @@ async function initializeTools() { await toolStore.initCurrentPanelView(defaultPanelView.value); } catch (error: any) { console.error("ToolPanel - Intialize error:", error); + errorMessage.value = errorMessageAsString(error); + rethrowSimple(error); } } @@ -187,6 +193,11 @@ function onInsertWorkflowSteps(workflowId: string, workflowStepCount: number | u @onInsertModule="onInsertModule" @onInsertWorkflow="onInsertWorkflow" @onInsertWorkflowSteps="onInsertWorkflowSteps" /> +
+ + {{ errorMessage }} + +
diff --git a/client/src/stores/toolStore.ts b/client/src/stores/toolStore.ts index c653a0bdac64..4f64bd91efea 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) {