diff --git a/client/src/api/tools.ts b/client/src/api/tools.ts index 7ef06925577..16cd60c4591 100644 --- a/client/src/api/tools.ts +++ b/client/src/api/tools.ts @@ -1,5 +1,6 @@ import { type components, GalaxyApi } from "@/api"; import { ERROR_STATES, type ShowFullJobResponse } from "@/api/jobs"; +import type { Tool, ToolPanelItem, ToolSection, ToolSectionLabel } from "@/stores/toolStore"; import { errorMessageAsString } from "@/utils/simple-error"; export type HdcaUploadTarget = components["schemas"]["HdcaDataItemsTarget"]; @@ -130,3 +131,17 @@ export function fetchJobErrorMessage(jobDetails: ShowFullJobResponse): string | } return errorMessage; } + +// TODO: Once the backend models are typed, make sure these type guards are correct. +export function isTool(section: ToolPanelItem): section is Tool { + return !isToolSection(section) && !isToolSectionLabel(section); +} +export function isToolSection(section: ToolPanelItem): section is ToolSection { + return (section as ToolSection).tools !== undefined || (section as ToolSection).elems !== undefined; +} +export function isToolSectionLabel(section: ToolPanelItem): section is ToolSectionLabel { + return ( + (section as ToolSectionLabel).text !== undefined && + (section as ToolSectionLabel).model_class === "ToolSectionLabel" + ); +} diff --git a/client/src/components/BaseComponents/Form/GFormInput.vue b/client/src/components/BaseComponents/Form/GFormInput.vue index e810350c1c1..2df280c0952 100644 --- a/client/src/components/BaseComponents/Form/GFormInput.vue +++ b/client/src/components/BaseComponents/Form/GFormInput.vue @@ -1,5 +1,5 @@ - emit('keydown', event)" /> + emit('keydown', event)" /> diff --git a/client/src/components/Panels/Common/ToolPanelLabel.vue b/client/src/components/Panels/Common/ToolPanelLabel.vue index 832713bdcaf..f1c5829aded 100644 --- a/client/src/components/Panels/Common/ToolPanelLabel.vue +++ b/client/src/components/Panels/Common/ToolPanelLabel.vue @@ -1,25 +1,102 @@ - - {{ definition.text }} - + + + + + {{ definition.text }} + + + + + diff --git a/client/src/components/Panels/Common/ToolPanelLinks.vue b/client/src/components/Panels/Common/ToolPanelLinks.vue index 4c95bebea58..e48798e95e3 100644 --- a/client/src/components/Panels/Common/ToolPanelLinks.vue +++ b/client/src/components/Panels/Common/ToolPanelLinks.vue @@ -1,3 +1,18 @@ + + @@ -6,29 +21,3 @@ - - diff --git a/client/src/components/Panels/Common/ToolSearch.vue b/client/src/components/Panels/Common/ToolSearch.vue index 4401b3e6cc2..a2e124b312b 100644 --- a/client/src/components/Panels/Common/ToolSearch.vue +++ b/client/src/components/Panels/Common/ToolSearch.vue @@ -5,7 +5,7 @@ import { nextTick } from "vue"; import { onMounted, onUnmounted, type PropType, watch } from "vue"; import { FAVORITES_KEYS, searchTools } from "@/components/Panels/utilities"; -import { type Tool, type ToolSection, useToolStore } from "@/stores/toolStore"; +import { type Tool, type ToolPanelItem, type ToolSection, useToolStore } from "@/stores/toolStore"; import { useUserStore } from "@/stores/userStore"; import _l from "@/utils/localization"; @@ -36,7 +36,7 @@ const props = defineProps({ required: true, }, currentPanel: { - type: Object as PropType>, + type: Object as PropType>, required: true, }, useWorker: { @@ -62,7 +62,7 @@ const { searchWorker } = storeToRefs(toolStore); interface RequestPayload { tools: Tool[]; query: string; - currentPanel: Record; + currentPanel: Record; } interface SearchEventQuery { diff --git a/client/src/components/Panels/Common/ToolSection.test.js b/client/src/components/Panels/Common/ToolSection.test.ts similarity index 54% rename from client/src/components/Panels/Common/ToolSection.test.js rename to client/src/components/Panels/Common/ToolSection.test.ts index c7b3431d011..c523e5946fa 100644 --- a/client/src/components/Panels/Common/ToolSection.test.js +++ b/client/src/components/Panels/Common/ToolSection.test.ts @@ -1,28 +1,30 @@ import { getLocalVue } from "@tests/vitest/helpers"; -import { mount } from "@vue/test-utils"; +import { mount, type Wrapper } from "@vue/test-utils"; import { createPinia } from "pinia"; import { describe, expect, test, vi } from "vitest"; +import { ref } from "vue"; -import { setMockConfig } from "@/composables/__mocks__/config"; +import type { Tool, ToolSection as ToolSectionType, ToolSectionLabel } from "@/stores/toolStore"; import ToolSection from "./ToolSection.vue"; -vi.mock("@/composables/config"); - -setMockConfig({ - toolbox_auto_sort: true, -}); +vi.mock("@/composables/config", () => ({ + useConfig: vi.fn(() => ({ + config: ref({ toolbox_auto_sort: true }), + isConfigLoaded: ref(true), + })), +})); const localVue = getLocalVue(); const pinia = createPinia(); -function sectionIsOpened(wrapper) { +function sectionIsOpened(wrapper: Wrapper) { return wrapper.find("[data-description='opened tool panel section']").exists(); } describe("ToolSection", () => { test("test tool section", () => { - const wrapper = mount(ToolSection, { + const wrapper = mount(ToolSection as object, { propsData: { category: { name: "name", @@ -38,7 +40,7 @@ describe("ToolSection", () => { }); test("test tool section title", async () => { - const wrapper = mount(ToolSection, { + const wrapper = mount(ToolSection as object, { propsData: { category: { title: "tool_section", @@ -47,6 +49,8 @@ describe("ToolSection", () => { name: "name", }, { + model_class: "ToolSectionLabel", + id: "label", text: "text", }, ], @@ -68,7 +72,7 @@ describe("ToolSection", () => { }); test("test tool slider state", async () => { - const wrapper = mount(ToolSection, { + const wrapper = mount(ToolSection as object, { propsData: { category: { title: "tool_section", @@ -77,6 +81,8 @@ describe("ToolSection", () => { name: "name", }, { + model_class: "ToolSectionLabel", + id: "label", text: "text", }, ], @@ -104,3 +110,55 @@ describe("ToolSection", () => { expect(sectionIsOpened(wrapper)).toBe(false); }); }); + +describe("ToolSection element ordering", () => { + function mountSection( + elems: (ToolSectionType | ToolSectionLabel | Tool)[], + propsOverrides: { sortItems?: boolean } = {}, + ) { + return mount(ToolSection as object, { + propsData: { + category: { + title: "test_section", + elems, + }, + ...propsOverrides, + }, + localVue, + pinia, + }); + } + + const tools = [ + { id: "z_tool", name: "Zebra" }, + { id: "a_tool", name: "Apple" }, + { id: "m_tool", name: "Mango" }, + ] as Tool[]; + + function getRenderedToolIds(wrapper: Wrapper) { + return wrapper.findAll("[data-tool-id]").wrappers.map((w) => w.attributes("data-tool-id")); + } + + test("renders tools alphabetically by default", async () => { + const wrapper = mountSection(tools); + await wrapper.find(".name").trigger("click"); + expect(getRenderedToolIds(wrapper)).toEqual(["a_tool", "m_tool", "z_tool"]); + }); + + test("preserves original order when sortItems is false", async () => { + const wrapper = mountSection(tools, { sortItems: false }); + await wrapper.find(".name").trigger("click"); + expect(getRenderedToolIds(wrapper)).toEqual(["z_tool", "a_tool", "m_tool"]); + }); + + test("does not render ToolSectionLabels as tools", async () => { + const elemsWithLabel = [ + { id: "z_tool", name: "Zebra" }, + { model_class: "ToolSectionLabel", id: "label_1", text: "A Label" }, + { id: "a_tool", name: "Apple" }, + ] as (ToolSectionLabel | Tool)[]; + const wrapper = mountSection(elemsWithLabel); + await wrapper.find(".name").trigger("click"); + expect(getRenderedToolIds(wrapper)).toEqual(["z_tool", "a_tool"]); + }); +}); diff --git a/client/src/components/Panels/Common/ToolSection.vue b/client/src/components/Panels/Common/ToolSection.vue index cb4528282ea..365fce73b25 100644 --- a/client/src/components/Panels/Common/ToolSection.vue +++ b/client/src/components/Panels/Common/ToolSection.vue @@ -4,81 +4,89 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { useEventBus } from "@vueuse/core"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import { isTool, isToolSection, isToolSectionLabel } from "@/api/tools"; import { useConfig } from "@/composables/config"; -import { type Tool as ToolType, type ToolSection, type ToolSectionLabel, useToolStore } from "@/stores/toolStore"; +import { type Tool as ToolType, type ToolPanelItem, useToolStore } from "@/stores/toolStore"; import ariaAlert from "@/utils/ariaAlert"; +import { PANEL_LABEL_IDS } from "../panelViews"; + import Tool from "./Tool.vue"; import ToolPanelLabel from "./ToolPanelLabel.vue"; import ToolPanelLinks from "./ToolPanelLinks.vue"; const emit = defineEmits<{ - (e: "onClick", tool: any, evt: Event): void; + (e: "onClick", tool: ToolType, evt: Event): void; (e: "onFilter", filter: string): void; - (e: "onOperation", tool: any, evt: Event): void; + (e: "onLabelToggle", labelId: string): void; }>(); const eventBus = useEventBus("open-tool-section"); interface Props { - category: ToolSection | ToolType | ToolSectionLabel; + category: ToolPanelItem; queryFilter?: string; disableFilter?: boolean; hideName?: boolean; - operationTitle?: string; - operationIcon?: string; - toolKey?: string; - sectionName?: string; expanded?: boolean; sortItems?: boolean; hasFilterButton?: boolean; + searchActive?: boolean; + showFavoriteButton?: boolean; + collapsedLabels?: { + [PANEL_LABEL_IDS.FAVORITES_LABEL]: boolean; + [PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL]: boolean; + [PANEL_LABEL_IDS.RECENT_TOOLS_LABEL]: boolean; + } | null; } const props = withDefaults(defineProps(), { queryFilter: "", disableFilter: false, hideName: false, - operationTitle: "", - operationIcon: "", - toolKey: "", - sectionName: "default", expanded: false, sortItems: true, hasFilterButton: false, + searchActive: false, + showFavoriteButton: false, + collapsedLabels: null, }); const { config, isConfigLoaded } = useConfig(); const toolStore = useToolStore(); +/** + * We can have sections of 2 types: + * 1. Sections with `elems` property, which is an array of tools and sections. + * 2. Sections with `tools` property, which is an array of tool IDs (strings) and section labels. + * In this case, we need to resolve the tool IDs to get the actual tools. + */ const elems = computed(() => { - if (toolSection.value.elems !== undefined && toolSection.value.elems.length > 0) { - return toolSection.value.elems; - } - if (toolSection.value.tools !== undefined && toolSection.value.tools.length > 0) { - return toolSection.value.tools.map((toolId) => { - const tool = toolStore.getToolForId(toolId as string); - if (!tool && typeof toolId !== "string") { - return toolId as ToolSectionLabel; - } else { - return tool; - } - }); + if (isToolSection(props.category)) { + if (props.category.elems !== undefined && props.category.elems.length > 0) { + // This section has `elems`, we can return it as is. + return props.category.elems; + } + if (props.category.tools !== undefined && props.category.tools.length > 0) { + return props.category.tools + .map((toolOrLabel) => { + if (typeof toolOrLabel === "string") { + // This is a tool ID, we need to resolve it to get the actual `Tool`. + return toolStore.getToolForId(toolOrLabel) || null; + } else { + // This is a `ToolSectionLabel`, we can return it as is. + return toolOrLabel; + } + }) + .filter((el) => el !== null); + } } return []; }); -const toolSection = computed(() => props.category as ToolSection); -const toolSectionLabel = computed(() => props.category as ToolSectionLabel); - -const name = computed(() => toolSection.value.title || toolSection.value.name); -const isSection = computed(() => toolSection.value.tools !== undefined || toolSection.value.elems !== undefined); -const hasElements = computed(() => elems.value.length > 0); -const title = computed(() => props.category.description || undefined); -const links = computed(() => toolSection.value.links || {}); - const opened = ref(props.expanded || checkFilter()); -const sortedElements = computed(() => { +const sortedElements = computed>(() => { // If this.config.sortTools is true, sort the tools alphabetically // When administrators have manually inserted labels we respect // the order set and hope for the best from the integrated @@ -88,12 +96,12 @@ const sortedElements = computed(() => { isConfigLoaded.value && config.value.toolbox_auto_sort === true && props.sortItems === true && - !elems.value.some((el) => (el as ToolSectionLabel).text !== undefined && (el as ToolSectionLabel).text !== "") + !elems.value.some((el) => isToolSectionLabel(el) && el.text !== "") ) { const elements = [...elems.value]; const sorted = elements.sort((a, b) => { - const aNameLower = (a as ToolSection).name.toLowerCase(); - const bNameLower = (b as ToolSection).name.toLowerCase(); + const aNameLower = "name" in a ? a.name.toLowerCase() : ""; + const bNameLower = "name" in b ? b.name.toLowerCase() : ""; if (aNameLower > bNameLower) { return 1; } else if (aNameLower < bNameLower) { @@ -118,9 +126,9 @@ watch( watch( () => opened.value, (newVal: boolean, oldVal: boolean) => { - if (newVal !== oldVal) { + if (newVal !== oldVal && isToolSection(props.category)) { const currentState = newVal ? "opened" : "closed"; - ariaAlert(`${name.value} tools menu ${currentState}`); + ariaAlert(`${props.category.name} tools menu ${currentState}`); } }, ); @@ -134,83 +142,95 @@ onUnmounted(() => { }); function openToolSection(sectionId: string) { - if (isSection.value && sectionId == props.category?.id) { + if (isToolSection(props.category) && sectionId == props.category?.id) { toggleMenu(true); } } function checkFilter() { return !props.disableFilter && !!props.queryFilter; } -function onClick(tool: any, evt: Event) { +function onClick(tool: ToolType, evt: Event) { emit("onClick", tool, evt); } -function onOperation(tool: any, evt: Event) { - emit("onOperation", tool, evt); +function onLabelToggle(labelId: string) { + emit("onLabelToggle", labelId); } function toggleMenu(nextState = !opened.value) { opened.value = nextState; } +function getCollapsedState(id: string): boolean | undefined { + const validStateLabels = [ + PANEL_LABEL_IDS.FAVORITES_LABEL, + PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL, + PANEL_LABEL_IDS.RECENT_TOOLS_LABEL, + ] as const; + if (validStateLabels.includes(id as (typeof validStateLabels)[number])) { + return props.collapsedLabels ? props.collapsedLabels[id as (typeof validStateLabels)[number]] : undefined; + } + return undefined; +} - + + class="toolSectionTitle tool-panel-divider" + :title="props.category.description || undefined"> - + - {{ name }} + {{ props.category.title || props.category.name }} - - - - - + + + + + + v-if="isToolSectionLabel(el)" + :key="`label-${key}`" + :definition="el" + :collapsed="getCollapsedState(el.id)" + @toggle="onLabelToggle" /> - - - - + + diff --git a/client/src/components/Panels/ToolBoxSearch.test.ts b/client/src/components/Panels/ToolBoxSearch.test.ts new file mode 100644 index 00000000000..6df7813d874 --- /dev/null +++ b/client/src/components/Panels/ToolBoxSearch.test.ts @@ -0,0 +1,330 @@ +import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import flushPromises from "flush-promises"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import toolsListUntyped from "@/components/ToolsView/testData/toolsList.json"; +import toolsListInPanelUntyped from "@/components/ToolsView/testData/toolsListInPanel.json"; +import { setMockConfig } from "@/composables/__mocks__/config"; +import { type Tool, type ToolSection, useToolStore } from "@/stores/toolStore"; +import { useUserStore } from "@/stores/userStore"; + +import ToolBox from "./ToolBox.vue"; + +vi.mock("@/composables/config"); + +setMockConfig({ + toolbox_auto_sort: true, +}); + +const toolsList = toolsListUntyped as unknown as Tool[]; +const toolsListInPanel = toolsListInPanelUntyped as unknown as Record; +const EXPECTED_LABELS = ["Recent tools", "Favorites"]; + +const localVue = getLocalVue(); +const router = injectTestRouter(localVue); + +function toToolsById(list: Tool[]) { + return list.reduce( + (acc, tool) => { + acc[tool.id] = tool; + return acc; + }, + {} as Record, + ); +} + +describe("ToolBox search", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("searches across toolbox when favorites are the default view", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: ["liftOver1"] } }; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + expect(wrapper.find('[data-tool-id="liftOver1"]').exists()).toBe(true); + + const input = wrapper.find("input.search-query"); + await input.setValue("Zip"); + vi.advanceTimersByTime(250); + await flushPromises(); + + expect(wrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(true); + }); + + it("shows empty favorites copy in My panel when no favorites are set", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: [] } }; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + const emptyState = wrapper.find(".tool-panel-empty"); + expect(emptyState.exists()).toBe(true); + expect(emptyState.text()).toContain("You haven't favorited any tools yet."); + expect(emptyState.text()).toContain(`${toolsList.length} community curated tools.`); + const discoverButton = emptyState.find('[data-description="discover-tools"]'); + expect(discoverButton.exists()).toBe(true); + expect(discoverButton.text()).toBe("Discover Tools"); + + const labels = wrapper.findAll(".tool-panel-label").wrappers.map((item) => item.text()); + expect(labels).toEqual(["Favorites"]); + }); + + it("separates favorite results and shows favorite button for non-favorites during search", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: ["__FILTER_FAILED_DATASETS__"] } }; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + const input = wrapper.find("input.search-query"); + await input.setValue("Filter"); + vi.advanceTimersByTime(250); + await flushPromises(); + + const labels = wrapper.findAll(".tool-panel-label").wrappers.map((item) => item.text()); + expect(labels).toEqual(["Favorites", "Search results"]); + + const toolIds = wrapper.findAll("a[data-tool-id]").wrappers.map((item) => item.attributes("data-tool-id")); + expect(toolIds).toEqual(["__FILTER_FAILED_DATASETS__", "__FILTER_EMPTY_DATASETS__"]); + + expect(wrapper.find('.tool-favorite-button[data-tool-id="__FILTER_EMPTY_DATASETS__"]').exists()).toBe(true); + expect(wrapper.find('.tool-favorite-button-hover[data-tool-id="__FILTER_EMPTY_DATASETS__"]').exists()).toBe( + false, + ); + expect(wrapper.find('.tool-favorite-button[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe(true); + expect(wrapper.find('.tool-favorite-button-hover[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe( + true, + ); + }); + + it("collapses favorite results during search in My panel", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: ["__FILTER_FAILED_DATASETS__"] } }; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + const input = wrapper.find("input.search-query"); + await input.setValue("Filter"); + vi.advanceTimersByTime(250); + await flushPromises(); + + expect(wrapper.find('[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe(true); + expect(wrapper.find('[data-tool-id="__FILTER_EMPTY_DATASETS__"]').exists()).toBe(true); + + const favoritesLabel = wrapper + .findAll(".tool-panel-label") + .wrappers.find((item) => item.text().includes("Favorites")); + expect(favoritesLabel).toBeTruthy(); + await favoritesLabel?.trigger("click"); + await flushPromises(); + + expect(wrapper.find('[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe(false); + expect(wrapper.find('[data-tool-id="__FILTER_EMPTY_DATASETS__"]').exists()).toBe(true); + }); + + it("shows recent tools before favorites and allows clearing", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: ["__FILTER_FAILED_DATASETS__"] } }; + userStore.recentTools = ["__ZIP_COLLECTION__", "__FILTER_EMPTY_DATASETS__"]; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + const labels = wrapper.findAll(".tool-panel-label").wrappers.map((item) => item.text()); + expect(labels).toEqual(EXPECTED_LABELS); + + const toolIds = wrapper.findAll("a[data-tool-id]").wrappers.map((item) => item.attributes("data-tool-id")); + expect(toolIds).toEqual(["__ZIP_COLLECTION__", "__FILTER_EMPTY_DATASETS__", "__FILTER_FAILED_DATASETS__"]); + expect(wrapper.find('.tool-favorite-button[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(true); + + await wrapper.find('[data-description="clear-recent-tools"]').trigger("click"); + await flushPromises(); + + expect(wrapper.find('[data-description="clear-recent-tools"]').exists()).toBe(false); + expect(wrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(false); + }); + + it("collapses favorites and recent tools sections on label click", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: ["__FILTER_FAILED_DATASETS__"] } }; + userStore.recentTools = ["__ZIP_COLLECTION__"]; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + expect(wrapper.find('[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe(true); + expect(wrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(true); + + const favoritesLabel = wrapper + .findAll(".tool-panel-label") + .wrappers.find((item) => item.text().includes("Favorites")); + expect(favoritesLabel).toBeTruthy(); + await favoritesLabel?.trigger("click"); + await flushPromises(); + + expect(wrapper.find('[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe(false); + expect(wrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(true); + + const recentLabel = wrapper + .findAll(".tool-panel-label") + .wrappers.find((item) => item.text().includes("Recent tools")); + expect(recentLabel).toBeTruthy(); + await recentLabel?.trigger("click"); + await flushPromises(); + + expect(wrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(false); + }); + + it("does not show empty favorites copy when recent tools exist", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { favorites: { tools: [] } }; + userStore.recentTools = ["__ZIP_COLLECTION__"]; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + const emptyState = wrapper.find(".tool-panel-empty"); + expect(emptyState.exists()).toBe(true); + expect(emptyState.text()).toContain("You haven't favorited any tools yet."); + const labels = wrapper.findAll(".tool-panel-label").wrappers.map((item) => item.text()); + expect(labels).toEqual(EXPECTED_LABELS); + }); +}); diff --git a/client/src/components/Panels/ToolPanel.test.ts b/client/src/components/Panels/ToolPanel.test.ts index cf2b0dcf196..3f848b73fdc 100644 --- a/client/src/components/Panels/ToolPanel.test.ts +++ b/client/src/components/Panels/ToolPanel.test.ts @@ -1,5 +1,5 @@ import { getFakeRegisteredUser } from "@tests/test-data"; -import { getLocalVue } from "@tests/vitest/helpers"; +import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers"; import { mount } from "@vue/test-utils"; import flushPromises from "flush-promises"; import { createPinia } from "pinia"; @@ -27,6 +27,7 @@ interface ToolPanelView { } const localVue = getLocalVue(); +const router = injectTestRouter(localVue); const { server, http } = useServerMock(); const TEST_PANELS_URI = "/api/tool_panels"; @@ -36,7 +37,9 @@ const PANEL_VIEW_ERR_MSG = "Error loading panel view"; vi.mock("@/composables/config"); vi.mock("@/composables/userLocalStorage", () => ({ - useUserLocalStorage: vi.fn(() => ref(DEFAULT_VIEW_ID)), + useUserLocalStorage: vi.fn((_key: string, initialValue: unknown) => + ref(_key === "tool-store-view" ? DEFAULT_VIEW_ID : initialValue), + ), })); describe("ToolPanel", () => { @@ -54,7 +57,9 @@ describe("ToolPanel", () => { throw new Error(`View with key ${viewKey} not found in viewsList`); } // ref and useUserLocalStorage are already imported at the top - vi.mocked(useUserLocalStorage).mockImplementation(() => ref(viewKey)); + vi.mocked(useUserLocalStorage).mockImplementation((_key: string, initialValue: unknown) => + ref(_key === "tool-store-view" ? viewKey : initialValue), + ); return { viewKey, view }; } @@ -122,9 +127,7 @@ describe("ToolPanel", () => { useSearchWorker: false, }, localVue, - stubs: { - ToolBox: true, - }, + router, pinia, }); @@ -171,7 +174,11 @@ describe("ToolPanel", () => { // Test: check if the panel header now has an icon and a changed name const currentViewType = value.view_type as keyof typeof types_to_icons; const panelViewIcon = wrapper.find("[data-description='panel view header icon']"); - expect(panelViewIcon.attributes("data-icon")).toEqual(types_to_icons[currentViewType].iconName); + if (key === "my_panel") { + expect(panelViewIcon.exists()).toBe(false); + } else { + expect(panelViewIcon.attributes("data-icon")).toEqual(types_to_icons[currentViewType].iconName); + } expect(wrapper.find("#toolbox-heading").text()).toBe(value!.name); } else { // Test: check if the default panel view is already selected, and no icon @@ -220,4 +227,12 @@ describe("ToolPanel", () => { expect(wrapper.find('[data-description="Discover Tools button"]').exists()).toBe(false); }); + + it("shows the tools count on the discover tools button", async () => { + const wrapper = await createWrapper(); + const count = toolsList.length; + const formatted = count < 1000 ? `${count}` : `${Math.floor(count / 1000)}k+`; + const discoverButton = wrapper.find('[data-description="toolbox discover tools"]'); + expect(discoverButton.text()).toBe(`Discover ${formatted} Tools`); + }); }); diff --git a/client/src/components/Panels/ToolPanel.vue b/client/src/components/Panels/ToolPanel.vue index 444fc8ad7b1..c274dee37e9 100644 --- a/client/src/components/Panels/ToolPanel.vue +++ b/client/src/components/Panels/ToolPanel.vue @@ -1,12 +1,15 @@