diff --git a/client/src/components/Panels/ToolBox.vue b/client/src/components/Panels/ToolBox.vue index 556b50e9ac5..6a77c03f6c4 100644 --- a/client/src/components/Panels/ToolBox.vue +++ b/client/src/components/Panels/ToolBox.vue @@ -17,6 +17,7 @@ import { buildToolEntries, buildToolLabel, buildToolSection, + countUniqueToolsInList, countUniqueToolsInPanel, FAVORITES_KEYS, filterPanelByToolIds, @@ -180,6 +181,8 @@ const defaultSectionsById = computed | null>(() => return Object.keys(validSections).length > 0 ? validSections : null; }); +const myToolsDefaultSectionsById = computed(() => defaultSectionsById.value || localSectionsById.value); + // Use composable for favorites and recent tools — we only need the bits that // drive the search-results split (favorites get their own section in mixed // results). The full My-Tools landing state lives inside `MyToolsLanding.vue`. @@ -189,7 +192,10 @@ const { favoritesCollapsed, favoriteToolIdSet, recentToolIdsToShowSet } = useToo const { favoriteResults, nonFavoriteResults, hasMixedResults } = useFavoriteSearchResults(results, favoriteToolIdSet); const toolsCount = computed(() => - countUniqueToolsInPanel(defaultSectionsById.value || localSectionsById.value, toolsList.value.length), + countUniqueToolsInPanel( + defaultSectionsById.value || localSectionsById.value, + countUniqueToolsInList(toolsList.value), + ), ); const resultsSet = computed(() => new Set(results.value)); @@ -436,7 +442,7 @@ function onLabelToggle(labelId: string) { { failDefault: boolean = false, captureToolsRequest?: (url: URL) => void, panelResponses: Record = {}, + defaultPanelView: string = DEFAULT_VIEW_ID, + capturePanelRequest?: (panelView: string) => void, ) { server.use( http.untyped.get("/api/tools", ({ request }) => { @@ -102,7 +104,7 @@ describe("ToolPanel", () => { return HttpResponse.json([]); }), http.untyped.get(TEST_PANELS_URI, () => { - return HttpResponse.json({ default_panel_view: DEFAULT_VIEW_ID, views: viewsList }); + return HttpResponse.json({ default_panel_view: defaultPanelView, views: viewsList }); }), http.get("/api/users/{user_id}", ({ response }) => { return response(200).json(getFakeRegisteredUser()); @@ -133,6 +135,7 @@ describe("ToolPanel", () => { server.use( http.untyped.get(/\/api\/tool_panels\/.*/, ({ request }) => { const panelView = new URL(request.url).pathname.split("/").pop() || ""; + capturePanelRequest?.(panelView); return HttpResponse.json(panelResponses[panelView] ?? toolsListInPanel); }), ); @@ -274,6 +277,85 @@ describe("ToolPanel", () => { expect(discoverButton.text()).toBe(`Discover ${formatted} Tools`); }); + it("uses the loaded default panel count when My Tools is the backend default view", async () => { + storePanelView(""); + const wrapper = await createWrapper( + "", + false, + undefined, + { + my_panel: { + favorites: { + model_class: "ToolSection", + id: "favorites", + name: "Favorites", + tools: [], + }, + }, + }, + "my_panel", + ); + const count = getUniqueToolIdsInPanel(toolsListInPanel).size; + 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`); + expect(discoverButton.text()).not.toBe(`Discover ${toolsListWithExtraVersion.length} Tools`); + }); + + it("ignores My Tools panel contents for the header count when My Tools is the backend default view", async () => { + storePanelView(""); + const firstToolId = firstTool?.id; + if (!firstToolId) { + throw new Error("Expected at least one tool fixture"); + } + const wrapper = await createWrapper( + "", + false, + undefined, + { + my_panel: { + favorites: { + model_class: "ToolSection", + id: "favorites", + name: "Favorites", + tools: [firstToolId], + }, + }, + }, + "my_panel", + ); + const count = getUniqueToolIdsInPanel(toolsListInPanel).size; + 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`); + expect(discoverButton.text()).not.toBe("Discover 1 Tools"); + }); + + it("loads default panel sections when My Tools is the backend default view", async () => { + storePanelView(""); + const requestedPanels: string[] = []; + await createWrapper( + "", + false, + undefined, + { + my_panel: { + favorites: { + model_class: "ToolSection", + id: "favorites", + name: "Favorites", + tools: [], + }, + }, + }, + "my_panel", + (panelView) => requestedPanels.push(panelView), + ); + + expect(requestedPanels).toContain(DEFAULT_VIEW_ID); + expect(requestedPanels).toContain("my_panel"); + }); + it("does not request tool tags during default tool panel startup", async () => { let toolsRequestUrl: URL | undefined; let toolTagsRequested = false; diff --git a/client/src/components/Panels/ToolPanel.vue b/client/src/components/Panels/ToolPanel.vue index eaab5725850..67b39b3e37c 100644 --- a/client/src/components/Panels/ToolPanel.vue +++ b/client/src/components/Panels/ToolPanel.vue @@ -9,7 +9,7 @@ import localize from "@/utils/localization"; import { errorMessageAsString } from "@/utils/simple-error"; import { MY_PANEL_VIEW_ID } from "./panelViews"; -import { countUniqueToolsInPanel } from "./utilities"; +import { countUniqueToolsInList, countUniqueToolsInPanel } from "./utilities"; import LoadingSpan from "../LoadingSpan.vue"; import ActivityPanel from "./ActivityPanel.vue"; @@ -36,14 +36,22 @@ const isMyPanel = computed(() => currentPanelView.value === MY_PANEL_VIEW_ID); const errorMessage = ref(""); const panelsFetched = ref(false); const showFavorites = ref(false); +const defaultToolSections = computed(() => { + return ( + toolSections.value.default || + (defaultPanelView.value && defaultPanelView.value !== MY_PANEL_VIEW_ID + ? toolSections.value[defaultPanelView.value] + : null) + ); +}); const headerToolSections = computed(() => { if (isMyPanel.value) { - return toolSections.value[defaultPanelView.value] || toolSections.value.default || currentToolSections.value; + return defaultToolSections.value || currentToolSections.value; } return currentToolSections.value; }); const toolsCount = computed(() => - countUniqueToolsInPanel(headerToolSections.value, Object.keys(toolsById.value).length), + countUniqueToolsInPanel(headerToolSections.value, countUniqueToolsInList(toolsById.value)), ); function formatToolsCount(count: number) { diff --git a/client/src/components/Panels/utilities.test.ts b/client/src/components/Panels/utilities.test.ts index cb770e1844e..3b892f4ad03 100644 --- a/client/src/components/Panels/utilities.test.ts +++ b/client/src/components/Panels/utilities.test.ts @@ -4,6 +4,7 @@ import toolsListInPanelUntyped from "@/components/ToolsView/testData/toolsListIn import { describe, expect, it } from "vitest"; import { + countUniqueToolsInList, createSortedResultPanel, createWhooshQuery, determineWidth, @@ -98,6 +99,12 @@ describe("test helpers in tool searching utilities", () => { expect(createWhooshQuery({ section: '"Get Data"' })).toEqual('(section:("Get Data"))'); }); + it("counts versioned tools once when falling back to the full tools list", async () => { + const firstTool = toolsList[0]!; + const toolsWithOlderVersion = [...toolsList, { ...firstTool, id: `${firstTool.id}/0.9`, version: "0.9" }]; + expect(countUniqueToolsInList(toolsWithOlderVersion)).toBe(toolsList.length); + }); + it("lowercases tag clauses to match the Whoosh field analyzer", async () => { // Whoosh indexes tool_tags lowercased; lowercase the search clause too. expect(createWhooshQuery({ tag: ["Get Data"] })).toEqual('(tool_tags:("get data"))'); diff --git a/client/src/components/Panels/utilities.ts b/client/src/components/Panels/utilities.ts index 38485291fd9..98e9423bea3 100644 --- a/client/src/components/Panels/utilities.ts +++ b/client/src/components/Panels/utilities.ts @@ -67,6 +67,24 @@ export function getUniqueToolIdsInPanel(panel: Record | n return toolIds; } +type CountableTool = Pick & Partial>; + +export function getVersionlessToolId(toolId: string, tool: CountableTool | undefined) { + if (tool?.version && toolId.endsWith(`/${tool.version}`)) { + return toolId.slice(0, -tool.version.length - 1); + } + return toolId; +} + +export function countUniqueToolsInList(tools: Record | T[]) { + const toolEntries = Array.isArray(tools) ? tools.map((tool) => [tool.id, tool] as const) : Object.entries(tools); + const toolIds = new Set(); + for (const [toolId, tool] of toolEntries) { + toolIds.add(getVersionlessToolId(toolId, tool)); + } + return toolIds.size; +} + export function countUniqueToolsInPanel(panel: Record | null | undefined, fallbackCount = 0) { return getUniqueToolIdsInPanel(panel).size || fallbackCount; } diff --git a/client/src/stores/toolStore.ts b/client/src/stores/toolStore.ts index ea3baf9427d..7aaf2fecfaa 100644 --- a/client/src/stores/toolStore.ts +++ b/client/src/stores/toolStore.ts @@ -109,6 +109,8 @@ const MY_PANEL_VIEW: Panel = { searchable: true, }; +const DEFAULT_PANEL_VIEW_ID = "default"; + export const useToolStore = defineStore("toolStore", () => { const currentPanelView: Ref = useUserLocalStorage("tool-store-view", ""); const defaultPanelView: Ref = ref(""); @@ -369,8 +371,16 @@ export const useToolStore = defineStore("toolStore", () => { async function setPanel(panelView: string) { try { - if (panelView === MY_PANEL_VIEW_ID && defaultPanelView.value && panelView !== defaultPanelView.value) { - await fetchToolSections(defaultPanelView.value); + if (panelView === MY_PANEL_VIEW_ID) { + const sectionedPanelView = + defaultPanelView.value && defaultPanelView.value !== MY_PANEL_VIEW_ID + ? defaultPanelView.value + : panels.value[DEFAULT_PANEL_VIEW_ID] + ? DEFAULT_PANEL_VIEW_ID + : null; + if (sectionedPanelView) { + await fetchToolSections(sectionedPanelView); + } } await fetchToolSections(panelView); currentPanelView.value = panelView;