mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-11-03 21:34:30 +08:00
Merge pull request #21600 from bgruening/fav-tool-panel
Introducing a new favorite tool panel + most-recent tools
This commit is contained in:
@@ -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">
|
||||
|
||||
+31
-28
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
+69
-11
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}")]'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user