Merge pull request #21600 from bgruening/fav-tool-panel

Introducing a new favorite tool panel + most-recent tools
This commit is contained in:
Marius van den Beek
2026-02-17 16:13:39 +01:00
committed by GitHub
31 changed files with 1935 additions and 421 deletions
+15
View File
@@ -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"
);
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref } from "vue";
const props = defineProps<{
value?: string | null;
@@ -10,6 +10,8 @@ const emit = defineEmits<{
(e: "keydown", event: KeyboardEvent): void;
}>();
const inputRef = ref<HTMLInputElement | null>(null);
const inputValue = computed({
get() {
return props.value;
@@ -18,10 +20,18 @@ const inputValue = computed({
emit("input", value ?? null);
},
});
function focus() {
inputRef.value?.focus();
}
defineExpose({
focus,
});
</script>
<template>
<input v-model="inputValue" class="g-form-input" @keydown="(event) => emit('keydown', event)" />
<input ref="inputRef" v-model="inputValue" class="g-form-input" @keydown="(event) => emit('keydown', event)" />
</template>
<style scoped lang="scss">
@@ -2,6 +2,9 @@ import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import { nextTick } from "vue";
import { useUserStore } from "@/stores/userStore";
import Tool from "./Tool.vue";
@@ -10,7 +13,7 @@ const localVue = getLocalVue();
describe("Tool", () => {
test("test tool", () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
const wrapper = mount(Tool, {
const wrapper = mount(Tool as object, {
propsData: {
tool: {
id: "test_tool",
@@ -24,36 +27,11 @@ describe("Tool", () => {
nameElement.trigger("click");
expect(wrapper.emitted().onClick).toBeDefined();
const labelsElement = wrapper.find(".labels");
expect(labelsElement.children).toBeUndefined();
const operationElement = wrapper.find(".operation");
expect(operationElement.classes()).toEqual(expect.arrayContaining(["operation"]));
operationElement.trigger("click");
expect(wrapper.emitted().onOperation).toBeDefined();
});
test("test tool operation", () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
const wrapper = mount(Tool, {
propsData: {
tool: {
id: "test_tool",
name: "name",
},
operationIcon: "operationIconClass",
operationTitle: "operationTitle",
},
localVue,
pinia,
});
const nameElement = wrapper.findAll(".name");
expect(nameElement.at(0).text()).toBe("name");
const operationElement = wrapper.find(".operation");
expect(operationElement.classes()).toEqual(expect.arrayContaining(["operationIconClass"]));
const title = operationElement.attributes("title");
expect(title).toBe("operationTitle");
expect(labelsElement.element.children.length).toBe(0);
});
test("test tool hide name, test description", () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
const wrapper = mount(Tool, {
const wrapper = mount(Tool as object, {
propsData: {
tool: {
id: "test_tool",
@@ -70,4 +48,29 @@ describe("Tool", () => {
const descriptionElement = wrapper.find(".description");
expect(descriptionElement.text()).toBe("description");
});
test("favorite button is focusable for keyboard navigation", async () => {
const pinia = createTestingPinia({ createSpy: vi.fn });
const wrapper = mount(Tool as object, {
propsData: {
tool: {
id: "test_tool",
name: "name",
},
},
localVue,
pinia,
attachTo: document.body,
});
const userStore = useUserStore();
userStore.currentPreferences = { favorites: { tools: ["test_tool"] } };
await nextTick();
const favoriteButton = wrapper.find('.tool-favorite-button-hover[data-tool-id="test_tool"]');
expect(favoriteButton.exists()).toBe(true);
(favoriteButton.element as HTMLElement).focus();
expect(document.activeElement).toBe(favoriteButton.element);
wrapper.destroy();
});
});
+85 -86
View File
@@ -1,110 +1,109 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { computed } from "vue";
import type { Tool as ToolType } from "@/stores/toolStore";
import { useToolStore } from "@/stores/toolStore";
import { useUserStore } from "@/stores/userStore";
import ariaAlert from "@/utils/ariaAlert";
import ToolFavoriteButton from "@/components/Tool/Buttons/ToolFavoriteButton.vue";
interface Props {
tool: ToolType;
hideName?: boolean;
showFavoriteButton?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
hideName: false,
showFavoriteButton: false,
});
const emit = defineEmits<{
(e: "onClick", tool: ToolType, evt: MouseEvent): void;
}>();
const toolStore = useToolStore();
const userStore = useUserStore();
const { currentFavorites } = storeToRefs(userStore);
const isFavorite = computed(() => currentFavorites.value.tools?.includes(props.tool.id));
const toolLink = computed(() => toolStore.getLinkById(props.tool.id));
const toolTarget = computed(() => toolStore.getTargetById(props.tool.id));
function onClick(evt: MouseEvent) {
ariaAlert(`${props.tool.name} selected from panel`);
emit("onClick", props.tool, evt);
}
</script>
<template>
<div class="toolTitle">
<a v-if="tool.disabled" :data-tool-id="tool.id" class="title-link name text-muted">
<span v-if="!hideName">{{ tool.name }}</span>
<span class="description">{{ tool.description }}</span>
<a v-if="props.tool.disabled" :data-tool-id="props.tool.id" class="title-link name text-muted tool-link">
<span v-if="!props.hideName">{{ props.tool.name }}</span>
<span class="description">{{ props.tool.description }}</span>
</a>
<a
v-else
:class="targetClass"
:data-tool-id="tool.id"
class="title-link cursor-pointer tool-link"
:data-tool-id="props.tool.id"
:href="toolLink"
:target="toolTarget"
:title="tool.help"
:title="props.tool.help"
@click="onClick">
<span class="labels">
<span
v-for="(label, index) in tool.labels"
v-for="(label, index) in props.tool.labels"
:key="index"
:class="['badge', 'badge-primary', `badge-${label}`]">
{{ label }}
</span>
</span>
<span v-if="!hideName" class="name font-weight-bold">{{ tool.name }}</span>
<span class="description">{{ tool.description }}</span>
<span
v-b-tooltip.hover
:class="['operation', 'float-right', operationIcon]"
:title="operationTitle"
@click.stop.prevent="onOperation" />
<span v-if="!props.hideName" class="name font-weight-bold">{{ props.tool.name }}</span>
<span class="description">{{ props.tool.description }}</span>
</a>
<ToolFavoriteButton
v-if="props.showFavoriteButton || isFavorite"
:id="props.tool.id"
:class="['tool-favorite-button', { 'tool-favorite-button-hover': isFavorite }]"
:data-tool-id="props.tool.id"
color="grey" />
</div>
</template>
<script>
import BootstrapVue from "bootstrap-vue";
import Vue from "vue";
import { useToolStore } from "@/stores/toolStore";
import ariaAlert from "@/utils/ariaAlert";
Vue.use(BootstrapVue);
export default {
name: "Tool",
props: {
tool: {
type: Object,
required: true,
},
operationTitle: {
type: String,
default: "",
},
operationIcon: {
type: String,
default: "",
},
hideName: {
type: Boolean,
default: false,
},
toolKey: {
type: String,
default: "",
},
renderIcon: {
type: Boolean,
default: false,
},
},
setup() {
const toolStore = useToolStore();
return {
getLinkById: toolStore.getLinkById,
getTargetById: toolStore.getTargetById,
};
},
computed: {
toolLink() {
return this.getLinkById(this.tool.id);
},
toolTarget() {
return this.getTargetById(this.tool.id);
},
targetClass() {
if (this.toolKey) {
return `tool-menu-item-${this.tool[this.toolKey]} title-link cursor-pointer`;
} else {
return `title-link cursor-pointer`;
}
},
},
methods: {
onClick(evt) {
ariaAlert(`${this.tool.name} selected from panel`);
this.$emit("onClick", this.tool, evt);
},
onOperation(evt) {
ariaAlert(`${this.tool.name} operation selected from panel`);
this.$emit("onOperation", this.tool, evt);
},
},
};
</script>
<style scoped>
.toolTitle {
display: flex;
align-items: center;
overflow-wrap: anywhere;
}
.tool-link {
flex: 1 1 auto;
}
.tool-favorite-button {
margin-left: 0.25rem;
}
.tool-favorite-button-hover {
opacity: 0;
transition: opacity 0.2s ease;
transition-delay: 0s;
pointer-events: none;
}
.toolTitle:hover .tool-favorite-button-hover {
opacity: 1;
transition-delay: 0.5s;
pointer-events: auto;
}
.toolTitle:focus-within .tool-favorite-button-hover {
opacity: 1;
transition-delay: 0s;
pointer-events: auto;
}
.tool-favorite-button-hover:focus {
opacity: 1;
transition-delay: 0s;
pointer-events: auto;
}
</style>
@@ -1,25 +1,102 @@
<script setup lang="ts">
import { faBroom, faChevronDown, faChevronRight, faStar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
import type { ToolSectionLabel } from "@/stores/toolStore";
import { useUserStore } from "@/stores/userStore";
import { PANEL_LABEL_IDS } from "../panelViews";
import ToolPanelLinks from "./ToolPanelLinks.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
const props = defineProps<{
definition: ToolSectionLabel;
const props = withDefaults(
defineProps<{
definition: ToolSectionLabel;
collapsed?: boolean;
}>(),
{
collapsed: undefined,
},
);
const emit = defineEmits<{
(e: "toggle", labelId: string): void;
}>();
const favoritesLabelIds = new Set<string>([PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL, PANEL_LABEL_IDS.FAVORITES_LABEL]);
const collapsibleLabelIds = new Set<string>([
PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL,
PANEL_LABEL_IDS.FAVORITES_LABEL,
PANEL_LABEL_IDS.RECENT_TOOLS_LABEL,
]);
const description = computed(() => props.definition.description || undefined);
const isFavoritesDivider = computed(() => favoritesLabelIds.has(props.definition.id));
const isRecentLabel = computed(() => props.definition.id === PANEL_LABEL_IDS.RECENT_TOOLS_LABEL);
const isCollapsible = computed(() => collapsibleLabelIds.has(props.definition.id) && props.collapsed !== undefined);
const isCollapsed = computed(() => props.collapsed ?? false);
const toggleIcon = computed(() => (isCollapsed.value ? faChevronRight : faChevronDown));
const { clearRecentTools } = useUserStore();
function onToggle() {
if (isCollapsible.value) {
emit("toggle", props.definition.id);
}
}
function onKeydown(event: KeyboardEvent) {
if (!isCollapsible.value) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
emit("toggle", props.definition.id);
}
}
</script>
<template>
<div v-b-tooltip.topright.hover.noninteractive class="tool-panel-label" tabindex="0" :title="description">
{{ definition.text }}
<ToolPanelLinks :links="definition.links || undefined" />
<div
v-b-tooltip.topright.hover.noninteractive
:class="['tool-panel-label', 'tool-panel-divider', { 'tool-panel-label-clickable': isCollapsible }]"
:tabindex="isCollapsible ? 0 : undefined"
:title="description"
:role="isCollapsible ? 'button' : undefined"
:aria-expanded="isCollapsible ? (!isCollapsed ? 'true' : 'false') : undefined"
v-on="isCollapsible ? { click: onToggle, keydown: onKeydown } : {}">
<span class="tool-panel-label-divider-text tool-panel-divider-text">
<FontAwesomeIcon
v-if="isCollapsible"
:icon="toggleIcon"
class="tool-panel-label-toggle tool-panel-divider-toggle" />
<FontAwesomeIcon
v-if="isFavoritesDivider"
:icon="faStar"
class="tool-panel-label-divider-icon tool-panel-divider-icon" />
{{ definition.text }}
<GButton
v-if="isRecentLabel"
class="tool-panel-label-divider-action tool-panel-divider-action"
size="small"
color="grey"
icon-only
transparent
title="Clear recent tools"
data-description="clear-recent-tools"
@click.stop="clearRecentTools">
<FontAwesomeIcon :icon="faBroom" />
</GButton>
<ToolPanelLinks v-if="definition.links" :links="definition.links" />
</span>
</div>
</template>
<style scoped lang="scss">
@import "@/style/scss/theme/blue.scss";
@import "@/style/scss/tool-panel-divider.scss";
.tool-panel-label {
&:deep(.tool-panel-links) {
display: none;
@@ -33,4 +110,9 @@ const description = computed(() => props.definition.description || undefined);
}
}
}
.tool-panel-label-clickable {
cursor: pointer;
user-select: none;
}
</style>
@@ -1,3 +1,18 @@
<script setup lang="ts">
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
const props = defineProps<{
links: Record<string, string>;
}>();
const link = computed(() => {
const links = Object.values(props.links);
return links.length > 0 ? links[0] : null;
});
</script>
<template>
<span v-if="link" class="tool-panel-links">
<a :href="link" target="_blank" style="display: inline">
@@ -6,29 +21,3 @@
</a>
</span>
</template>
<script>
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
export default {
components: { FontAwesomeIcon },
props: {
links: {
type: Object,
default: () => ({}),
},
},
data() {
return {
faExternalLinkAlt,
};
},
computed: {
link() {
const links = Object.values(this.links || {});
return links.length > 0 ? links[0] : null;
},
},
};
</script>
@@ -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<Record<string, Tool | ToolSection>>,
type: Object as PropType<Record<string, ToolPanelItem>>,
required: true,
},
useWorker: {
@@ -62,7 +62,7 @@ const { searchWorker } = storeToRefs(toolStore);
interface RequestPayload {
tools: Tool[];
query: string;
currentPanel: Record<string, Tool | ToolSection>;
currentPanel: Record<string, ToolPanelItem>;
}
interface SearchEventQuery {
@@ -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<Vue>) {
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<Vue>) {
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"]);
});
});
@@ -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<string>("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<Props>(), {
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<Array<[string, ToolPanelItem]>>(() => {
// 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;
}
</script>
<template>
<div v-if="isSection && hasElements" class="tool-panel-section">
<div v-if="isToolSection(props.category) && elems.length > 0" class="tool-panel-section">
<div
v-b-tooltip.topright.hover.noninteractive
:class="['toolSectionTitle', `tool-menu-section-${sectionName}`]"
:title="title">
class="toolSectionTitle tool-panel-divider"
:title="props.category.description || undefined">
<a
class="title-link d-flex justify-content-between align-items-center"
class="title-link tool-panel-divider-link"
href="javascript:void(0)"
role="button"
:aria-expanded="opened"
@click="toggleMenu()">
<div>
<span class="tool-panel-divider-text">
<span class="name">
{{ name }}
{{ props.category.title || props.category.name }}
</span>
<ToolPanelLinks :links="links" />
</div>
<button
v-if="isSection && props.hasFilterButton"
v-b-tooltip.hover.noninteractive.bottom
title="Show full section"
class="inline-icon-button"
@click.stop="emit('onFilter', `section:${toolSection.name}`)">
<FontAwesomeIcon :icon="faFilter" />
</button>
<ToolPanelLinks v-if="props.category.links" :links="props.category.links" />
<button
v-if="props.hasFilterButton"
v-b-tooltip.hover.noninteractive.bottom
title="Show full section"
class="inline-icon-button"
@click.stop="emit('onFilter', `section:${props.category.name}`)">
<FontAwesomeIcon :icon="faFilter" />
</button>
</span>
</a>
</div>
<transition name="slide">
<div v-if="opened" data-description="opened tool panel section">
<template v-for="[key, el] in sortedElements">
<ToolPanelLabel
v-if="toolSectionLabel.text || el.model_class === 'ToolSectionLabel'"
:key="key"
:definition="el" />
v-if="isToolSectionLabel(el)"
:key="`label-${key}`"
:definition="el"
:collapsed="getCollapsedState(el.id)"
@toggle="onLabelToggle" />
<Tool
v-else
:key="key"
v-else-if="isTool(el)"
:key="`tool-${key}`"
class="ml-2"
:tool="el"
:tool-key="toolKey"
:hide-name="hideName"
:operation-title="operationTitle"
:operation-icon="operationIcon"
@onOperation="onOperation"
:show-favorite-button="props.showFavoriteButton || searchActive"
@onClick="onClick" />
</template>
</div>
</transition>
</div>
<div v-else>
<ToolPanelLabel v-if="toolSectionLabel.text" :definition="toolSectionLabel" />
<Tool
v-else
:tool="category"
:hide-name="hideName"
:operation-title="operationTitle"
:operation-icon="operationIcon"
@onOperation="onOperation"
@onClick="onClick" />
</div>
<ToolPanelLabel
v-else-if="isToolSectionLabel(props.category)"
:definition="props.category"
:collapsed="getCollapsedState(props.category.id)"
@toggle="onLabelToggle" />
<Tool
v-else-if="isTool(props.category)"
:tool="props.category"
:hide-name="hideName"
:show-favorite-button="props.showFavoriteButton || searchActive"
@onClick="onClick" />
</template>
<style lang="scss" scoped>
@@ -220,8 +240,8 @@ function toggleMenu(nextState = !opened.value) {
font-size: 75%;
padding: 0em 0.5em;
}
.tool-panel-label {
background: darken($panel-bg-color, 5%);
.tool-panel-label:not(.tool-panel-divider) {
border-left: 0.25rem solid darken($panel-bg-color, 25%);
font-size: $h5-font-size;
font-weight: 600;
@@ -231,13 +251,17 @@ function toggleMenu(nextState = !opened.value) {
text-transform: uppercase;
}
.tool-panel-section .tool-panel-label {
.tool-panel-section .tool-panel-label:not(.tool-panel-divider) {
/* labels within subsections */
margin-left: 1.5rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.tool-panel-section .tool-panel-label.tool-panel-divider {
margin-left: 1.5rem;
}
.slide-enter-active {
-moz-transition-duration: 0.2s;
-webkit-transition-duration: 0.2s;
@@ -21,6 +21,7 @@ const groupsDefinitions = [
{ type: "publication", title: "...from Publication" },
{ type: "training", title: "...for Training" },
];
const groupedPanelViewTypes = new Set(groupsDefinitions.map((group) => group.type));
const props = defineProps<{
/** Whether the menu is compact: doesn't take up full width of the parent when `true`.
@@ -35,6 +36,8 @@ const { currentPanelView, loading, panels } = storeToRefs(toolStore);
const panelName = ref("");
const defaultPanelView = computed(() => panels.value["default"] || Object.values(panels.value)[0]);
const currentPanel = computed(() => panels.value[currentPanelView.value]);
const isFavoritesView = computed(() => currentPanel.value?.view_type === "favorites");
const panelIcon = computed<IconDefinition | null>(() => {
if (
@@ -58,13 +61,18 @@ const styleClasses = computed(() => {
const toolPanelHeader = computed(() => {
if (loading.value && panelName.value) {
return localize(panelName.value);
} else if (currentPanelView.value !== "default" && panels.value && panels.value[currentPanelView.value]?.name) {
return localize(panels.value[currentPanelView.value]?.name);
} else if (currentPanelView.value !== "default" && panels.value && currentPanel.value?.name) {
return localize(currentPanel.value.name);
} else {
return localize("Tools");
}
});
const headingClass = computed(() =>
currentPanelView.value !== "default" && !isFavoritesView.value ? "font-italic" : "",
);
const showPanelIcon = computed(() => !!panelIcon.value && !loading.value);
const groupedPanelViews = computed(() => {
const groups = [];
for (const group of groupsDefinitions) {
@@ -81,7 +89,16 @@ const groupedPanelViews = computed(() => {
return groups;
});
const ungroupedPanelViews = computed(() => panelViewsOfType("generic"));
const ungroupedPanelViews = computed(() => {
const defaultPanelId = defaultPanelView.value?.id;
return Object.values(panels.value).filter((panel) => {
if (!panel || panel.id === defaultPanelId) {
return false;
}
const viewType = panel.view_type;
return viewType ? !groupedPanelViewTypes.has(viewType) : false;
});
});
function panelViewsOfType(panelViewType: string) {
const panelViews = [];
@@ -121,16 +138,11 @@ async function updatePanelView(panel: Panel) {
<div class="d-flex panel-view-selector justify-content-between flex-gapx-1">
<div>
<FontAwesomeIcon
v-if="panelIcon && !loading"
v-if="showPanelIcon && !isFavoritesView"
class="mr-1"
:icon="panelIcon"
data-description="panel view header icon" />
<Heading
id="toolbox-heading"
:class="toolPanelHeader !== 'Tools' && 'font-italic'"
h2
inline
size="sm">
<Heading id="toolbox-heading" :class="headingClass" h2 inline size="sm">
<span v-if="loading && panelName">
<LoadingSpan :message="toolPanelHeader" />
</span>
@@ -19,12 +19,10 @@ const emit = defineEmits<{
const icon = computed(() => {
const viewType = props.panelView.view_type;
if (props.currentPanelView === props.panelView.id) {
return faCheck;
} else {
return types_to_icons[viewType] || faEye;
}
return types_to_icons[viewType] || faEye;
});
const isSelected = computed(() => props.currentPanelView === props.panelView.id);
</script>
<template>
@@ -32,9 +30,15 @@ const icon = computed(() => {
class="ml-1"
:title="props.panelView.description"
:data-panel-id="panelView.id"
:active="props.currentPanelView === props.panelView.id"
:active="isSelected"
@click="emit('onSelect', props.panelView)">
<FontAwesomeIcon :icon="icon" data-description="panel view item icon" fixed-width />
<span v-localize>{{ props.panelView.name }}</span>
<FontAwesomeIcon :icon="icon" class="mr-1" fixed-width />
<span v-localize>{{ panelView.name }}</span>
<FontAwesomeIcon
v-if="isSelected"
:icon="faCheck"
class="ml-1"
data-description="panel view item icon"
fixed-width />
</BDropdownItem>
</template>
+329 -43
View File
@@ -1,29 +1,40 @@
<script setup lang="ts">
import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BBadge } from "bootstrap-vue";
import { BAlert, BBadge } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, type ComputedRef, type Ref, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useGlobalUploadModal } from "@/composables/globalUploadModal";
import { useToolRouting } from "@/composables/route";
import type { Tool, ToolSection as ToolSectionType } from "@/stores/toolStore";
import { useFavoriteSearchResults, useToolPanelFavorites } from "@/composables/toolPanelFavorites";
import type { Tool, ToolPanelItem, ToolSection as ToolSectionType, ToolSectionLabel } from "@/stores/toolStore";
import { useToolStore } from "@/stores/toolStore";
import { useUserStore } from "@/stores/userStore";
import localize from "@/utils/localization";
import { PANEL_LABEL_IDS } from "./panelViews";
import {
buildToolEntries,
buildToolLabel,
buildToolSection,
FAVORITES_KEYS,
filterPanelByToolIds,
filterTools,
getValidPanelItems,
getValidToolsInCurrentView,
getValidToolsInEachSection,
getVisibleTools,
UNSECTIONED_SECTION,
} from "./utilities";
import GButton from "../BaseComponents/GButton.vue";
import ToolSearch from "./Common/ToolSearch.vue";
import ToolSection from "./Common/ToolSection.vue";
const SECTION_IDS_TO_EXCLUDE = ["expression_tools"]; // if this isn't the Workflow Editor panel
const LOGIN_ROUTE = "/login/start";
/** Section IDs that are only valid for the workflow editor toolbox, and should be excluded from the regular toolbox. */
const WORKFLOW_ONLY_SECTION_IDS = ["expression_tools"];
const TOOLS_LIST_ROUTE = "/tools/list";
const { openGlobalUploadModal } = useGlobalUploadModal();
const { routeToTool } = useToolRouting();
@@ -34,27 +45,47 @@ const emit = defineEmits<{
}>();
interface Props {
/** Whether this is the toolbox in the workflow editor
* @default false
*/
workflow?: boolean;
/** Whether to use a search worker for searching tools
* @default true
*/
useSearchWorker?: boolean;
/** This is used to automatically apply the "#favorites"
* filter when this prop is true.
* @default false
*/
showFavorites?: boolean;
/** Whether this is the `My Tools` panel
* @default false
*/
favoritesDefault?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
workflow: false,
useSearchWorker: true,
showFavorites: false,
favoritesDefault: false,
});
const { isAnonymous } = storeToRefs(useUserStore());
const query = ref("");
const queryPending = ref(false);
const showSections = ref(props.workflow);
const results: Ref<string[]> = ref([]);
const resultPanel: Ref<Record<string, Tool | ToolSectionType> | null> = ref(null);
const closestTerm: Ref<string | null> = ref(null);
const results = ref<string[]>([]);
const resultPanel = ref<Record<string, Tool | ToolSectionType> | null>(null);
const closestTerm = ref<string | null>(null);
const toolStore = useToolStore();
const { currentPanelView, currentToolSections } = storeToRefs(toolStore);
const { currentPanelView, currentToolSections, defaultPanelView, toolSections } = storeToRefs(toolStore);
const hasResults = computed(() => results.value.length > 0);
const queryTooShort = computed(() => query.value && query.value.length < 3);
const queryFinished = computed(() => query.value && queryPending.value != true);
@@ -64,20 +95,24 @@ watch(
() => query.value,
() => {
queryPending.value = true;
if (FAVORITES_KEYS.includes(query.value)) {
emit("update:show-favorites", true);
} else {
emit("update:show-favorites", false);
if (!props.favoritesDefault) {
if (FAVORITES_KEYS.includes(query.value)) {
emit("update:show-favorites", true);
} else {
emit("update:show-favorites", false);
}
}
},
);
watch(
() => props.showFavorites,
(newValue) => {
if (newValue) {
query.value = "#favorites";
} else {
query.value = "";
if (!props.favoritesDefault) {
if (newValue) {
query.value = "#favorites";
} else {
query.value = "";
}
}
},
);
@@ -88,51 +123,239 @@ watch(
},
);
/** `toolsById` from `toolStore`, except it only has valid tools for `props.workflow` value */
/**
* `toolsById` from `toolStore`
*
* Although, in the case of `props.workflow` (workflow editor toolbox),
* it only has tools that are valid for the workflow editor.
*/
const localToolsById = computed(() => {
if (toolStore.toolsById && Object.keys(toolStore.toolsById).length > 0) {
return getValidToolsInCurrentView(
toolStore.toolsById,
props.workflow,
!props.workflow ? SECTION_IDS_TO_EXCLUDE : [],
);
return getVisibleTools(toolStore.toolsById, props.workflow, !props.workflow ? WORKFLOW_ONLY_SECTION_IDS : []);
}
return {};
});
/** `currentPanel` from `toolStore`, except it only has valid tools and sections for `props.workflow` value */
const localSectionsById = computed(() => {
const validToolIdsInCurrentView = Object.keys(localToolsById.value);
const localToolIds = computed(() => new Set(Object.keys(localToolsById.value)));
/**
* `currentToolSections` from `toolStore`
*
* Although, in the case of `props.workflow` (workflow editor toolbox),
* it only has sections that are valid for the workflow editor.
*/
const localSectionsById = computed<Record<string, ToolPanelItem>>(() => {
// Looking within each `ToolSection`, and filtering on child elements
const sectionEntries = getValidToolsInEachSection(validToolIdsInCurrentView, currentToolSections.value);
const sectionEntries = getValidToolsInEachSection(localToolIds.value, currentToolSections.value);
// Looking at each item in the panel now (not within each child)
return getValidPanelItems(
sectionEntries,
validToolIdsInCurrentView,
!props.workflow ? SECTION_IDS_TO_EXCLUDE : [],
) as Record<string, Tool | ToolSectionType>;
return getValidPanelItems(sectionEntries, localToolIds.value, !props.workflow ? WORKFLOW_ONLY_SECTION_IDS : []);
});
/**
* Same as `localSectionsById` except for the default panel view.
*
* This is used mainly to show a sectioned results view in the `My Tools` panel when there is a search query, since the
* `My Tools` panel doesn't have sections of it's own; so we use the default view's sections as the section for each result.
*
* @returns
* - `toolSections[defaultPanelView]` from `toolStore` - if the `defaultPanelView` is set
* - `null` if the `defaultPanelView` is not set or if it doesn't have sections in `toolSections`
*/
const defaultSectionsById = computed<Record<string, ToolPanelItem> | null>(() => {
const defaultPanelSections = defaultPanelView.value ? toolSections.value[defaultPanelView.value] : null;
if (!defaultPanelSections) {
return null;
}
// Looking within each `default` view `ToolSection`, and filtering on child elements
const sectionEntries = getValidToolsInEachSection(localToolIds.value, defaultPanelSections);
return getValidPanelItems(sectionEntries, localToolIds.value, !props.workflow ? WORKFLOW_ONLY_SECTION_IDS : []);
});
// Use composable for favorites and recent tools management
const {
favoritesCollapsed,
recentToolsCollapsed,
favoriteToolIds,
favoriteToolIdSet,
favoriteToolIdsInPanel,
recentToolIdsToShow,
recentToolIdsToShowSet,
} = useToolPanelFavorites(localToolsById);
// Use composable for search results filtering
const { favoriteResults, nonFavoriteResults, hasMixedResults } = useFavoriteSearchResults(results, favoriteToolIdSet);
const toolsCount = computed(() => toolsList.value.length);
/** Whether to show the "empty favorites" alert:
* - If the current panel is the `My Tools` panel
* - There is no search query
* - The user has no favorite tools
*/
const showEmptyFavorites = computed(() => props.favoritesDefault && !query.value && favoriteToolIds.value.length === 0);
const resultsSet = computed(() => new Set(results.value));
const nonFavoriteResultsSet = computed(() => new Set(nonFavoriteResults.value));
/**
* A panel with just one section that has all tools in it. Only used in the case that
* `defaultSectionsById` is `null` (`default` panel view doesn't exist or doesn't have sections yet)
*/
const allToolsPanel = computed(() => {
return {
[PANEL_LABEL_IDS.ALL_TOOLS_SECTION]: {
model_class: "ToolSection",
id: PANEL_LABEL_IDS.ALL_TOOLS_SECTION,
name: localize("All tools"),
tools: Object.keys(localToolsById.value),
},
} as Record<string, Tool | ToolSectionType>;
});
/** The `currentPanel` which will be used by the tool search to search tools */
const searchPanelSections = computed(() => {
if (props.favoritesDefault) {
return defaultSectionsById.value || allToolsPanel.value;
}
return localSectionsById.value;
});
const toolsList = computed(() => Object.values(localToolsById.value));
/**
* For search results, this creates a panel which - if results are not mixed between favorite
* and non-favorite tools - contains just the tools that are in the search results; if
* results are mixed, it creates two sections: one for favorite results and one for non-favorite results.
*/
const flatResultsPanel = computed<Record<string, Tool | ToolSectionLabel> | null>(() => {
if (!hasResults.value) {
return null;
}
if (!hasMixedResults.value) {
return filterTools(localToolsById.value, results.value);
}
const entries: Array<[string, Tool | ToolSectionLabel]> = [];
entries.push([
PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL,
buildToolLabel(PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL, localize("Favorites")),
]);
if (!favoritesCollapsed.value) {
entries.push(...buildToolEntries(favoriteResults.value, localToolsById.value));
}
entries.push([
PANEL_LABEL_IDS.SEARCH_RESULTS_LABEL,
buildToolLabel(PANEL_LABEL_IDS.SEARCH_RESULTS_LABEL, localize("Search results")),
]);
entries.push(...buildToolEntries(nonFavoriteResults.value, localToolsById.value));
return Object.fromEntries(entries);
});
/**
* The custom/client built `My Tools` panel when `props.favoritesDefault` is true.
* This panel shows favorite and recent tools when there is no search query.
*
* Unlike panel views returned by the `toolStore`, this panel is built here on the client side.
*/
const favoritesDefaultPanel = computed<Record<string, ToolPanelItem> | null>(() => {
if (!props.favoritesDefault || query.value) {
return null;
}
const entries: Array<[string, ToolPanelItem]> = [];
const recents = recentToolIdsToShow.value;
const favorites = favoriteToolIdsInPanel.value;
if (recents.length > 0) {
entries.push([
PANEL_LABEL_IDS.RECENT_TOOLS_LABEL,
buildToolLabel(PANEL_LABEL_IDS.RECENT_TOOLS_LABEL, localize("Recent tools")),
]);
if (!recentToolsCollapsed.value) {
entries.push(...buildToolEntries(recents, localToolsById.value));
}
}
entries.push([
PANEL_LABEL_IDS.FAVORITES_LABEL,
buildToolLabel(PANEL_LABEL_IDS.FAVORITES_LABEL, localize("Favorites")),
]);
if (!favoritesCollapsed.value) {
if (showEmptyFavorites.value) {
entries.push([
PANEL_LABEL_IDS.FAVORITES_EMPTY_ALERT,
buildToolLabel(PANEL_LABEL_IDS.FAVORITES_EMPTY_ALERT, ""),
]);
} else {
entries.push(...buildToolEntries(favorites, localToolsById.value));
}
}
return Object.fromEntries(entries);
});
/**
* For tool search results, this returns a results panel.
*/
const sectionedResultsPanel = computed<Record<string, ToolPanelItem> | null>(() => {
if (!hasResults.value) {
return null;
}
/** The base panel to filter results from.
*
* If `props.favoritesDefault` is true, we use the `default` panel view's sections
* from `toolStore` as the base panel to filter results from.
* Otherwise, we use the `resultPanel` returned from the search.
*/
const basePanel =
props.favoritesDefault && defaultSectionsById.value ? defaultSectionsById.value : resultPanel.value;
if (!basePanel) {
return null;
}
if (!hasMixedResults.value) {
return props.favoritesDefault && defaultSectionsById.value
? filterPanelByToolIds(basePanel, resultsSet.value)
: basePanel;
}
const otherResultsPanel = filterPanelByToolIds(basePanel, nonFavoriteResultsSet.value);
const favoritesSection = buildToolSection(
PANEL_LABEL_IDS.FAVORITES_RESULTS_SECTION,
localize("Favorites"),
favoriteResults.value,
);
return {
[PANEL_LABEL_IDS.FAVORITES_RESULTS_SECTION]: favoritesSection,
...otherResultsPanel,
};
});
/**
* If not searching or no results, we show all tools in sections (default)
*
* If we have results for search, we show tools in sections or just tools,
* based on whether `showSections` is true or false
*/
const localPanel: ComputedRef<Record<string, Tool | ToolSectionType> | null> = computed(() => {
const localPanel = computed<Record<string, ToolPanelItem> | null>(() => {
// We are in the `My Tools` panel and there is no search query, show the custom `My Tools` panel with favorites and recents sections
if (props.favoritesDefault && !query.value) {
return favoritesDefaultPanel.value || {};
}
// There is a search query and results, show the search results panel (sectioned or flat based on `showSections`)
if (hasResults.value) {
if (showSections.value) {
return resultPanel.value;
return sectionedResultsPanel.value || flatResultsPanel.value;
} else {
return filterTools(localToolsById.value, results.value) as Record<string, Tool | ToolSectionType>;
return flatResultsPanel.value;
}
} else {
return localSectionsById.value;
}
if (props.favoritesDefault) {
return {};
}
return localSectionsById.value;
});
const buttonIcon = computed(() => (showSections.value ? faEyeSlash : faEye));
@@ -191,6 +414,29 @@ function onSearchQuery(q: string) {
function onToggle() {
showSections.value = !showSections.value;
}
/** Stores the localStorage state of collapsed labels for the favorites and recents sections in the `My Tools` panel. */
const collapsedLabels = computed(() => {
if (props.favoritesDefault) {
return {
[PANEL_LABEL_IDS.FAVORITES_LABEL]: favoritesCollapsed.value,
[PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL]: favoritesCollapsed.value,
[PANEL_LABEL_IDS.RECENT_TOOLS_LABEL]: recentToolsCollapsed.value,
};
}
return null;
});
function onLabelToggle(labelId: string) {
if (!props.favoritesDefault) {
return;
}
if (labelId === PANEL_LABEL_IDS.FAVORITES_LABEL || labelId === PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL) {
favoritesCollapsed.value = !favoritesCollapsed.value;
} else if (labelId === PANEL_LABEL_IDS.RECENT_TOOLS_LABEL) {
recentToolsCollapsed.value = !recentToolsCollapsed.value;
}
}
</script>
<template>
@@ -200,7 +446,7 @@ function onToggle() {
:current-panel-view="currentPanelView"
:placeholder="localize('search tools')"
:tools-list="toolsList"
:current-panel="localSectionsById"
:current-panel="searchPanelSections"
:query="query"
:query-pending="queryPending"
:use-worker="useSearchWorker"
@@ -233,14 +479,51 @@ function onToggle() {
<div class="unified-panel-body">
<div class="toolMenuContainer">
<div v-if="localPanel" class="toolMenu">
<div v-for="(panel, key) in localPanel" :key="key">
<div v-for="(panelItem, key) in localPanel" :key="key">
<div v-if="panelItem?.id === PANEL_LABEL_IDS.FAVORITES_EMPTY_ALERT" class="tool-panel-empty">
<BAlert variant="info" show>
<template v-if="!isAnonymous">
You haven't favorited any tools yet. Search the toolbox or use
<GButton
class="ml-1"
size="small"
color="blue"
:to="TOOLS_LIST_ROUTE"
data-description="discover-tools">
Discover Tools
</GButton>
to explore {{ toolsCount }} community curated tools.
</template>
<template v-else>
You need to
<GButton
class="ml-1"
size="small"
color="blue"
:to="LOGIN_ROUTE"
data-description="login button">
Login
</GButton>
to favorite tools and have them appear in this section.
</template>
</BAlert>
</div>
<ToolSection
v-if="panel"
:category="panel || {}"
v-else-if="panelItem"
:category="panelItem"
:query-filter="hasResults ? query : undefined"
:has-filter-button="hasResults && currentPanelView === 'default'"
:has-filter-button="
hasResults &&
currentPanelView === 'default' &&
panelItem.id !== PANEL_LABEL_IDS.FAVORITES_RESULTS_SECTION &&
panelItem.id !== UNSECTIONED_SECTION.id
"
:search-active="hasResults"
:show-favorite-button="recentToolIdsToShowSet.has(panelItem.id)"
:collapsed-labels="collapsedLabels"
@onClick="onToolClick"
@onFilter="onSectionFilter" />
@onFilter="onSectionFilter"
@onLabelToggle="onLabelToggle" />
</div>
</div>
</div>
@@ -252,4 +535,7 @@ function onToggle() {
.toolTitle {
overflow-wrap: anywhere;
}
.tool-panel-empty {
padding: 0.5rem;
}
</style>
@@ -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<string, Tool | ToolSection>;
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<string, Tool>,
);
}
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);
});
});
+22 -7
View File
@@ -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`);
});
});
+22 -4
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { BAlert, BBadge } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useToolStore } from "@/stores/toolStore";
import { useUserStore } from "@/stores/userStore";
import localize from "@/utils/localization";
import { errorMessageAsString } from "@/utils/simple-error";
import { MY_PANEL_VIEW_ID } from "./panelViews";
import LoadingSpan from "../LoadingSpan.vue";
import ActivityPanel from "./ActivityPanel.vue";
import FavoritesButton from "./Buttons/FavoritesButton.vue";
@@ -25,11 +28,25 @@ const emit = defineEmits<{
(e: "onInsertTool", toolId: string, toolName: string): void;
}>();
const { currentPanelView, currentToolSections, isPanelPopulated, toolSections } = storeToRefs(toolStore);
const { currentPanelView, currentToolSections, isPanelPopulated, toolSections, toolsById } = storeToRefs(toolStore);
const isMyPanel = computed(() => currentPanelView.value === MY_PANEL_VIEW_ID);
const errorMessage = ref("");
const panelsFetched = ref(false);
const showFavorites = ref(false);
const toolsCount = computed(() => Object.keys(toolsById.value || {}).length);
function formatToolsCount(count: number) {
if (count < 1000) {
return `${count}`;
}
const thousands = Math.floor(count / 1000);
return `${thousands}k+`;
}
const discoverToolsLabel = computed(() => {
return `${localize("Discover")} ${formatToolsCount(toolsCount.value)} ${localize("Tools")}`;
});
async function initializePanel() {
try {
@@ -72,19 +89,20 @@ initializePanel();
title="Tools"
aria-labelledby="toolbox-heading"
class="toolbox-panel"
go-to-all-title="Discover Tools"
:go-to-all-title="discoverToolsLabel"
go-to-all-data-description="toolbox discover tools"
:href="!props.workflow ? `/tools/list` : undefined">
<template v-slot:activity-panel-header-top>
<PanelViewMenu />
</template>
<template v-slot:header-buttons>
<FavoritesButton v-model="showFavorites" />
<FavoritesButton v-if="!isMyPanel" v-model="showFavorites" />
</template>
<ToolBox
v-if="isPanelPopulated"
:workflow="props.workflow"
:show-favorites.sync="showFavorites"
:favorites-default="isMyPanel"
:use-search-worker="useSearchWorker"
@onInsertTool="onInsertTool" />
<div v-else-if="errorMessage" data-description="tool panel error message">
@@ -0,0 +1,14 @@
export const MY_PANEL_VIEW_ID = "my_panel";
export const MY_PANEL_VIEW_NAME = "My Tools";
export const MY_PANEL_VIEW_DESCRIPTION = "Search all tools and view your favorite tools.";
export const MY_PANEL_VIEW_TYPE = "favorites";
export const PANEL_LABEL_IDS = {
FAVORITES_RESULTS_SECTION: "favorites_results",
FAVORITES_RESULTS_LABEL: "favorites_results_label",
SEARCH_RESULTS_LABEL: "search_results_label",
FAVORITES_LABEL: "favorites_label",
RECENT_TOOLS_LABEL: "recent_tools_label",
FAVORITES_EMPTY_ALERT: "favorites_empty_alert",
ALL_TOOLS_SECTION: "all_tools",
} as const;
@@ -70,5 +70,13 @@
"description": null,
"view_type": "generic",
"searchable": true
},
"my_panel": {
"id": "my_panel",
"model_class": "StaticToolPanelView",
"name": "My Tools",
"description": "Search all tools and view your favorite tools.",
"view_type": "favorites",
"searchable": true
}
}
}
+226 -2
View File
@@ -8,10 +8,12 @@ import {
createWhooshQuery,
determineWidth,
filterTools,
getValidPanelItems,
getValidToolsInEachSection,
searchObjectsByKeys,
type SearchCommonKeys,
} from "./utilities";
import type { Tool, ToolSection } from "@/stores/toolStore";
import type { Tool, ToolPanelItem, ToolSection, ToolSectionLabel } from "@/stores/toolStore";
describe("test helpers in tool searching utilities and panel handling", () => {
it("panel width determination", () => {
@@ -36,7 +38,7 @@ const tempToolPanel = {
id: "fasta/fastq",
name: "FASTA/FASTQ",
},
},
} as unknown as Record<string, ToolSection>,
};
const tempToolsList = {
tools: {
@@ -245,3 +247,225 @@ describe("test helpers in tool searching utilities", () => {
expect(filteredToolIds).toHaveLength(2);
});
});
describe("createSortedResultPanel", () => {
it("orders results by descending order and groups into sections", () => {
const matchedTools = [
{ id: "__FILTER_FAILED_DATASETS__", order: 1 },
{ id: "liftOver1", order: 3 },
{ id: "__ZIP_COLLECTION__", order: 2 },
];
const { idResults, resultPanel } = createSortedResultPanel(matchedTools, toolsListInPanel);
// idResults should be sorted by order descending: liftOver1 (3), __ZIP_COLLECTION__ (2), __FILTER_FAILED_DATASETS__ (1)
expect(idResults).toEqual(["liftOver1", "__ZIP_COLLECTION__", "__FILTER_FAILED_DATASETS__"]);
// tools are grouped into their respective sections
const collectionOps = resultPanel["collection_operations"] as ToolSection;
expect(collectionOps.tools).toEqual(["__ZIP_COLLECTION__", "__FILTER_FAILED_DATASETS__"]);
const liftOver = resultPanel["liftOver"] as ToolSection;
expect(liftOver.tools).toEqual(["liftOver1"]);
});
it("does not duplicate tool ids when a tool appears in only one section", () => {
const matchedTools = [
{ id: "__FILTER_FAILED_DATASETS__", order: 0 },
{ id: "__FILTER_FAILED_DATASETS__", order: 0 },
];
const { idResults } = createSortedResultPanel(matchedTools, toolsListInPanel);
expect(idResults).toEqual(["__FILTER_FAILED_DATASETS__"]);
});
});
describe("getValidToolsInEachSection", () => {
it("filters section tools to only include valid tool IDs", () => {
const validIds = new Set(["__FILTER_FAILED_DATASETS__", "__ZIP_COLLECTION__", "liftOver1"]);
const entries = getValidToolsInEachSection(validIds, toolsListInPanel);
const collectionOps = entries.find(([id]) => id === "collection_operations");
expect(collectionOps).toBeDefined();
const collectionSection = collectionOps![1] as ToolSection;
expect(collectionSection.tools).toEqual(["__ZIP_COLLECTION__", "__FILTER_FAILED_DATASETS__"]);
const liftOver = entries.find(([id]) => id === "liftOver");
expect(liftOver).toBeDefined();
const liftOverSection = liftOver![1] as ToolSection;
expect(liftOverSection.tools).toEqual(["liftOver1"]);
});
it("removes all tools from a section when none are valid", () => {
const validIds = new Set(["liftOver1"]);
const entries = getValidToolsInEachSection(validIds, toolsListInPanel);
const collectionOps = entries.find(([id]) => id === "collection_operations");
expect(collectionOps).toBeDefined();
const collectionSection = collectionOps![1] as ToolSection;
expect(collectionSection.tools).toEqual([]);
});
it("preserves ToolSectionLabels within a section's tools array", () => {
const label: ToolSectionLabel = {
model_class: "ToolSectionLabel",
id: "inner_label",
text: "Inner Label",
};
const panel: Record<string, Tool | ToolSection> = {
test_section: {
model_class: "ToolSection",
id: "test_section",
name: "Test Section",
tools: ["tool_a", label as unknown as string, "tool_b"],
} as ToolSection,
};
const validIds = new Set(["tool_a"]);
const entries = getValidToolsInEachSection(validIds, panel);
const section = entries.find(([id]) => id === "test_section");
expect(section).toBeDefined();
const sectionData = section![1] as ToolSection;
// "tool_a" is valid, the label (non-string) is kept, "tool_b" is filtered out
expect(sectionData.tools).toHaveLength(2);
expect(sectionData.tools![0]).toBe("tool_a");
expect(sectionData.tools![1]).toBe(label);
});
it("passes through items without a tools array unchanged", () => {
const entries = getValidToolsInEachSection(new Set(), toolsListInPanel);
// testlabel1 is a ToolSectionLabel with no tools array
const labelEntry = entries.find(([id]) => id === "testlabel1");
expect(labelEntry).toBeDefined();
const labelData = labelEntry![1] as ToolSectionLabel;
expect(labelData.model_class).toBe("ToolSectionLabel");
expect(labelData.id).toBe("testlabel1");
});
it("does not mutate the original panel sections", () => {
const panel: Record<string, Tool | ToolSection> = {
sec: {
model_class: "ToolSection",
id: "sec",
name: "Sec",
tools: ["tool_x", "tool_y"],
} as ToolSection,
};
const originalTools = [...(panel["sec"] as ToolSection).tools!];
getValidToolsInEachSection(new Set(["tool_x"]), panel);
// the original section should not be modified
expect((panel["sec"] as ToolSection).tools).toEqual(originalTools);
});
it("returns entries in the same order as the panel", () => {
const entries = getValidToolsInEachSection(new Set(["__UNZIP_COLLECTION__", "liftOver1"]), toolsListInPanel);
const ids = entries.map(([id]) => id);
expect(ids).toEqual(["collection_operations", "liftOver", "testlabel1"]);
});
});
describe("getValidPanelItems", () => {
it("keeps sections with valid tools and removes empty sections", () => {
// First pass: filter tools in sections
const validIds = new Set(["__FILTER_FAILED_DATASETS__", "liftOver1"]);
const sectionEntries = getValidToolsInEachSection(validIds, toolsListInPanel);
const result = getValidPanelItems(sectionEntries, validIds);
// collection_operations has 1 valid tool, liftOver has 1 valid tool
expect(result["collection_operations"]).toBeDefined();
expect(result["liftOver"]).toBeDefined();
expect((result["collection_operations"] as ToolSection).tools).toEqual(["__FILTER_FAILED_DATASETS__"]);
expect((result["liftOver"] as ToolSection).tools).toEqual(["liftOver1"]);
});
it("removes sections whose tools are all filtered out", () => {
const validIds = new Set(["liftOver1"]);
const sectionEntries = getValidToolsInEachSection(validIds, toolsListInPanel);
const result = getValidPanelItems(sectionEntries, validIds);
// collection_operations has 0 valid tools so should be removed
expect(result["collection_operations"]).toBeUndefined();
expect(result["liftOver"]).toBeDefined();
});
it("excludes sections by excludedSectionIds", () => {
const validIds = new Set(["__FILTER_FAILED_DATASETS__", "liftOver1"]);
const sectionEntries = getValidToolsInEachSection(validIds, toolsListInPanel);
const result = getValidPanelItems(sectionEntries, validIds, ["liftOver"]);
expect(result["collection_operations"]).toBeDefined();
expect(result["liftOver"]).toBeUndefined();
});
it("keeps ToolSectionLabels (items without tools property)", () => {
const validIds = new Set(["liftOver1"]);
const sectionEntries = getValidToolsInEachSection(validIds, toolsListInPanel);
const result = getValidPanelItems(sectionEntries, validIds);
// testlabel1 is a ToolSectionLabel and should be kept
expect(result["testlabel1"]).toBeDefined();
expect((result["testlabel1"] as ToolSectionLabel).model_class).toBe("ToolSectionLabel");
});
it("keeps standalone Tool items when they are in validToolIdsInCurrentView", () => {
const standaloneTool = {
model_class: "Tool",
id: "standalone_tool",
name: "Standalone",
description: "A standalone tool",
} as unknown as Tool;
const items: [string, ToolPanelItem][] = [
["standalone_tool", standaloneTool as unknown as ToolSection],
[
"some_section",
{
model_class: "ToolSection",
id: "some_section",
name: "Some Section",
tools: ["standalone_tool"],
} as ToolSection,
],
];
const result = getValidPanelItems(items, new Set(["standalone_tool"]));
expect(result["standalone_tool"]).toBeDefined();
expect(result["some_section"]).toBeDefined();
});
it("pipeline: getValidToolsInEachSection → getValidPanelItems mirrors ToolBox.vue usage", () => {
// Simulates the localSectionsById computed in ToolBox.vue
const allToolIds = toolsList.map((t) => t.id);
const sectionEntries = getValidToolsInEachSection(new Set(allToolIds), toolsListInPanel);
const result = getValidPanelItems(sectionEntries, new Set(allToolIds));
// All sections with tools should be present
expect(result["collection_operations"]).toBeDefined();
expect(result["liftOver"]).toBeDefined();
// Label should be present
expect(result["testlabel1"]).toBeDefined();
// Tools should be unmodified (all are valid)
expect((result["collection_operations"] as ToolSection).tools).toHaveLength(4);
expect((result["liftOver"] as ToolSection).tools).toHaveLength(1);
});
it("pipeline with exclusions: excludes specified section ids", () => {
const allToolIds = toolsList.map((t) => t.id);
const sectionEntries = getValidToolsInEachSection(new Set(allToolIds), toolsListInPanel);
const result = getValidPanelItems(sectionEntries, new Set(allToolIds), ["collection_operations"]);
expect(result["collection_operations"]).toBeUndefined();
expect(result["liftOver"]).toBeDefined();
expect(result["testlabel1"]).toBeDefined();
});
it("returns empty object when all sections are excluded or empty", () => {
const sectionEntries = getValidToolsInEachSection(new Set(), toolsListInPanel);
const result = getValidPanelItems(sectionEntries, new Set());
// Only the label should remain (no tools property)
expect(result["collection_operations"]).toBeUndefined();
expect(result["liftOver"]).toBeUndefined();
expect(result["testlabel1"]).toBeDefined();
});
});
+131 -86
View File
@@ -8,16 +8,67 @@ import {
faNewspaper,
faProjectDiagram,
faSitemap,
faStar,
faUndo,
type IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import { orderBy } from "lodash";
import type { FilterSettings as ToolFilters, Tool, ToolSection, ToolSectionLabel } from "@/stores/toolStore";
import { isTool, isToolSection } from "@/api/tools";
import type {
FilterSettings as ToolFilters,
Tool,
ToolPanelItem,
ToolSection,
ToolSectionLabel,
} from "@/stores/toolStore";
import levenshteinDistance from "@/utils/levenshtein";
export const FAVORITES_KEYS = ["#favs", "#favorites", "#favourites"];
/** Build a ToolSection object */
export function buildToolSection(id: string, name: string, tools: string[]): ToolSection {
return {
model_class: "ToolSection",
id,
name,
tools,
};
}
/** Build a ToolSectionLabel object */
export function buildToolLabel(id: string, text: string): ToolSectionLabel {
return {
model_class: "ToolSectionLabel",
id,
text,
};
}
/** Build an array of [toolId, tool] entries from tool IDs and tools by ID map */
export function buildToolEntries(toolIds: string[], toolsById: Record<string, Tool>): Array<[string, Tool]> {
return toolIds.map((id) => [id, toolsById[id]] as [string, Tool]).filter(([, tool]) => tool !== undefined);
}
/** Filter panel to only include tools matching the provided tool IDs */
export function filterPanelByToolIds(
panel: Record<string, ToolPanelItem>,
toolIds: Set<string>,
): Record<string, Tool | ToolSection> {
const filtered: Record<string, Tool | ToolSection> = {};
for (const [key, item] of Object.entries(panel)) {
if ("tools" in item && item?.tools) {
const tools = item.tools.filter((toolId) => typeof toolId === "string" && toolIds.has(toolId));
if (tools.length > 0) {
filtered[key] = { ...item, tools };
}
} else if (isTool(item) && toolIds.has(item.id)) {
filtered[key] = item;
}
}
return filtered;
}
const FILTER_KEYS = {
id: ["id", "tool_id"],
panel_section_name: ["section", "panel_section_name"],
@@ -26,13 +77,13 @@ const FILTER_KEYS = {
const STRING_REPLACEMENTS: string[] = [" ", "-", "\\(", "\\)", "'", ":", `"`];
const MINIMUM_DL_LENGTH = 5; // for Demerau-Levenshtein distance
const MINIMUM_WORD_MATCH = 2; // for word match
const UNSECTIONED_SECTION = {
export const UNSECTIONED_SECTION: ToolSection = {
// to return a section for unsectioned tools
model_class: "ToolSection",
id: "unsectioned",
name: "Unsectioned Tools",
description: "Tools that don't appear under any section in the unsearched panel",
};
} as const;
export interface SearchCommonKeys {
[key: string]: number | undefined;
@@ -69,6 +120,7 @@ const TOOL_SECTION_SEARCH_KEYS: SearchCommonKeys = { exact: 4, startsWith: 3, na
/** Returns icon for tool panel `view_type` */
export const types_to_icons = {
default: faUndo,
favorites: faStar,
generic: faFilter,
ontology: faSitemap,
activity: faProjectDiagram,
@@ -164,13 +216,16 @@ export function filterTools(toolsById: Record<string, Tool>, results: string[])
return filteredTools;
}
/** Returns a `toolsById` object containing tools that meet required conditions
* based on params.
* @param toolsById - object of tools, keyed by id
* @param isWorkflowPanel - whether or not the ToolPanel is in Workflow Editor
* @param excludedSectionIds - ids for sections whose tools will be excluded
/** Returns a `toolsById` object containing tools that meet required conditions such as:
* - Not `hidden`
* - Not `disabled`
* - If in workflow editor panel, only tools that are `is_workflow_compatible`
* - Not in an excluded section (if `excludedSectionIds` provided)
* @param toolsById object of tools, keyed by id
* @param isWorkflowPanel whether or not the ToolPanel is in Workflow Editor
* @param excludedSectionIds ids for sections whose tools will be excluded
*/
export function getValidToolsInCurrentView(
export function getVisibleTools(
toolsById: Record<string, Tool>,
isWorkflowPanel = false,
excludedSectionIds: string[] = [],
@@ -195,27 +250,28 @@ export function getValidToolsInCurrentView(
/** Looks in each section of `currentPanel` and filters `section.tools` on `validToolIdsInCurrentView` */
export function getValidToolsInEachSection(
validToolIdsInCurrentView: string[],
currentPanel: Record<string, Tool | ToolSection>,
) {
// use a set for fast membership lookup
const idSet = new Set(validToolIdsInCurrentView);
validToolIdsInCurrentView: Set<string>,
currentPanel: Record<string, ToolPanelItem>,
): Array<[string, ToolPanelItem]> {
return Object.entries(currentPanel).map(([id, section]) => {
const validatedSection = { ...section } as ToolSection;
// assign sectionTools to avoid repeated getter access
const sectionTools = validatedSection.tools;
if (sectionTools && Array.isArray(sectionTools)) {
// filter on valid tools and panel labels in this section
validatedSection.tools = sectionTools.filter((toolId) => {
if (typeof toolId === "string" && idSet.has(toolId)) {
return true;
} else if (typeof toolId !== "string") {
// is a special case where there is a label within a section
return true;
}
});
if (isToolSection(section)) {
const validatedSection = { ...section };
// assign sectionTools to avoid repeated getter access
const sectionTools = validatedSection.tools;
if (sectionTools && Array.isArray(sectionTools)) {
// filter on valid tools and panel labels in this section
validatedSection.tools = sectionTools.filter((toolId) => {
if (typeof toolId === "string" && validToolIdsInCurrentView.has(toolId)) {
return true;
} else if (typeof toolId !== "string") {
// is a special case where there is a label within a section
return true;
}
});
}
return [id, validatedSection];
}
return [id, validatedSection];
return [id, section];
});
}
@@ -226,20 +282,18 @@ export function getValidToolsInEachSection(
* @returns a `currentPanel` object containing sections/tools/labels that meet required conditions
*/
export function getValidPanelItems(
items: (string | ToolSection)[][],
validToolIdsInCurrentView: string[],
items: Array<[string, ToolPanelItem]>,
validToolIdsInCurrentView: Set<string>,
excludedSectionIds: string[] = [],
) {
const validEntries = items.filter(([id, item]) => {
id = id as string;
item = item as Tool | ToolSection;
if (isToolObject(item as Tool) && validToolIdsInCurrentView.includes(id)) {
if (isTool(item) && validToolIdsInCurrentView.has(id)) {
// is a `Tool` and is in `localToolsById`
return true;
} else if (item.tools === undefined) {
} else if (!isToolSection(item)) {
// is neither a `Tool` nor a `ToolSection`, maybe a `ToolSectionLabel`
return true;
} else if (item.tools && item.tools.length > 0 && !excludedSectionIds.includes(id)) {
} else if ("tools" in item && item.tools?.length && !excludedSectionIds.includes(id)) {
// is a `ToolSection` with tools; is not an excluded section
return true;
} else {
@@ -267,7 +321,7 @@ export function getValidPanelItems(
export function searchTools(
tools: Tool[],
query: string,
currentPanel: Record<string, Tool | ToolSection>,
currentPanel: Record<string, ToolPanelItem>,
): {
results: string[];
resultPanel: Record<string, Tool | ToolSection>;
@@ -419,6 +473,28 @@ export function searchObjectsByKeys<T extends { id: string }>(
};
}
function getOrCreateSection(
acc: Record<string, Tool | ToolSection>,
sectionId: string,
sectionName: string,
): ToolSection {
return acc[sectionId] && isToolSection(acc[sectionId])
? (acc[sectionId] as ToolSection)
: buildToolSection(sectionId, sectionName, []);
}
function addToolToSection(
acc: Record<string, Tool | ToolSection>,
sectionId: string,
sectionName: string,
toolId: string,
): boolean {
const section = getOrCreateSection(acc, sectionId, sectionName);
section.tools?.push(toolId);
acc[sectionId] = section;
return true;
}
/**
* Orders the matchedTools by order of keys that are being searched, and creates a resultPanel
* @param matchedTools containing { id: tool id, order: order }
@@ -427,50 +503,34 @@ export function searchObjectsByKeys<T extends { id: string }>(
* - idResults: array of tool ids that match the query
* - resultPanel: a ToolPanel with only the results
*/
export function createSortedResultPanel(matchedTools: SearchMatch[], currentPanel: Record<string, Tool | ToolSection>) {
export function createSortedResultPanel(matchedTools: SearchMatch[], currentPanel: Record<string, ToolPanelItem>) {
const idResults: string[] = [];
// creating a sectioned results object ({section_id: [tool ids], ...}), keeping
// track unique ids of each tool, and also sorting by indexed order of keys
const resultPanel = orderBy(matchedTools, ["order"], ["desc"]).reduce(
(acc: Record<string, Tool | ToolSection>, match: SearchMatch) => {
// we need to search all sections in panel for this tool id
const sections = Object.keys(currentPanel);
for (const section of sections) {
const panelItems = Object.keys(currentPanel);
for (const itemId of panelItems) {
const existingPanelItem = currentPanel[itemId];
if (!existingPanelItem) {
continue;
}
let toolAdded = false;
const existingPanelItem = section ? currentPanel[section] : undefined;
if (existingPanelItem && section) {
if (
(existingPanelItem as ToolSection).tools &&
(existingPanelItem as ToolSection).tools?.includes(match.id)
) {
// it has tools so is a section, and it has the tool we're looking for
// if we haven't seen this section yet, create it in the resultPanel
let existingSection = acc[section] as ToolSection;
if (!existingSection) {
existingSection = { ...existingPanelItem };
existingSection.tools = [];
}
existingSection.tools?.push(match.id);
acc[section] = existingSection;
toolAdded = true;
} else if (isToolObject(existingPanelItem as Tool) && existingPanelItem.id === match.id) {
// it is a tool, and it is the tool we're looking for
if ("tools" in existingPanelItem && existingPanelItem.tools?.includes(match.id)) {
// it has tools so is a section, and it has the tool we're looking for
toolAdded = addToolToSection(acc, itemId, existingPanelItem.name, match.id);
} else if (isTool(existingPanelItem) && existingPanelItem.id === match.id) {
// it is a tool, and it is the tool we're looking for
// put it in the "Unsectioned Tools" section
toolAdded = addToolToSection(acc, UNSECTIONED_SECTION.id, UNSECTIONED_SECTION.name, match.id);
}
// put in it the "Unsectioned Tools" section (if it doesn't exist, create it)
const unsectionedId = UNSECTIONED_SECTION.id;
let unsectionedSection = acc[unsectionedId] as ToolSection;
if (!unsectionedSection) {
unsectionedSection = { ...UNSECTIONED_SECTION };
unsectionedSection.tools = [];
}
unsectionedSection.tools?.push(match.id);
acc[unsectionedId] = unsectionedSection;
toolAdded = true;
}
if (toolAdded && !idResults.includes(match.id)) {
idResults.push(match.id);
}
if (toolAdded && !idResults.includes(match.id)) {
idResults.push(match.id);
}
}
return acc;
@@ -517,21 +577,6 @@ function closestSubstring(query: string, actualStr: string) {
return null;
}
export function isToolObject(tool: Tool | ToolSection | ToolSectionLabel) {
// toolbox overhaul with typing will simplify this dramatically...
// Right now, our shorthand is that tools have no 'text', and don't match
// the model_class of the section/label.
if (
!(tool as ToolSectionLabel).text &&
tool.model_class !== "ToolSectionLabel" &&
tool.model_class !== "ToolSection" &&
(tool as ToolSection).tools === undefined
) {
return true;
}
return false;
}
// given array and a substring, get the closest matching term for substring
function matchingTerm(termArray: string[], substring: string) {
const sanitized = sanitizeString(substring);
+15 -1
View File
@@ -1,7 +1,7 @@
import "@tests/vitest/mockHelpPopovers";
import { getFakeRegisteredUser } from "@tests/test-data";
import { getLocalVue, suppressBootstrapVueWarnings } from "@tests/vitest/helpers";
import { getLocalVue, injectTestRouter, suppressBootstrapVueWarnings } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { createPinia } from "pinia";
@@ -17,6 +17,7 @@ import ToolForm from "./ToolForm.vue";
const { server, http } = useServerMock();
const localVue = getLocalVue();
const router = injectTestRouter(localVue);
const pinia = createPinia();
describe("ToolForm", () => {
@@ -72,6 +73,7 @@ describe("ToolForm", () => {
version: "version",
},
localVue,
router,
stubs: {
UserHistories: MockCurrentHistory({ id: "fakeHistory" }),
FormDisplay: true,
@@ -84,6 +86,7 @@ describe("ToolForm", () => {
historyStore = useHistoryStore();
historyStore.setHistories([{ id: "fakeHistory" }]);
historyStore.setCurrentHistoryId("fakeHistory");
historyStore.startWatchingHistory = () => {};
});
it("shows props", async () => {
@@ -97,4 +100,15 @@ describe("ToolForm", () => {
const creator = wrapper.find(".creative-work-creator");
expect(creator.text()).toContain("FakeName FakeSurname");
});
it("adds the executed tool to recent tools", async () => {
await flushPromises();
await wrapper.setData({ formData: {} });
const button = wrapper.find("[data-description='run tool button']");
await button.trigger("click");
await flushPromises();
expect(userStore.recentTools).toEqual(["tool_id"]);
});
});
+2
View File
@@ -309,6 +309,7 @@ export default {
...mapActions(useJobStore, ["saveLatestResponse"]),
...mapActions(useTourStore, ["setTour"]),
...mapActions(useHistoryStore, ["startWatchingHistory"]),
...mapActions(useUserStore, ["addRecentTool"]),
emailAllowed(config, user) {
return config.server_mail_configured && !user.isAnonymous;
},
@@ -388,6 +389,7 @@ export default {
return;
}
this.showExecuting = true;
this.addRecentTool(this.formConfig?.id);
const jobDef = {
history_id: historyId,
tool_id: this.formConfig.id,
@@ -0,0 +1,77 @@
import { storeToRefs } from "pinia";
import { computed, type ComputedRef, type Ref } from "vue";
import type { Tool } from "@/stores/toolStore";
import { useUserStore } from "@/stores/userStore";
import { usePersistentToggle } from "./persistentToggle";
/**
* Composable for managing favorites and recent tools in the tool panel.
* Provides computed properties for favorites/recent tool filtering and persistent collapse state.
*/
export function useToolPanelFavorites(localToolsById: Ref<Record<string, Tool>>) {
const { currentFavorites, recentTools } = storeToRefs(useUserStore());
// Collapse state with localStorage persistence (user-specific)
const { toggled: favoritesCollapsed, toggle: toggleFavorites } = usePersistentToggle(
"tool-panel-favorites-collapsed",
);
const { toggled: recentToolsCollapsed, toggle: toggleRecent } = usePersistentToggle("tool-panel-recent-collapsed");
// Derived favorites data
const favoriteToolIds = computed(() => currentFavorites.value.tools || []);
const favoriteToolIdSet = computed(() => new Set(favoriteToolIds.value));
const favoriteToolIdsInPanel = computed(() =>
favoriteToolIds.value.filter((toolId) => !!localToolsById.value[toolId]),
);
// Derived recent tools data
const recentToolIds = computed(() => recentTools.value || []);
const recentToolIdsInPanel = computed(() => recentToolIds.value.filter((toolId) => !!localToolsById.value[toolId]));
const recentToolIdsToShow = computed(() =>
recentToolIdsInPanel.value.filter((toolId) => !favoriteToolIdSet.value.has(toolId)),
);
const recentToolIdsToShowSet = computed(() => new Set(recentToolIdsToShow.value));
return {
// Collapse state
favoritesCollapsed,
recentToolsCollapsed,
toggleFavorites,
toggleRecent,
// Favorites
favoriteToolIds,
favoriteToolIdSet,
favoriteToolIdsInPanel,
// Recent tools
recentToolIds,
recentToolIdsInPanel,
recentToolIdsToShow,
recentToolIdsToShowSet,
};
}
/**
* Composable for computing search results split by favorites vs non-favorites.
* Used when displaying search results with favorites prominently.
*/
export function useFavoriteSearchResults(
results: Ref<string[]>,
favoriteToolIdSet: ComputedRef<Set<string>>,
): {
favoriteResults: ComputedRef<string[]>;
nonFavoriteResults: ComputedRef<string[]>;
/** `true` if there are both favorite and non-favorite results */
hasMixedResults: ComputedRef<boolean>;
} {
const favoriteResults = computed(() => results.value.filter((id) => favoriteToolIdSet.value.has(id)));
const nonFavoriteResults = computed(() => results.value.filter((id) => !favoriteToolIdSet.value.has(id)));
const hasMixedResults = computed(() => favoriteResults.value.length > 0 && nonFavoriteResults.value.length > 0);
return {
favoriteResults,
nonFavoriteResults,
hasMixedResults,
};
}
+50 -11
View File
@@ -6,6 +6,12 @@ import axios, { type AxiosResponse } from "axios";
import { defineStore } from "pinia";
import Vue, { computed, type Ref, ref, shallowRef } from "vue";
import {
MY_PANEL_VIEW_DESCRIPTION,
MY_PANEL_VIEW_ID,
MY_PANEL_VIEW_NAME,
MY_PANEL_VIEW_TYPE,
} from "@/components/Panels/panelViews";
import { FAVORITES_KEYS, filterTools, type types_to_icons } from "@/components/Panels/utilities";
import { parseHelpForSummary } from "@/components/ToolsList/utilities";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
@@ -31,6 +37,7 @@ export interface Panel {
searchable: boolean;
}
// TODO: Once the backend models are typed, we will replace these with the generated types from the schema.
export interface Tool {
model_class: string;
id: string;
@@ -58,10 +65,11 @@ export interface Tool {
changeset_revision: string;
tool_shed: string;
};
help?: string;
}
export interface ToolSection {
model_class: string;
model_class: "ToolSection";
id: string;
name: string;
title?: string;
@@ -69,11 +77,11 @@ export interface ToolSection {
description?: string;
links?: Record<string, string>;
tools?: (string | ToolSectionLabel)[];
elems?: (Tool | ToolSection)[];
elems?: (Tool | ToolSection)[]; // TODO: Are we sure that a `ToolSection` can have `ToolSection` children?
}
export interface ToolSectionLabel {
model_class: string;
model_class: "ToolSectionLabel";
id: string;
text: string;
version?: string;
@@ -81,11 +89,24 @@ export interface ToolSectionLabel {
links?: Record<string, string> | null;
}
export type ToolPanelItem = Tool | ToolSection | ToolSectionLabel;
export type ToolHelpData = {
help?: string;
summary?: string;
};
const MY_PANEL_VIEW_SECTION_ID = "favorites";
const MY_PANEL_VIEW: Panel = {
id: MY_PANEL_VIEW_ID,
model_class: "StaticToolPanelView",
name: MY_PANEL_VIEW_NAME,
description: MY_PANEL_VIEW_DESCRIPTION,
view_type: MY_PANEL_VIEW_TYPE,
searchable: true,
};
export const useToolStore = defineStore("toolStore", () => {
const currentPanelView: Ref<string> = useUserLocalStorage("tool-store-view", "");
const defaultPanelView: Ref<string> = ref("");
@@ -94,7 +115,7 @@ export const useToolStore = defineStore("toolStore", () => {
const searchWorker = ref<Worker | undefined>(undefined);
const toolsById = shallowRef<Record<string, Tool>>({});
const toolResults = ref<Record<string, string[]>>({});
const toolSections = ref<Record<string, Record<string, Tool | ToolSection>>>({});
const toolSections = ref<Record<string, Record<string, ToolPanelItem>>>({});
const fetchedHelpIds = ref<Set<string>>(new Set());
const helpDataCached = ref<Record<string, ToolHelpData>>({});
@@ -185,12 +206,24 @@ export const useToolStore = defineStore("toolStore", () => {
});
async function fetchToolSections(panelView: string) {
if (!panelView || toolSections.value[panelView]) {
return;
}
if (panelView === MY_PANEL_VIEW_ID) {
saveToolSections(panelView, {
[MY_PANEL_VIEW_SECTION_ID]: {
model_class: "ToolSection",
id: MY_PANEL_VIEW_SECTION_ID,
name: "Favorites",
tools: [],
},
});
return;
}
try {
if (panelView && !toolSections.value[panelView]) {
loading.value = true;
const { data } = await axios.get(`${getAppRoot()}api/tool_panels/${panelView}`);
saveToolSections(panelView, data);
}
loading.value = true;
const { data } = await axios.get(`${getAppRoot()}api/tool_panels/${panelView}`);
saveToolSections(panelView, data);
} catch (e) {
rethrowSimple(e);
} finally {
@@ -203,7 +236,10 @@ export const useToolStore = defineStore("toolStore", () => {
if (!defaultPanelView.value || Object.keys(panels.value).length === 0) {
const { data } = await axios.get(`${getAppRoot()}api/tool_panels`);
defaultPanelView.value = data.default_panel_view;
panels.value = data.views;
panels.value = {
...data.views,
...(data.views[MY_PANEL_VIEW_ID] ? {} : { [MY_PANEL_VIEW_ID]: MY_PANEL_VIEW }),
};
}
} catch (e) {
rethrowSimple(e);
@@ -290,7 +326,7 @@ export const useToolStore = defineStore("toolStore", () => {
);
}
function saveToolSections(panelView: string, newPanel: { [id: string]: ToolSection | Tool }) {
function saveToolSections(panelView: string, newPanel: { [id: string]: ToolPanelItem }) {
Vue.set(toolSections.value, panelView, newPanel);
}
@@ -304,6 +340,9 @@ 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);
}
await fetchToolSections(panelView);
currentPanelView.value = panelView;
} catch (e) {
+59
View File
@@ -0,0 +1,59 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useUserStore } from "@/stores/userStore";
describe("userStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
afterEach(() => {
const userStore = useUserStore();
userStore.$reset();
});
describe("addRecentTool", () => {
it("adds tools to the front, deduplicates, and ignores empty ids", () => {
const userStore = useUserStore();
userStore.addRecentTool("");
expect(userStore.recentTools).toEqual([]);
userStore.addRecentTool("tool_a");
userStore.addRecentTool("tool_b");
userStore.addRecentTool("tool_c");
expect(userStore.recentTools).toEqual(["tool_c", "tool_b", "tool_a"]);
// re-adding an existing tool moves it to front without duplicating
userStore.addRecentTool("tool_a");
expect(userStore.recentTools).toEqual(["tool_a", "tool_c", "tool_b"]);
});
it("drops the oldest tool when the limit is reached", () => {
const userStore = useUserStore();
for (let i = 0; i < 10; i++) {
userStore.addRecentTool(`tool_${i}`);
}
expect(userStore.recentTools).toHaveLength(10);
expect(userStore.recentTools[0]).toBe("tool_9");
expect(userStore.recentTools[9]).toBe("tool_0");
userStore.addRecentTool("tool_new");
expect(userStore.recentTools).toHaveLength(10);
expect(userStore.recentTools[0]).toBe("tool_new");
expect(userStore.recentTools).not.toContain("tool_0");
});
});
describe("clearRecentTools", () => {
it("clears all recent tools", () => {
const userStore = useUserStore();
userStore.addRecentTool("tool_a");
userStore.addRecentTool("tool_b");
expect(userStore.recentTools).toHaveLength(2);
userStore.clearRecentTools();
expect(userStore.recentTools).toEqual([]);
});
});
});
+20
View File
@@ -26,6 +26,8 @@ export type ListViewMode = "grid" | "list";
type UserListViewPreferences = Record<string, ListViewMode>;
const RECENT_TOOLS_LIMIT = 10;
export const useUserStore = defineStore("userStore", () => {
const currentUser = ref<AnyUser>(null);
const currentPreferences = ref<Preferences | null>(null);
@@ -41,11 +43,14 @@ export const useUserStore = defineStore("userStore", () => {
const historyPanelWidth = useUserLocalStorageFromHashId("user-store-history-panel-width", 300, hashedUserId);
const recentTools = useUserLocalStorageFromHashId<string[]>("user-store-recent-tools", [], hashedUserId);
let loadPromise: Promise<void> | null = null;
function $reset() {
currentUser.value = null;
currentPreferences.value = null;
recentTools.value = [];
loadPromise = null;
}
@@ -144,6 +149,18 @@ export const useUserStore = defineStore("userStore", () => {
}
}
function addRecentTool(toolId: string) {
if (!toolId) {
return;
}
const currentTools = recentTools.value || [];
recentTools.value = [toolId, ...currentTools.filter((id) => id !== toolId)].slice(0, RECENT_TOOLS_LIMIT);
}
function clearRecentTools() {
recentTools.value = [];
}
function setListViewPreference(listId: string, view: ListViewMode) {
currentListViewPreferences.value = {
...currentListViewPreferences.value,
@@ -171,6 +188,7 @@ export const useUserStore = defineStore("userStore", () => {
currentListViewPreferences,
hasSeenUploadHelp,
historyPanelWidth,
recentTools,
loadUser,
matchesCurrentUsername,
setCurrentUser,
@@ -178,6 +196,8 @@ export const useUserStore = defineStore("userStore", () => {
setListViewPreference,
addFavoriteTool,
removeFavoriteTool,
addRecentTool,
clearRecentTools,
$reset,
};
});
@@ -0,0 +1,61 @@
/**
* Styles for tool panel dividers used in tool panel labels.
* These styles are used for dividers in the new "My Tools" panel, but they
* can also be used in the "Full Tools" panel for consistency once the new panel is stable.
*/
.tool-panel-divider {
align-items: center;
background: transparent;
border-left: 0;
display: flex;
gap: 0.5rem;
font-size: $font-size-base;
font-weight: 600;
padding: 0.375rem 0.75rem;
text-transform: none;
&::before,
&::after {
border-bottom: 1px solid currentColor;
content: "";
flex: 1;
opacity: 0.35;
}
}
.tool-panel-divider-text {
align-items: center;
display: inline-flex;
gap: 0.5rem;
white-space: nowrap;
}
.tool-panel-divider-toggle {
opacity: 0.8;
}
.tool-panel-divider-icon {
opacity: 0.8;
}
.tool-panel-divider-action {
margin-left: 0;
}
.tool-panel-divider-link {
align-items: center;
display: inline-flex;
flex: 0 0 auto;
gap: 0.5rem;
justify-content: center;
padding: 0;
text-decoration: none;
}
.tool-panel-divider .title-link,
.tool-panel-divider .title-link:hover,
.tool-panel-divider .title-link:focus,
.tool-panel-divider .title-link:focus-visible {
background: transparent;
}
+1
View File
@@ -41,6 +41,7 @@
}
.unified-panel-controls {
@extend .px-3;
@extend .pt-1;
}
.unified-panel-body {
@extend .p-0;
@@ -940,8 +940,6 @@ workflow_editor:
selector: 'license save'
type: data-description
tool_menu: '.toolMenuContainer'
tool_menu_section_link: '.tool-menu-section-${section_name} a span'
tool_menu_item_link: 'a.tool-menu-item-${item_name}'
workflow_link:
type: xpath
selector: '//a[contains(., "${workflow_title}")]'
-2
View File
@@ -447,8 +447,6 @@ interface Rootworkflow_editor extends Component {
edit_annotation: SelectorTemplate;
edit_name: SelectorTemplate;
tool_menu: SelectorTemplate;
tool_menu_section_link: SelectorTemplate;
tool_menu_item_link: SelectorTemplate;
workflow_link: SelectorTemplate;
insert_steps: SelectorTemplate;
connect_icon: SelectorTemplate;
+1 -1
View File
@@ -46,7 +46,7 @@ export function suppressBootstrapVueWarnings() {
const originalWarn = console.warn;
vi.spyOn(console, "warn").mockImplementation(
vi.fn((msg) => {
if (msg.indexOf("BootstrapVue warn") < 0) {
if (typeof msg !== "string" || msg.indexOf("BootstrapVue warn") < 0) {
originalWarn(msg);
}
}),
@@ -0,0 +1,109 @@
from .framework import (
playwright_only,
selenium_test,
SeleniumTestCase,
)
class TestToolPanelSearchPlaywright(SeleniumTestCase):
@playwright_only("Validates tool panel search behavior with Playwright backend.")
@selenium_test
def test_tool_panel_search_my_panel(self):
self.home()
self.open_toolbox()
self.swap_to_tool_panel("my_panel")
tool_panel = self.components.tool_panel
tool_panel.toolbox.wait_for_visible()
search = self.components.tools.search
search.wait_for_visible()
search.wait_for_and_send_keys("filter failed")
tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
@playwright_only("Validates tool panel favorites/recents behavior with Playwright backend.")
@selenium_test
def test_tool_panel_favorites_and_recents_my_panel(self):
self.login()
self.home()
self.open_toolbox()
self.swap_to_tool_panel("my_panel")
tool_panel = self.components.tool_panel
tool_panel.toolbox.wait_for_visible()
favorites_label_selector = ".tool-panel-label:has-text('Favorites')"
recents_label_selector = ".tool-panel-label:has-text('Recent tools')"
empty_alert_selector = ".tool-panel-empty .alert"
self.wait_for_selector(favorites_label_selector)
empty_alert = self.wait_for_selector(empty_alert_selector)
assert "haven't favorited any tools yet" in empty_alert.text
self.wait_for_selector_absent_or_hidden(recents_label_selector)
search = self.components.tools.search
search.wait_for_visible()
search.wait_for_and_send_keys("create_2")
tool_panel.tool_link(tool_id="create_2").wait_for_and_click()
self.components.tool_form.execute.wait_for_and_click()
self.sleep_for(self.wait_types.UX_RENDER)
self.home()
self.open_toolbox()
self.swap_to_tool_panel("my_panel")
tool_panel.toolbox.wait_for_visible()
self.wait_for_selector(recents_label_selector)
tool_panel.tool_link(tool_id="create_2").wait_for_visible()
empty_alert = self.wait_for_selector(empty_alert_selector)
assert "haven't favorited any tools yet" in empty_alert.text
self.wait_for_selector('[data-description="clear-recent-tools"]').click()
self.wait_for_selector_absent_or_hidden(recents_label_selector)
search = self.components.tools.search
search.wait_for_visible()
search.wait_for_and_send_keys("filter failed")
tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
favorite_button_selector = '.tool-favorite-button[data-tool-id="__FILTER_FAILED_DATASETS__"]'
favorite_button = self.wait_for_selector(favorite_button_selector)
favorite_button.click()
self.components.tools.clear_search.wait_for_and_click()
tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
self.wait_for_selector_absent_or_hidden(empty_alert_selector)
self.page.locator(favorite_button_selector).focus()
self.page.keyboard.press("Enter")
self.wait_for_selector_absent_or_hidden('.toolTitle a[data-tool-id="__FILTER_FAILED_DATASETS__"]')
empty_alert = self.wait_for_selector(empty_alert_selector)
assert "haven't favorited any tools yet" in empty_alert.text
search.wait_for_visible()
search.wait_for_and_send_keys("filter failed")
favorite_button = self.wait_for_selector(favorite_button_selector)
favorite_button.click()
self.components.tools.clear_search.wait_for_and_click()
tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
self.wait_for_selector_absent_or_hidden(empty_alert_selector)
search.wait_for_visible()
search.wait_for_and_send_keys("filter")
self.wait_for_selector(favorites_label_selector)
self.wait_for_selector(".tool-panel-label:has-text('Search results')")
tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
tool_panel.tool_link(tool_id="filter_multiple_splitter").wait_for_visible()
self.wait_for_selector(favorites_label_selector).click()
self.wait_for_selector(".tool-panel-label[aria-expanded='false']:has-text('Favorites')")
self.wait_for_selector_absent_or_hidden('.toolTitle a[data-tool-id="__FILTER_FAILED_DATASETS__"]')
tool_panel.tool_link(tool_id="filter_multiple_splitter").wait_for_visible()