Skip to content

Commit b315803

Browse files
committed
Show "Request a Tool" button only when tool search returns no results
Previously the button was always visible below the search controls. Now it appears contextually inside the "No results found" empty state, grouping the call-to-action with the feedback that triggered it. - Move button into the `queryFinished && !hasResults` block in ToolBox.vue - Button remains hidden for anonymous users, in workflow mode, and when `enable_tool_request_form` config is disabled - Fix `isConfigLoaded` in the config mock to return a `ref(true)` rather than a plain boolean, matching the real composable - Add 6 tests covering button visibility: with/without results, config disabled, anonymous user, workflow mode, and modal open-on-click
1 parent b55b916 commit b315803

3 files changed

Lines changed: 159 additions & 8 deletions

File tree

client/src/components/Panels/ToolBox.vue

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,16 @@ function onLabelToggle(labelId: string) {
477477
</div>
478478
<div v-else-if="queryFinished && !hasResults" class="pb-2">
479479
<BBadge class="alert-warning w-100">No results found</BBadge>
480+
<div v-if="showRequestToolButton" class="mt-2">
481+
<GButton
482+
size="small"
483+
class="w-100"
484+
data-description="request tool button"
485+
@click="openToolRequestForm">
486+
<FontAwesomeIcon :icon="faWrench" class="mr-1" />
487+
{{ localize("Request a Tool") }}
488+
</GButton>
489+
</div>
480490
</div>
481491
<div v-if="closestTerm" class="pb-2">
482492
<BBadge class="alert-danger w-100">
@@ -489,12 +499,6 @@ function onLabelToggle(labelId: string) {
489499
</div>
490500
</section>
491501
</div>
492-
<div v-if="showRequestToolButton" class="px-2 pb-2">
493-
<GButton size="small" class="w-100" data-description="request tool button" @click="openToolRequestForm">
494-
<FontAwesomeIcon :icon="faWrench" class="mr-1" />
495-
{{ localize("Request a Tool") }}
496-
</GButton>
497-
</div>
498502

499503
<ToolRequestForm :show.sync="showToolRequestForm" />
500504

client/src/components/Panels/ToolBoxSearch.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66

77
import toolsListUntyped from "@/components/ToolsView/testData/toolsList.json";
88
import toolsListInPanelUntyped from "@/components/ToolsView/testData/toolsListInPanel.json";
9-
import { setMockConfig } from "@/composables/__mocks__/config";
9+
import { resetMockConfig, setMockConfig } from "@/composables/__mocks__/config";
1010
import { type Tool, type ToolSection, useToolStore } from "@/stores/toolStore";
1111
import { useUserStore } from "@/stores/userStore";
1212

13+
import ToolRequestForm from "../Tool/ToolRequestForm.vue";
1314
import ToolBox from "./ToolBox.vue";
1415

1516
vi.mock("@/composables/config");
@@ -328,3 +329,147 @@ describe("ToolBox search", () => {
328329
expect(labels).toEqual(EXPECTED_LABELS);
329330
});
330331
});
332+
333+
/** Helper to mount a default (non-favorites) ToolBox with pre-loaded tool data.
334+
* Pass `anonymous=true` to simulate an unauthenticated (anonymous) user.
335+
* An anonymous user is any non-null object without an `email` property.
336+
*/
337+
async function mountDefaultToolBox(pinia: ReturnType<typeof createPinia>, anonymous = false) {
338+
setActivePinia(pinia);
339+
340+
const toolStore = useToolStore();
341+
toolStore.toolsById = toToolsById(toolsList);
342+
toolStore.toolSections = { default: toolsListInPanel };
343+
toolStore.defaultPanelView = "default";
344+
toolStore.currentPanelView = "default";
345+
346+
const userStore = useUserStore();
347+
// isAnonymous is a computed value driven by currentUser.
348+
// null → isAnonymous=false (registered/logged-in); object without email → isAnonymous=true
349+
userStore.currentUser = anonymous ? ({ id: "anon" } as any) : null;
350+
userStore.currentPreferences = { favorites: { tools: [] } };
351+
352+
const wrapper = mount(ToolBox as object, {
353+
pinia,
354+
localVue,
355+
router,
356+
propsData: { useSearchWorker: false },
357+
});
358+
await flushPromises();
359+
return wrapper;
360+
}
361+
362+
describe("ToolBox — Request a Tool button", () => {
363+
beforeEach(() => {
364+
vi.useFakeTimers();
365+
setMockConfig({ toolbox_auto_sort: true, enable_tool_request_form: true });
366+
});
367+
368+
afterEach(() => {
369+
vi.useRealTimers();
370+
resetMockConfig();
371+
setMockConfig({ toolbox_auto_sort: true });
372+
});
373+
374+
it("is hidden when search returns results", async () => {
375+
const pinia = createPinia();
376+
const wrapper = await mountDefaultToolBox(pinia);
377+
378+
const input = wrapper.find("input.search-query");
379+
await input.setValue("Filter");
380+
vi.advanceTimersByTime(250);
381+
await flushPromises();
382+
383+
expect(wrapper.find('[data-description="request tool button"]').exists()).toBe(false);
384+
});
385+
386+
it("is visible when search returns no results", async () => {
387+
const pinia = createPinia();
388+
const wrapper = await mountDefaultToolBox(pinia);
389+
390+
const input = wrapper.find("input.search-query");
391+
await input.setValue("xyznonexistenttool123");
392+
vi.advanceTimersByTime(250);
393+
await flushPromises();
394+
395+
expect(wrapper.find(".alert-warning").exists()).toBe(true);
396+
expect(wrapper.find('[data-description="request tool button"]').exists()).toBe(true);
397+
});
398+
399+
it("is hidden when enable_tool_request_form config is false", async () => {
400+
setMockConfig({ toolbox_auto_sort: true, enable_tool_request_form: false });
401+
const pinia = createPinia();
402+
const wrapper = await mountDefaultToolBox(pinia);
403+
404+
const input = wrapper.find("input.search-query");
405+
await input.setValue("xyznonexistenttool123");
406+
vi.advanceTimersByTime(250);
407+
await flushPromises();
408+
409+
expect(wrapper.find(".alert-warning").exists()).toBe(true);
410+
expect(wrapper.find('[data-description="request tool button"]').exists()).toBe(false);
411+
});
412+
413+
it("is hidden for anonymous users even when search returns no results", async () => {
414+
const pinia = createPinia();
415+
const wrapper = await mountDefaultToolBox(pinia, /* anonymous= */ true);
416+
417+
const input = wrapper.find("input.search-query");
418+
await input.setValue("xyznonexistenttool123");
419+
vi.advanceTimersByTime(250);
420+
await flushPromises();
421+
422+
expect(wrapper.find(".alert-warning").exists()).toBe(true);
423+
expect(wrapper.find('[data-description="request tool button"]').exists()).toBe(false);
424+
});
425+
426+
it("is hidden in workflow mode even when search returns no results", async () => {
427+
const pinia = createPinia();
428+
setActivePinia(pinia);
429+
430+
const toolStore = useToolStore();
431+
toolStore.toolsById = toToolsById(toolsList);
432+
toolStore.toolSections = { default: toolsListInPanel };
433+
toolStore.defaultPanelView = "default";
434+
toolStore.currentPanelView = "default";
435+
436+
const userStore = useUserStore();
437+
userStore.currentUser = null;
438+
userStore.currentPreferences = { favorites: { tools: [] } };
439+
440+
const wrapper = mount(ToolBox as object, {
441+
pinia,
442+
localVue,
443+
router,
444+
propsData: { workflow: true, useSearchWorker: false },
445+
});
446+
await flushPromises();
447+
448+
const input = wrapper.find("input.search-query");
449+
await input.setValue("xyznonexistenttool123");
450+
vi.advanceTimersByTime(250);
451+
await flushPromises();
452+
453+
expect(wrapper.find(".alert-warning").exists()).toBe(true);
454+
expect(wrapper.find('[data-description="request tool button"]').exists()).toBe(false);
455+
});
456+
457+
it("opens the tool request form modal when clicked", async () => {
458+
const pinia = createPinia();
459+
const wrapper = await mountDefaultToolBox(pinia);
460+
461+
const input = wrapper.find("input.search-query");
462+
await input.setValue("xyznonexistenttool123");
463+
vi.advanceTimersByTime(250);
464+
await flushPromises();
465+
466+
const button = wrapper.find('[data-description="request tool button"]');
467+
expect(button.exists()).toBe(true);
468+
469+
await button.trigger("click");
470+
await flushPromises();
471+
472+
const requestForm = wrapper.findComponent(ToolRequestForm);
473+
expect(requestForm.props("show")).toBe(true);
474+
});
475+
});

client/src/composables/__mocks__/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { vi } from "vitest";
22
import { ref } from "vue";
33

4+
const isConfigLoadedRef = ref(true);
5+
46
// Default mock config values
57
const defaultConfig = {
68
allow_local_account_creation: true,
@@ -20,7 +22,7 @@ const mockConfig = ref({ ...defaultConfig });
2022

2123
export const useConfig = vi.fn(() => ({
2224
config: mockConfig,
23-
isConfigLoaded: true,
25+
isConfigLoaded: isConfigLoadedRef,
2426
}));
2527

2628
// Helper function for tests to override config values

0 commit comments

Comments
 (0)