mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-30 18:13:23 +08:00
Address gx-arch-review feedback on PR #22212
- Extract FavoritesManager (lib/galaxy/managers/favorites.py): owns
normalize / resolve / persist / order logic; controllers in users.py
are now 2 lines each, and trans.sa_session.commit moves out of the
controller layer. Drops the redundant re-normalize on every write
and replaces FavoriteObjectType.X.value == .value defensive dispatch
with enum-identity comparison. Renames the set_favorite_order
endpoint summary away from the ambiguous "top-level" wording.
- Move curated_tool_tags_by_id off the toolbox into ToolsService;
the controller now routes through the service instead of poking
trans.app.toolbox directly. Raw aggregates (curated_tool_tags,
tool_edam_operations, tool_edam_topics) remain on the toolbox since
they're derived from per-tool data.
- Rename /api/tools/tags -> /api/tags/tool_tags (operation_id
tags__tool_tags) to disambiguate from /api/tools/{id}; update the
client store, tests, and the hand-edited OpenAPI schema entry.
(Run `make update-client-api-schema` before pushing to regenerate
the schema in its canonical alphabetical position.)
- Promote inline imports to module-level (ontology_data.py logging,
app/__init__.py configure_tool_tag_mapping); replace
getattr(self.config, "tool_tag_mappings_file", None) with direct
attribute access now that the option is registered in the schema.
- Convert ontology_data's import-time bundled-mapping reads to
lru_cache so the YAML / TSV files load lazily; fix the
Tool._setup_id comment to point at the actual lowercasing site
(Tool.parse around `self_ids = [self.id.lower()]`).
- doc/source/admin/tool_panel.rst: 26.0 -> 26.1.
- scripts/extract_tool_sections_from_api.py: annotate the public
helpers the test suite imports.
- test_to_dict_cache_drops_when_tool_removed now asserts observable
to_dict() output before/after remove, not private cache membership.
- Split test_tool_discovery_landing into focused tests; extract a
helper that opens the discovery view and types the standard filter.
- Move the Playwright drag helper from test_tool_panel_search.py into
TestWithSeleniumMixin.playwright_drag_item_above so future tests
reuse it.
- Client tests: drop the full-string Whoosh equality in
utilities.test.ts; hard-code expected Whoosh strings in
ToolsList.test.ts (drop createWhooshQuery as both sides of the
assertion) and remove the redundant injectTestRouter mount option
(ToolsList.vue only uses the useRouter composable); drop the
FontAwesome data-icon/iconName assertions in ToolPanel.test.ts in
favor of icon-existence checks; replace three near-identical
add/remove favorite tests in ToolsListCard.test.ts with an it.each;
export quoteToolTagValue from Panels/utilities so
filterConversion.test.js imports the real helper instead of
reconstructing it inline; delete the spy-only "loads the curated
tag mapping" test in ToolBoxSearch.test.ts (the gate is covered by
ToolPanel.test.ts's negative case and the favorite-tag rendering
tests cover the positive case end-to-end).
This commit is contained in:
@@ -5004,6 +5004,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags/tool_tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Return the curated tool-id to tag-name mapping for currently-loaded tools. */
|
||||
get: operations["tags__tool_tags"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tasks/{task_id}/result": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5334,23 +5351,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tools/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Return the curated tool-id to tag-name mapping for currently-loaded tools. */
|
||||
get: operations["tools__tags"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tools/{tool_id}/icon": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5881,7 +5881,7 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** Persist the user's top-level favorites order */
|
||||
/** Persist the order of the user's favorites */
|
||||
put: operations["set_favorite_order_api_users__user_id__favorites_order_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -10079,7 +10079,7 @@ export interface components {
|
||||
name: string;
|
||||
};
|
||||
/**
|
||||
* CustomBuildsCollection
|
||||
* Custom builds collection
|
||||
* @description The custom builds associated with the user.
|
||||
*/
|
||||
CustomBuildsCollection: components["schemas"]["CustomBuildModel"][];
|
||||
@@ -10963,7 +10963,7 @@ export interface components {
|
||||
model_class: "DatasetHash";
|
||||
};
|
||||
/**
|
||||
* DatasetInheritanceChain
|
||||
* Dataset inheritance chain
|
||||
* @default []
|
||||
*/
|
||||
DatasetInheritanceChain: components["schemas"]["DatasetInheritanceChainEntry"][];
|
||||
@@ -11200,7 +11200,7 @@ export interface components {
|
||||
tool_id: string;
|
||||
};
|
||||
/**
|
||||
* DatatypeConverterList
|
||||
* List of data type converters
|
||||
* @default []
|
||||
*/
|
||||
DatatypeConverterList: components["schemas"]["DatatypeConverter"][];
|
||||
@@ -11285,7 +11285,7 @@ export interface components {
|
||||
visualization: string;
|
||||
};
|
||||
/**
|
||||
* DatatypeVisualizationMappingsList
|
||||
* List of datatype visualization mappings
|
||||
* @default []
|
||||
*/
|
||||
DatatypeVisualizationMappingsList: components["schemas"]["DatatypeVisualizationMapping"][];
|
||||
@@ -11303,7 +11303,7 @@ export interface components {
|
||||
datatypes_mapping: components["schemas"]["DatatypesMap"];
|
||||
};
|
||||
/**
|
||||
* DatatypesEDAMDetailsDict
|
||||
* Dict of EDAM details for formats
|
||||
* @default {}
|
||||
*/
|
||||
DatatypesEDAMDetailsDict: {
|
||||
@@ -12827,8 +12827,17 @@ export interface components {
|
||||
writable: boolean;
|
||||
};
|
||||
/**
|
||||
* FilesSourcePluginList
|
||||
* List of files source plugins
|
||||
* @default []
|
||||
* @example {
|
||||
* "browsable": true,
|
||||
* "doc": "Galaxy's library import directory",
|
||||
* "id": "_import",
|
||||
* "label": "Library Import Directory",
|
||||
* "type": "gximport",
|
||||
* "uri_root": "gximport://",
|
||||
* "writable": false
|
||||
* }
|
||||
*/
|
||||
FilesSourcePluginList: (
|
||||
| components["schemas"]["BrowsableFilesSourcePlugin"]
|
||||
@@ -17453,7 +17462,7 @@ export interface components {
|
||||
value: string;
|
||||
};
|
||||
/**
|
||||
* JobMetricCollection
|
||||
* Job Metrics
|
||||
* @description Represents a collection of metrics associated with a Job.
|
||||
* @default []
|
||||
*/
|
||||
@@ -18654,7 +18663,7 @@ export interface components {
|
||||
synopsis?: string | null;
|
||||
};
|
||||
/**
|
||||
* LibrarySummaryList
|
||||
* List with summary information of Libraries.
|
||||
* @default []
|
||||
*/
|
||||
LibrarySummaryList: components["schemas"]["LibrarySummary"][];
|
||||
@@ -18753,14 +18762,14 @@ export interface components {
|
||||
*/
|
||||
LinkDataOnly: "copy_files" | "link_to_files";
|
||||
/**
|
||||
* ListJstreeResponse
|
||||
* List of files
|
||||
* @deprecated
|
||||
* @description List of files in Jstree format.
|
||||
* @default []
|
||||
*/
|
||||
ListJstreeResponse: unknown[];
|
||||
/**
|
||||
* ListUriResponse
|
||||
* List of remote entries
|
||||
* @description List of directories and files.
|
||||
* @default []
|
||||
*/
|
||||
@@ -19693,7 +19702,7 @@ export interface components {
|
||||
username: string;
|
||||
};
|
||||
/**
|
||||
* PageSummaryList
|
||||
* List with summary information of Pages.
|
||||
* @default []
|
||||
*/
|
||||
PageSummaryList: components["schemas"]["PageSummary"][];
|
||||
@@ -20462,7 +20471,7 @@ export interface components {
|
||||
url: string;
|
||||
};
|
||||
/**
|
||||
* QuotaSummaryList
|
||||
* List with summary information of Quotas.
|
||||
* @default []
|
||||
*/
|
||||
QuotaSummaryList: components["schemas"]["QuotaSummary"][];
|
||||
@@ -23411,7 +23420,7 @@ export interface components {
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
/** ToolDataEntryList */
|
||||
/** A list with details on individual data tables. */
|
||||
ToolDataEntryList: components["schemas"]["ToolDataEntry"][];
|
||||
/** ToolDataField */
|
||||
ToolDataField: {
|
||||
@@ -23823,7 +23832,7 @@ export interface components {
|
||||
title_default?: string | null;
|
||||
};
|
||||
/**
|
||||
* TourList
|
||||
* List of tours
|
||||
* @default []
|
||||
*/
|
||||
TourList: components["schemas"]["Tour"][];
|
||||
@@ -25537,7 +25546,7 @@ export interface components {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* VisualizationSummaryList
|
||||
* List with detailed information of Visualizations.
|
||||
* @default []
|
||||
*/
|
||||
VisualizationSummaryList: components["schemas"]["VisualizationSummary"][];
|
||||
@@ -46039,6 +46048,49 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
tags__tool_tags: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
|
||||
"run-as"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Request Error */
|
||||
"4XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
/** @description Server Error */
|
||||
"5XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_result_api_tasks__task_id__result_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -46926,49 +46978,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
tools__tags: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
|
||||
"run-as"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Request Error */
|
||||
"4XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
/** @description Server Error */
|
||||
"5XX": {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageExceptionModel"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_icon_api_tools__tool_id__icon_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -35,16 +35,71 @@ function toToolsById(list: Tool[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function withoutToolTags(list: Tool[]) {
|
||||
return list.map(({ tool_tags, ...tool }) => tool as Tool);
|
||||
}
|
||||
|
||||
function withFavoriteEdamOperationTool(list: Tool[]) {
|
||||
return list.map((tool) =>
|
||||
tool.id === "liftOver1" ? ({ ...tool, edam_operations: ["operation_2409"] } as Tool) : tool,
|
||||
);
|
||||
}
|
||||
|
||||
const SIGNED_IN_USER = {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
} as any;
|
||||
|
||||
interface MountToolBoxOptions {
|
||||
/** Override the tool list used to populate `toolStore.toolsById` (default: `toolsList`). */
|
||||
tools?: Tool[];
|
||||
/** Override `toolStore.toolSections` (default: `{ default: toolsListInPanel }`). */
|
||||
toolSections?: Record<string, Record<string, Tool | ToolSection>>;
|
||||
/** Override `userStore.currentUser` — anonymous by default. */
|
||||
currentUser?: any;
|
||||
/** Override `userStore.currentPreferences.favorites` (default: `{ tools: [] }`). */
|
||||
favorites?: any;
|
||||
/** Set `userStore.recentTools` if provided. */
|
||||
recentTools?: string[];
|
||||
/** Override the active panel view (default: `"my_panel"`). */
|
||||
currentPanelView?: string;
|
||||
/** Install spies / extra store setup before `mount`. Runs after the defaults are applied. */
|
||||
setupStores?: (toolStore: ReturnType<typeof useToolStore>, userStore: ReturnType<typeof useUserStore>) => void;
|
||||
}
|
||||
|
||||
function mountToolBox(options: MountToolBoxOptions = {}) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
toolStore.toolsById = toToolsById(options.tools ?? toolsList);
|
||||
toolStore.toolSections = options.toolSections ?? { default: toolsListInPanel };
|
||||
toolStore.defaultPanelView = "default";
|
||||
toolStore.currentPanelView = options.currentPanelView ?? "my_panel";
|
||||
|
||||
const userStore = useUserStore();
|
||||
if (options.currentUser !== undefined) {
|
||||
userStore.currentUser = options.currentUser;
|
||||
}
|
||||
userStore.currentPreferences = { favorites: options.favorites ?? { tools: [] } };
|
||||
if (options.recentTools !== undefined) {
|
||||
userStore.recentTools = options.recentTools;
|
||||
}
|
||||
|
||||
options.setupStores?.(toolStore, userStore);
|
||||
|
||||
const wrapper = mount(ToolBox as object, {
|
||||
pinia,
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
favoritesDefault: true,
|
||||
useSearchWorker: false,
|
||||
},
|
||||
});
|
||||
|
||||
return { wrapper, toolStore, userStore };
|
||||
}
|
||||
|
||||
describe("ToolBox search", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -55,29 +110,7 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("searches across toolbox when favorites are the default view and clears the query with Escape", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const { wrapper } = mountToolBox({ favorites: { tools: ["liftOver1"] } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-tool-id="liftOver1"]').exists()).toBe(true);
|
||||
@@ -97,29 +130,7 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("shows empty favorites copy in My panel when no favorites are set", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const { wrapper } = mountToolBox();
|
||||
await flushPromises();
|
||||
|
||||
const emptyState = wrapper.find(".tool-panel-empty");
|
||||
@@ -135,29 +146,7 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("separates favorite results and shows favorite button for non-favorites during search", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const { wrapper } = mountToolBox({ favorites: { tools: ["__FILTER_FAILED_DATASETS__"] } });
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.find("input.search-query");
|
||||
@@ -182,31 +171,9 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("treats #favorites as a filter token for both explicit AND and shorthand searches", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
toolStore.toolsById = toToolsById(toolsList);
|
||||
toolStore.toolSections = { default: toolsListInPanel };
|
||||
toolStore.defaultPanelView = "default";
|
||||
toolStore.currentPanelView = "my_panel";
|
||||
|
||||
const userStore = useUserStore();
|
||||
userStore.currentPreferences = {
|
||||
const { wrapper } = mountToolBox({
|
||||
favorites: { tools: ["__FILTER_FAILED_DATASETS__", "__ZIP_COLLECTION__"] },
|
||||
};
|
||||
|
||||
const wrapper = mount(ToolBox as object, {
|
||||
pinia,
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
favoritesDefault: true,
|
||||
useSearchWorker: false,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.find("input.search-query");
|
||||
@@ -234,29 +201,7 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("collapses favorite results during search in My panel", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const { wrapper } = mountToolBox({ favorites: { tools: ["__FILTER_FAILED_DATASETS__"] } });
|
||||
await flushPromises();
|
||||
|
||||
const input = wrapper.find("input.search-query");
|
||||
@@ -279,30 +224,10 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("shows recent tools before favorites and allows clearing", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
const { wrapper } = mountToolBox({
|
||||
favorites: { tools: ["__FILTER_FAILED_DATASETS__"] },
|
||||
recentTools: ["__ZIP_COLLECTION__", "__FILTER_EMPTY_DATASETS__"],
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const labels = wrapper.findAll(".tool-panel-label").wrappers.map((item) => item.text());
|
||||
@@ -320,51 +245,26 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("shows one section per favorite tag in stored order and hides empty tag sections", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
toolStore.toolsById = toToolsById(toolsList);
|
||||
toolStore.toolSections = { default: toolsListInPanel };
|
||||
toolStore.defaultPanelView = "default";
|
||||
toolStore.currentPanelView = "my_panel";
|
||||
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
} as any;
|
||||
userStore.currentPreferences = {
|
||||
const { wrapper, userStore } = mountToolBox({
|
||||
currentUser: SIGNED_IN_USER,
|
||||
favorites: {
|
||||
tools: ["__FILTER_FAILED_DATASETS__"],
|
||||
tags: ["genome_coordinates", "data_cleanup", "missing_tag"],
|
||||
},
|
||||
};
|
||||
userStore.recentTools = ["__ZIP_COLLECTION__"];
|
||||
vi.spyOn(userStore, "removeFavoriteTag").mockImplementation(async (tag: string) => {
|
||||
userStore.currentPreferences = {
|
||||
favorites: {
|
||||
tools: userStore.currentPreferences?.favorites.tools ?? [],
|
||||
tags: (userStore.currentPreferences?.favorites.tags ?? []).filter(
|
||||
(currentTag) => currentTag !== tag,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const wrapper = mount(ToolBox as object, {
|
||||
pinia,
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
favoritesDefault: true,
|
||||
useSearchWorker: false,
|
||||
recentTools: ["__ZIP_COLLECTION__"],
|
||||
setupStores: (_toolStore, userStore) => {
|
||||
vi.spyOn(userStore, "removeFavoriteTag").mockImplementation(async (tag: string) => {
|
||||
userStore.currentPreferences = {
|
||||
favorites: {
|
||||
tools: userStore.currentPreferences?.favorites.tools ?? [],
|
||||
tags: (userStore.currentPreferences?.favorites.tags ?? []).filter(
|
||||
(currentTag) => currentTag !== tag,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const tagSectionNames = wrapper.findAll(".toolSectionTitle .name").wrappers.map((item) => item.text().trim());
|
||||
@@ -406,24 +306,8 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("renders top-level favorites in the stored mixed-type order", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
toolStore.toolsById = toToolsById(toolsList);
|
||||
toolStore.toolSections = { default: toolsListInPanel };
|
||||
toolStore.defaultPanelView = "default";
|
||||
toolStore.currentPanelView = "my_panel";
|
||||
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
} as any;
|
||||
userStore.currentPreferences = {
|
||||
const { wrapper } = mountToolBox({
|
||||
currentUser: SIGNED_IN_USER,
|
||||
favorites: {
|
||||
tools: ["__FILTER_FAILED_DATASETS__"],
|
||||
tags: ["genome_coordinates"],
|
||||
@@ -432,18 +316,7 @@ describe("ToolBox search", () => {
|
||||
{ object_type: "tools", object_id: "__FILTER_FAILED_DATASETS__" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mount(ToolBox as object, {
|
||||
pinia,
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
favoritesDefault: true,
|
||||
useSearchWorker: false,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const menuText = wrapper.find(".toolMenu").text();
|
||||
@@ -454,30 +327,10 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("collapses favorites and recent tools sections on label click", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
const { wrapper } = mountToolBox({
|
||||
favorites: { tools: ["__FILTER_FAILED_DATASETS__"] },
|
||||
recentTools: ["__ZIP_COLLECTION__"],
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-tool-id="__FILTER_FAILED_DATASETS__"]').exists()).toBe(true);
|
||||
@@ -509,30 +362,7 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("does not show empty favorites copy when recent tools exist", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const { wrapper } = mountToolBox({ recentTools: ["__ZIP_COLLECTION__"] });
|
||||
await flushPromises();
|
||||
|
||||
const emptyState = wrapper.find(".tool-panel-empty");
|
||||
@@ -543,56 +373,35 @@ describe("ToolBox search", () => {
|
||||
});
|
||||
|
||||
it("shows one section per favorite EDAM operation and allows removing it from My Tools", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
toolStore.toolsById = toToolsById(withFavoriteEdamOperationTool(toolsList));
|
||||
toolStore.toolSections = {
|
||||
default: toolsListInPanel,
|
||||
"ontology:edam_operations": {
|
||||
operation_2409: {
|
||||
model_class: "ToolSection",
|
||||
id: "operation_2409",
|
||||
name: "Data handling",
|
||||
tools: ["liftOver1"],
|
||||
const { wrapper, userStore } = mountToolBox({
|
||||
tools: withFavoriteEdamOperationTool(toolsList),
|
||||
toolSections: {
|
||||
default: toolsListInPanel,
|
||||
"ontology:edam_operations": {
|
||||
operation_2409: {
|
||||
model_class: "ToolSection",
|
||||
id: "operation_2409",
|
||||
name: "Data handling",
|
||||
tools: ["liftOver1"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
toolStore.defaultPanelView = "default";
|
||||
toolStore.currentPanelView = "my_panel";
|
||||
|
||||
const userStore = useUserStore();
|
||||
userStore.currentUser = {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
} as any;
|
||||
userStore.currentPreferences = { favorites: { tools: [], tags: [], edam_operations: ["operation_2409"] } };
|
||||
vi.spyOn(userStore, "removeFavoriteEdamOperation").mockImplementation(async (operationId: string) => {
|
||||
userStore.currentPreferences = {
|
||||
favorites: {
|
||||
tools: userStore.currentPreferences?.favorites.tools ?? [],
|
||||
tags: userStore.currentPreferences?.favorites.tags ?? [],
|
||||
edam_operations: (userStore.currentPreferences?.favorites.edam_operations ?? []).filter(
|
||||
(currentOperation) => currentOperation !== operationId,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const wrapper = mount(ToolBox as object, {
|
||||
pinia,
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
favoritesDefault: true,
|
||||
useSearchWorker: false,
|
||||
currentUser: SIGNED_IN_USER,
|
||||
favorites: { tools: [], tags: [], edam_operations: ["operation_2409"] },
|
||||
setupStores: (_toolStore, userStore) => {
|
||||
vi.spyOn(userStore, "removeFavoriteEdamOperation").mockImplementation(async (operationId: string) => {
|
||||
userStore.currentPreferences = {
|
||||
favorites: {
|
||||
tools: userStore.currentPreferences?.favorites.tools ?? [],
|
||||
tags: userStore.currentPreferences?.favorites.tags ?? [],
|
||||
edam_operations: (userStore.currentPreferences?.favorites.edam_operations ?? []).filter(
|
||||
(currentOperation) => currentOperation !== operationId,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const operationSection = wrapper
|
||||
@@ -621,34 +430,10 @@ describe("ToolBox search", () => {
|
||||
// section-rendering and remove-button wiring; an analogous topic test
|
||||
// would only re-exercise the same code with a different enum value.
|
||||
|
||||
it("loads the curated tag mapping in My Tools when favorite tags exist", async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const toolStore = useToolStore();
|
||||
toolStore.toolsById = toToolsById(withoutToolTags(toolsList));
|
||||
toolStore.toolSections = { default: toolsListInPanel };
|
||||
toolStore.defaultPanelView = "default";
|
||||
toolStore.currentPanelView = "my_panel";
|
||||
|
||||
const fetchToolTagsMappingMock = vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue();
|
||||
vi.spyOn(toolStore, "fetchTools").mockResolvedValue();
|
||||
|
||||
const userStore = useUserStore();
|
||||
userStore.currentPreferences = { favorites: { tools: [], tags: ["data_cleanup"] } };
|
||||
|
||||
mount(ToolBox as object, {
|
||||
pinia,
|
||||
localVue,
|
||||
router,
|
||||
propsData: {
|
||||
favoritesDefault: true,
|
||||
useSearchWorker: false,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(fetchToolTagsMappingMock).toHaveBeenCalled();
|
||||
});
|
||||
// The lazy-load gate that decides *whether* to call `fetchToolTagsMapping`
|
||||
// is verified end-to-end via the favorite-tag rendering tests above (which
|
||||
// depend on a populated tag mapping) and via
|
||||
// ToolPanel.test.ts > "does not request tool tags during default tool
|
||||
// panel startup" (the negative case). An additional spy-only test here
|
||||
// would duplicate the gate check without exercising user-visible behavior.
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useUserLocalStorage } from "@/composables/userLocalStorage";
|
||||
import { useToolStore } from "@/stores/toolStore";
|
||||
|
||||
import viewsListJson from "./testData/viewsList.json";
|
||||
import { types_to_icons } from "./utilities";
|
||||
|
||||
import ToolPanel from "./ToolPanel.vue";
|
||||
|
||||
@@ -171,25 +170,23 @@ describe("ToolPanel", () => {
|
||||
await currItem.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Test: check if the current panel view is selected now
|
||||
expect(currItem.find("[data-description='panel view item icon']").attributes("data-icon")).toEqual(
|
||||
"check",
|
||||
);
|
||||
// The active item renders the selection icon; we assert
|
||||
// existence rather than the FA `data-icon` value (which is
|
||||
// an icon-library implementation detail).
|
||||
expect(currItem.find("[data-description='panel view item icon']").exists()).toBe(true);
|
||||
|
||||
// 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;
|
||||
// Test: the panel header reflects the chosen view.
|
||||
const panelViewIcon = wrapper.find("[data-description='panel view header icon']");
|
||||
if (key === "my_panel") {
|
||||
expect(panelViewIcon.exists()).toBe(false);
|
||||
} else {
|
||||
expect(panelViewIcon.attributes("data-icon")).toEqual(types_to_icons[currentViewType].iconName);
|
||||
expect(panelViewIcon.exists()).toBe(true);
|
||||
}
|
||||
expect(wrapper.find("#toolbox-heading").text()).toBe(value!.name);
|
||||
} else {
|
||||
// Test: check if the default panel view is already selected, and no icon
|
||||
expect(currItem.find("[data-description='panel view item icon']").attributes("data-icon")).toEqual(
|
||||
"check",
|
||||
);
|
||||
// Default view: the selection icon is rendered on this item and
|
||||
// the header carries no extra icon (default state).
|
||||
expect(currItem.find("[data-description='panel view item icon']").exists()).toBe(true);
|
||||
expect(wrapper.find("[data-description='panel view header icon']").exists()).toBe(false);
|
||||
}
|
||||
}
|
||||
@@ -245,9 +242,9 @@ describe("ToolPanel", () => {
|
||||
let toolsRequestUrl: URL | undefined;
|
||||
let toolTagsRequested = false;
|
||||
// Default tool panel mount must not pull the curated tag mapping —
|
||||
// that's My-Tools-only and is fetched via /api/tools/tags on demand.
|
||||
// that's My-Tools-only and is fetched via /api/tags/tool_tags on demand.
|
||||
server.use(
|
||||
http.untyped.get("/api/tools/tags", () => {
|
||||
http.untyped.get("/api/tags/tool_tags", () => {
|
||||
toolTagsRequested = true;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
|
||||
@@ -91,10 +91,6 @@ describe("test helpers in tool searching utilities", () => {
|
||||
expect(q).toContain(
|
||||
'id_exact:(__FILTER_FAILED_DATASETS__) AND help:(downstream) AND owner:(devteam) AND tool_tags:("data cleanup") AND tool_tags:(collection_ops)',
|
||||
);
|
||||
// Combined query results in:
|
||||
expect(q).toEqual(
|
||||
'(name:(Filter) name_exact:(Filter) description:(Filter)) AND (id_exact:(__FILTER_FAILED_DATASETS__) AND help:(downstream) AND owner:(devteam) AND tool_tags:("data cleanup") AND tool_tags:(collection_ops))',
|
||||
);
|
||||
});
|
||||
|
||||
it("builds tag-only whoosh queries without an empty leading clause", async () => {
|
||||
|
||||
@@ -89,14 +89,26 @@ export const UNSECTIONED_SECTION: ToolSection = {
|
||||
// (KeywordAnalyzer(lowercase=True, commas=True) — see lib/galaxy/tools/search/__init__.py).
|
||||
// We lowercase the search clause too so a query for `tag:"Get Data"` matches an
|
||||
// indexed `get data` regardless of how the parser handles case folding.
|
||||
function escapeQuotedWhooshToken(value: string) {
|
||||
export function escapeQuotedWhooshToken(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Strip surrounding quotes/whitespace from a user-typed tag value. */
|
||||
export function normalizeToolTagValue(tag: string | string[]): string {
|
||||
return String(tag)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.replace(/^'(.*)'$/, "$1");
|
||||
}
|
||||
|
||||
/** Quote a tag for inline filter text (`tag:foo` vs `tag:"foo bar"`) without lowercasing. */
|
||||
export function quoteToolTagValue(tag: string | string[]): string {
|
||||
const normalizedValue = normalizeToolTagValue(tag);
|
||||
return /\s/.test(normalizedValue) ? `"${escapeQuotedWhooshToken(normalizedValue)}"` : normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeToolTagQueryValue(tag: string): string {
|
||||
const trimmedTag = tag.trim();
|
||||
const unquotedTag = trimmedTag.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1");
|
||||
const lowered = unquotedTag.toLowerCase();
|
||||
const lowered = normalizeToolTagValue(tag).toLowerCase();
|
||||
return /\s/.test(lowered) ? `"${escapeQuotedWhooshToken(lowered)}"` : lowered;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import toolsListUntyped from "@/components/ToolsView/testData/toolsList.json";
|
||||
import type { Tool } from "@/stores/toolStore";
|
||||
import { useToolStore } from "@/stores/toolStore";
|
||||
|
||||
import { createWhooshQuery } from "../Panels/utilities";
|
||||
|
||||
import ToolsList from "./ToolsList.vue";
|
||||
import ToolsListTable from "./ToolsListTable.vue";
|
||||
|
||||
@@ -28,11 +26,16 @@ const FILTER_SETTINGS = {
|
||||
owner: "owner-filter",
|
||||
help: "help-filter",
|
||||
};
|
||||
const WHOOSH_QUERY = createWhooshQuery(FILTER_SETTINGS);
|
||||
// Expected Whoosh queries are hard-coded so a regression in `createWhooshQuery`
|
||||
// can't make both sides of the assertion wrong simultaneously. The canonical
|
||||
// `createWhooshQuery` test lives in `../Panels/utilities.test.ts`.
|
||||
const WHOOSH_QUERY =
|
||||
"(name:(name-filter) name_exact:(name-filter) description:(name-filter))" +
|
||||
" AND (ontology:(ontology-filter) AND id_exact:(id-filter) AND owner:(owner-filter) AND help:(help-filter))";
|
||||
const TAG_FILTER_SETTINGS = {
|
||||
tag: ["collection_ops", "data cleanup"],
|
||||
};
|
||||
const TAG_WHOOSH_QUERY = createWhooshQuery(TAG_FILTER_SETTINGS);
|
||||
const TAG_WHOOSH_QUERY = '(tool_tags:(collection_ops) AND tool_tags:("data cleanup"))';
|
||||
const RAW_TAG_SEARCH = 'tag:"data cleanup" OR tag:collection_ops';
|
||||
const RAW_TAG_WHOOSH_QUERY = 'tool_tags:("data cleanup") OR tool_tags:(collection_ops)';
|
||||
const MIXED_TAG_SEARCH = 'tag:"data cleanup" trim';
|
||||
@@ -42,11 +45,15 @@ const MIXED_TAG_AND_ONTOLOGY_FILTER_SETTINGS = {
|
||||
tag: ["Join, Subtract and Group"],
|
||||
ontology: '"operation_3695"',
|
||||
};
|
||||
const MIXED_TAG_AND_ONTOLOGY_WHOOSH_QUERY = createWhooshQuery(MIXED_TAG_AND_ONTOLOGY_FILTER_SETTINGS);
|
||||
const MIXED_TAG_AND_ONTOLOGY_WHOOSH_QUERY =
|
||||
'(tool_tags:("join, subtract and group") AND edam_operations:("operation_3695"))';
|
||||
const toolsList = toolsListUntyped as unknown as Tool[];
|
||||
|
||||
const routerPushMock = vi.fn();
|
||||
|
||||
// The component reads the router via `useRouter()` (mocked here) while child
|
||||
// components (e.g. GButton's `<RouterLink>`) need a real router on the mount
|
||||
// option to render — keep both wired up.
|
||||
vi.mock("vue-router/composables", () => ({
|
||||
useRouter: () => ({
|
||||
push: routerPushMock,
|
||||
@@ -198,7 +205,7 @@ describe("ToolsList", () => {
|
||||
path: "/tools/list",
|
||||
query: { tag: ["data cleanup"] },
|
||||
});
|
||||
expect(fetchToolsMock).toHaveBeenLastCalledWith(createWhooshQuery({ tag: ["data cleanup"] }));
|
||||
expect(fetchToolsMock).toHaveBeenLastCalledWith('(tool_tags:("data cleanup"))');
|
||||
});
|
||||
|
||||
it("does not re-open autocomplete for a complete multi-word tag missing only the closing quote", async () => {
|
||||
@@ -246,7 +253,7 @@ describe("ToolsList", () => {
|
||||
path: "/tools/list",
|
||||
query: { tag: ["data cleanup"] },
|
||||
});
|
||||
expect(fetchToolsMock).toHaveBeenLastCalledWith(createWhooshQuery({ tag: ["data cleanup"] }));
|
||||
expect(fetchToolsMock).toHaveBeenLastCalledWith('(tool_tags:("data cleanup"))');
|
||||
});
|
||||
|
||||
it("renders route-provided multi-word tags with quotes in the search bar", async () => {
|
||||
|
||||
@@ -9,7 +9,14 @@ import { type FilterSettings, type Tool, useToolStore } from "@/stores/toolStore
|
||||
import { type ListViewMode, useUserStore } from "@/stores/userStore";
|
||||
import Filtering, { contains, type ValidFilter } from "@/utils/filtering";
|
||||
|
||||
import { buildToolTagClause, createWhooshQuery, FAVORITES_KEYS } from "../Panels/utilities";
|
||||
import {
|
||||
buildToolTagClause,
|
||||
createWhooshQuery,
|
||||
escapeQuotedWhooshToken,
|
||||
FAVORITES_KEYS,
|
||||
normalizeToolTagValue,
|
||||
quoteToolTagValue,
|
||||
} from "../Panels/utilities";
|
||||
|
||||
import GButton from "../BaseComponents/GButton.vue";
|
||||
import GButtonGroup from "../BaseComponents/GButtonGroup.vue";
|
||||
@@ -63,25 +70,6 @@ const tagAutocompleteValues = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
// Escape backslashes first so a backslash in user input never combines with the
|
||||
// quote we add to form an unintended escape (and to keep CodeQL's incomplete
|
||||
// string escaping rule satisfied).
|
||||
function escapeQuotedWhooshToken(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function normalizeToolTagValue(tag: string | string[]) {
|
||||
return String(tag)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.replace(/^'(.*)'$/, "$1");
|
||||
}
|
||||
|
||||
function quoteToolTagValue(tag: string | string[]): string | string[] {
|
||||
const normalizedValue = normalizeToolTagValue(tag);
|
||||
return /\s/.test(normalizedValue) ? `"${escapeQuotedWhooshToken(normalizedValue)}"` : normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeInlineFilterValue(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
|
||||
@@ -93,14 +93,16 @@ describe("ToolsListCard", () => {
|
||||
expect(wrapper.emitted("apply-filter")).toEqual([["tag", "collection_ops"]]);
|
||||
});
|
||||
|
||||
const SIGNED_IN_USER = {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
} as any;
|
||||
|
||||
it("adds and removes favorite tags for signed-in users", async () => {
|
||||
const { wrapper } = mountCard({
|
||||
currentUser: {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
},
|
||||
currentUser: SIGNED_IN_USER,
|
||||
favorites: { tools: [], tags: ["collection_ops"], edam_operations: ["operation_2409"], edam_topics: [] },
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
@@ -113,43 +115,36 @@ describe("ToolsListCard", () => {
|
||||
expect(userStore.addFavoriteTag).toHaveBeenCalledWith("data_cleanup");
|
||||
});
|
||||
|
||||
it("adds and removes favorite EDAM operations for signed-in users", async () => {
|
||||
const { wrapper } = mountCard({
|
||||
currentUser: {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
},
|
||||
// EDAM operations and topics share their entire dispatch path (`useToolsListCardActions`),
|
||||
// so verify one button per dispatch target with a parameterized test rather
|
||||
// than repeating the setup.
|
||||
it.each([
|
||||
{
|
||||
label: "EDAM operations",
|
||||
favorites: { tools: [], tags: [], edam_operations: ["operation_2409"], edam_topics: [] },
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
|
||||
const ontologyButton = wrapper.find(".inline-ontology-button");
|
||||
expect(wrapper.text()).toContain("Data handling");
|
||||
await ontologyButton.trigger("click");
|
||||
|
||||
expect(userStore.removeFavoriteEdamOperation).toHaveBeenCalledWith("operation_2409");
|
||||
});
|
||||
|
||||
it("adds and removes favorite EDAM topics for signed-in users", async () => {
|
||||
const { wrapper } = mountCard({
|
||||
currentUser: {
|
||||
id: "user-id",
|
||||
username: "test-user",
|
||||
email: "test@example.org",
|
||||
isAnonymous: false,
|
||||
},
|
||||
selector: ".inline-ontology-button",
|
||||
visibleSectionLabel: "Data handling",
|
||||
action: "removeFavoriteEdamOperation" as const,
|
||||
actionArg: "operation_2409",
|
||||
},
|
||||
{
|
||||
label: "EDAM topics",
|
||||
favorites: { tools: [], tags: [], edam_operations: [], edam_topics: ["topic_0091"] },
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
|
||||
const topicButton = wrapper.find('[data-description="favorite-edam-topic-button"]');
|
||||
expect(wrapper.text()).toContain("Data formats");
|
||||
await topicButton.trigger("click");
|
||||
|
||||
expect(userStore.removeFavoriteEdamTopic).toHaveBeenCalledWith("topic_0091");
|
||||
});
|
||||
selector: '[data-description="favorite-edam-topic-button"]',
|
||||
visibleSectionLabel: "Data formats",
|
||||
action: "removeFavoriteEdamTopic" as const,
|
||||
actionArg: "topic_0091",
|
||||
},
|
||||
])(
|
||||
"removes a favorite $label entry for signed-in users",
|
||||
async ({ favorites, selector, visibleSectionLabel, action, actionArg }) => {
|
||||
const { wrapper } = mountCard({ currentUser: SIGNED_IN_USER, favorites });
|
||||
const userStore = useUserStore() as any;
|
||||
expect(wrapper.text()).toContain(visibleSectionLabel);
|
||||
await wrapper.find(selector).trigger("click");
|
||||
expect(userStore[action]).toHaveBeenCalledWith(actionArg);
|
||||
},
|
||||
);
|
||||
|
||||
it("shows a login affordance for anonymous users", async () => {
|
||||
const { wrapper } = mountCard();
|
||||
|
||||
@@ -273,7 +273,7 @@ export const useToolStore = defineStore("toolStore", () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the curated `{tool_id: [tag, ...]}` mapping from `/api/tools/tags`
|
||||
* Fetch the curated `{tool_id: [tag, ...]}` mapping from `/api/tags/tool_tags`
|
||||
* and merge it into `toolsById`. Idempotent: subsequent calls are no-ops
|
||||
* once the mapping has been loaded for the current toolbox.
|
||||
*
|
||||
@@ -286,7 +286,7 @@ export const useToolStore = defineStore("toolStore", () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await axios.get(`${getAppRoot()}api/tools/tags`);
|
||||
const { data } = await axios.get(`${getAppRoot()}api/tags/tool_tags`);
|
||||
const mapping = (data ?? {}) as Record<string, string[]>;
|
||||
const merged: Record<string, Tool> = {};
|
||||
for (const [id, tool] of Object.entries(toolsById.value)) {
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { HistoryFilters } from "@/components/History/HistoryFilters";
|
||||
import { quoteToolTagValue } from "@/components/Panels/utilities";
|
||||
import { getWorkflowFilters } from "@/components/Workflow/List/workflowFilters";
|
||||
import Filtering, { contains } from "@/utils/filtering";
|
||||
|
||||
describe("test filtering helpers to convert filters to filter text", () => {
|
||||
const MyWorkflowFilters = getWorkflowFilters("my");
|
||||
const PublishedWorkflowFilters = getWorkflowFilters("published");
|
||||
// Mirror ToolsList.vue's real wiring — same `quoteToolTagValue` import —
|
||||
// so a regression in the production helper trips this test instead of the
|
||||
// test silently agreeing with itself via an inline reimplementation.
|
||||
const ToolTagFilters = new Filtering(
|
||||
{
|
||||
tag: {
|
||||
type: "MultiTags",
|
||||
handler: contains("tag", undefined, (value) => {
|
||||
const normalizedValue = String(value)
|
||||
.trim()
|
||||
.replace(/^"(.*)"$/, "$1")
|
||||
.replace(/^'(.*)'$/, "$1");
|
||||
return /\s/.test(normalizedValue) ? `"${normalizedValue}"` : normalizedValue;
|
||||
}),
|
||||
handler: contains("tag", undefined, quoteToolTagValue),
|
||||
menuItem: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -237,7 +237,7 @@ new tool panel without referencing the original sections at all.
|
||||
Curated tool tags
|
||||
-----------------
|
||||
|
||||
Galaxy 26.0 introduces curated tool *tags* alongside EDAM operations and
|
||||
Galaxy 26.1 introduces curated tool *tags* alongside EDAM operations and
|
||||
topics. Curated tags map a tool id to one or more user-friendly labels (e.g.
|
||||
``Get Data``, ``Statistics``) and feed three pieces of UI: the
|
||||
``tag:`` autocompletion in the tool panel and the *Discover Tools* page,
|
||||
|
||||
@@ -163,6 +163,7 @@ from galaxy.tool_util.data import ToolDataTableManager as BaseToolDataTableManag
|
||||
from galaxy.tool_util.deps import containers
|
||||
from galaxy.tool_util.deps.dependencies import AppInfo
|
||||
from galaxy.tool_util.deps.views import DependencyResolversView
|
||||
from galaxy.tool_util.ontologies.ontology_data import configure_tool_tag_mapping
|
||||
from galaxy.tool_util.verify.test_data import TestDataResolver
|
||||
from galaxy.tools.biotools import get_galaxy_biotools_metadata_source
|
||||
from galaxy.tools.cache import ToolCache
|
||||
@@ -388,9 +389,7 @@ class MinimalGalaxyApplication(BasicSharedApp, HaltableContainer, SentryClientMi
|
||||
# Apply the optional admin override of the curated tool→tag mapping
|
||||
# before any Tool is constructed, so the first tool load sees the
|
||||
# right TOOL_TAG_MAPPING.
|
||||
from galaxy.tool_util.ontologies.ontology_data import configure_tool_tag_mapping
|
||||
|
||||
configure_tool_tag_mapping(getattr(self.config, "tool_tag_mappings_file", None))
|
||||
configure_tool_tag_mapping(self.config.tool_tag_mappings_file)
|
||||
|
||||
self.dynamic_tool_manager = DynamicToolManager(self)
|
||||
self._toolbox_lock = threading.RLock()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Manager for a user's favorites: tools, curated tool tags, and EDAM
|
||||
operations/topics, plus the user-visible ordering across all of them.
|
||||
|
||||
The favorites payload is persisted as a JSON document inside
|
||||
``user.preferences["favorites"]`` (a Text column). This manager owns its
|
||||
schema — validation, deduplication, and the ``order`` invariant that
|
||||
mirrors the per-type lists — so that controllers stay thin and other
|
||||
call sites can reuse the same logic without going through HTTP.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from galaxy import exceptions
|
||||
from galaxy.managers.context import ProvidesUserContext
|
||||
from galaxy.model import User
|
||||
from galaxy.schema.schema import (
|
||||
FavoriteObjectType,
|
||||
FavoriteOrderPayload,
|
||||
)
|
||||
|
||||
FAVORITE_OBJECT_TYPE_VALUES = tuple(object_type.value for object_type in FavoriteObjectType)
|
||||
|
||||
|
||||
class FavoritesManager:
|
||||
"""Load, mutate, and persist a user's favorites payload."""
|
||||
|
||||
def get(self, user: User) -> dict[str, Any]:
|
||||
"""Return the user's favorites normalized to the canonical shape."""
|
||||
raw = json.loads(user.preferences["favorites"]) if "favorites" in user.preferences else {}
|
||||
return _normalize(raw)
|
||||
|
||||
def add(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
user: User,
|
||||
object_type: FavoriteObjectType,
|
||||
raw_object_id: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
favorites = self.get(user)
|
||||
canonical_id = self._resolve_object_id(trans, user, object_type, raw_object_id)
|
||||
favorite_list = favorites[object_type.value]
|
||||
if canonical_id not in favorite_list:
|
||||
favorite_list.append(canonical_id)
|
||||
favorites = self._save(trans, user, favorites, commit=commit)
|
||||
return favorites
|
||||
|
||||
def remove(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
user: User,
|
||||
object_type: FavoriteObjectType,
|
||||
object_id: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
favorites = self.get(user)
|
||||
favorite_list = favorites[object_type.value]
|
||||
if object_id not in favorite_list:
|
||||
raise exceptions.ObjectNotFound("Given object is not in the list of favorites")
|
||||
favorite_list.remove(object_id)
|
||||
return self._save(trans, user, favorites, commit=commit)
|
||||
|
||||
def set_order(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
user: User,
|
||||
payload: FavoriteOrderPayload,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
favorites = self.get(user)
|
||||
# `FavoriteOrderItem` inherits from `Model`, which sets
|
||||
# `use_enum_values=True`; `item.object_type` is already a plain string.
|
||||
requested_order = [_order_entry(item.object_type, item.object_id) for item in payload.order]
|
||||
expected_keys = {(entry["object_type"], entry["object_id"]) for entry in favorites["order"]}
|
||||
requested_keys = {(entry["object_type"], entry["object_id"]) for entry in requested_order}
|
||||
if len(requested_order) != len(favorites["order"]) or requested_keys != expected_keys:
|
||||
raise exceptions.RequestParameterInvalidException(
|
||||
"Favorite order must contain every current favorite exactly once."
|
||||
)
|
||||
favorites["order"] = requested_order
|
||||
for object_type in FAVORITE_OBJECT_TYPE_VALUES:
|
||||
favorites[object_type] = [
|
||||
entry["object_id"] for entry in requested_order if entry["object_type"] == object_type
|
||||
]
|
||||
return self._save(trans, user, favorites, commit=commit)
|
||||
|
||||
def _save(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
user: User,
|
||||
favorites: dict[str, Any],
|
||||
*,
|
||||
commit: bool,
|
||||
) -> dict[str, Any]:
|
||||
user.preferences["favorites"] = json.dumps(favorites)
|
||||
if commit:
|
||||
trans.sa_session.commit()
|
||||
return favorites
|
||||
|
||||
def _resolve_object_id(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
user: User,
|
||||
object_type: FavoriteObjectType,
|
||||
raw_object_id: str,
|
||||
) -> str:
|
||||
if object_type is FavoriteObjectType.tools:
|
||||
return _resolve_tool_id(trans, user, raw_object_id)
|
||||
if object_type is FavoriteObjectType.tags:
|
||||
return _resolve_against_set(
|
||||
raw_object_id,
|
||||
trans.app.toolbox.curated_tool_tags,
|
||||
"Favorite tag cannot be empty.",
|
||||
"Could not find a curated tool tag named '{object_id}'.",
|
||||
)
|
||||
if object_type is FavoriteObjectType.edam_operations:
|
||||
return _resolve_against_set(
|
||||
raw_object_id,
|
||||
trans.app.toolbox.tool_edam_operations,
|
||||
"Favorite EDAM operation cannot be empty.",
|
||||
"Could not find an EDAM operation named '{object_id}'.",
|
||||
)
|
||||
if object_type is FavoriteObjectType.edam_topics:
|
||||
return _resolve_against_set(
|
||||
raw_object_id,
|
||||
trans.app.toolbox.tool_edam_topics,
|
||||
"Favorite EDAM topic cannot be empty.",
|
||||
"Could not find an EDAM topic named '{object_id}'.",
|
||||
)
|
||||
# FavoriteObjectType is exhaustive; FastAPI rejects unknown values upstream.
|
||||
raise exceptions.RequestParameterInvalidException(f"Unsupported favorite object type '{object_type}'.")
|
||||
|
||||
|
||||
def _resolve_tool_id(trans: ProvidesUserContext, user: User, raw_object_id: str) -> str:
|
||||
tool = trans.app.toolbox.get_tool(raw_object_id)
|
||||
if not tool:
|
||||
raise exceptions.ObjectNotFound(f"Could not find tool with id '{raw_object_id}'.")
|
||||
if not tool.allow_user_access(user):
|
||||
raise exceptions.AuthenticationFailed(f"Access denied for tool with id '{raw_object_id}'.")
|
||||
# Persist the canonical `tool.id` (which `get_tool` returns after
|
||||
# resolving aliases, old_ids, and versioned ids) so the client — which
|
||||
# keys `toolStore.toolsById` by the canonical id — can always render
|
||||
# the favorite. Without this, an alias like `cat1/1.0.0` would be
|
||||
# stored verbatim and then silently dropped from the My Tools panel.
|
||||
assert tool.id is not None
|
||||
return tool.id
|
||||
|
||||
|
||||
def _resolve_against_set(
|
||||
raw_object_id: str,
|
||||
valid_ids: frozenset[str],
|
||||
empty_message: str,
|
||||
not_found_template: str,
|
||||
) -> str:
|
||||
object_id = raw_object_id.strip()
|
||||
if not object_id:
|
||||
raise exceptions.RequestParameterInvalidException(empty_message)
|
||||
if object_id not in valid_ids:
|
||||
raise exceptions.ObjectNotFound(not_found_template.format(object_id=object_id))
|
||||
return object_id
|
||||
|
||||
|
||||
def _order_entry(object_type: str, object_id: str) -> dict[str, str]:
|
||||
return {"object_type": object_type, "object_id": object_id}
|
||||
|
||||
|
||||
def _normalize(favorites: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized: dict[str, Any] = {}
|
||||
for object_type in FAVORITE_OBJECT_TYPE_VALUES:
|
||||
object_ids = favorites.get(object_type) or []
|
||||
seen_ids: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for object_id in object_ids:
|
||||
if isinstance(object_id, str) and object_id and object_id not in seen_ids:
|
||||
seen_ids.add(object_id)
|
||||
deduped.append(object_id)
|
||||
normalized[object_type] = deduped
|
||||
|
||||
seen_entries: set[tuple[str, str]] = set()
|
||||
order: list[dict[str, str]] = []
|
||||
for raw_entry in favorites.get("order") or []:
|
||||
if not isinstance(raw_entry, dict):
|
||||
continue
|
||||
raw_object_type = raw_entry.get("object_type")
|
||||
raw_object_id = raw_entry.get("object_id")
|
||||
if not isinstance(raw_object_type, str) or not isinstance(raw_object_id, str):
|
||||
continue
|
||||
entry_key = (raw_object_type, raw_object_id)
|
||||
if (
|
||||
raw_object_type in FAVORITE_OBJECT_TYPE_VALUES
|
||||
and raw_object_id in normalized[raw_object_type]
|
||||
and entry_key not in seen_entries
|
||||
):
|
||||
seen_entries.add(entry_key)
|
||||
order.append(_order_entry(raw_object_type, raw_object_id))
|
||||
|
||||
for object_type in FAVORITE_OBJECT_TYPE_VALUES:
|
||||
for object_id in normalized[object_type]:
|
||||
entry_key = (object_type, object_id)
|
||||
if entry_key not in seen_entries:
|
||||
seen_entries.add(entry_key)
|
||||
order.append(_order_entry(object_type, object_id))
|
||||
|
||||
normalized["order"] = order
|
||||
return normalized
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import lru_cache
|
||||
from typing import (
|
||||
cast,
|
||||
Dict,
|
||||
@@ -15,6 +17,8 @@ from galaxy.tool_util.parser import ToolSource
|
||||
from galaxy.tool_util_models.tool_source import XrefDict
|
||||
from galaxy.util.resources import resource_string
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _multi_dict_mapping(content: str) -> Dict[str, List[str]]:
|
||||
mapping: Dict[str, List[str]] = {}
|
||||
@@ -35,17 +39,25 @@ EDAM_OPERATION_MAPPING_FILENAME = "edam_operation_mappings.tsv"
|
||||
EDAM_TOPIC_MAPPING_FILENAME = "edam_topic_mappings.tsv"
|
||||
TOOL_TAG_MAPPING_FILENAME = "tool_tag_mappings.yml"
|
||||
|
||||
BIOTOOLS_MAPPING_CONTENT = _read_ontology_data_text(BIOTOOLS_MAPPING_FILENAME)
|
||||
BIOTOOLS_MAPPING: Dict[str, List[str]] = defaultdict(list)
|
||||
for line in BIOTOOLS_MAPPING_CONTENT.splitlines():
|
||||
if not line.startswith("#"):
|
||||
tool_id, xref = line.split("\t")
|
||||
BIOTOOLS_MAPPING[tool_id].append(xref)
|
||||
EDAM_OPERATION_MAPPING_CONTENT = _read_ontology_data_text(EDAM_OPERATION_MAPPING_FILENAME)
|
||||
EDAM_OPERATION_MAPPING: Dict[str, List[str]] = _multi_dict_mapping(EDAM_OPERATION_MAPPING_CONTENT)
|
||||
|
||||
EDAM_TOPIC_MAPPING_CONTENT = _read_ontology_data_text(EDAM_TOPIC_MAPPING_FILENAME)
|
||||
EDAM_TOPIC_MAPPING: Dict[str, List[str]] = _multi_dict_mapping(EDAM_TOPIC_MAPPING_CONTENT)
|
||||
@lru_cache(maxsize=1)
|
||||
def _biotools_mapping() -> Dict[str, List[str]]:
|
||||
mapping: Dict[str, List[str]] = defaultdict(list)
|
||||
for line in _read_ontology_data_text(BIOTOOLS_MAPPING_FILENAME).splitlines():
|
||||
if not line.startswith("#"):
|
||||
tool_id, xref = line.split("\t")
|
||||
mapping[tool_id].append(xref)
|
||||
return mapping
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _edam_operation_mapping() -> Dict[str, List[str]]:
|
||||
return _multi_dict_mapping(_read_ontology_data_text(EDAM_OPERATION_MAPPING_FILENAME))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _edam_topic_mapping() -> Dict[str, List[str]]:
|
||||
return _multi_dict_mapping(_read_ontology_data_text(EDAM_TOPIC_MAPPING_FILENAME))
|
||||
|
||||
|
||||
def _load_tool_tag_mapping(content: str) -> Dict[str, List[str]]:
|
||||
@@ -53,15 +65,26 @@ def _load_tool_tag_mapping(content: str) -> Dict[str, List[str]]:
|
||||
Dict[str, List[str]],
|
||||
(yaml.safe_load(content) or {}).get("tool_tags", {}),
|
||||
)
|
||||
# Galaxy lowercases tool ids when constructing `Tool.all_ids` (see
|
||||
# `Tool._setup_id`), so the curated mapping must use lowercase keys to
|
||||
# be looked up successfully. Normalize at load time so admin-supplied
|
||||
# YAML files don't have to worry about case.
|
||||
# `Tool.all_ids` is built from lowercased tool ids (see `Tool.parse` in
|
||||
# `lib/galaxy/tools/__init__.py`, around the `self_ids = [self.id.lower()]`
|
||||
# block), so the curated mapping must use lowercase keys to be looked up
|
||||
# successfully. Normalize at load time so admin-supplied YAML files don't
|
||||
# have to worry about case.
|
||||
return {tool_id.lower(): tags for tool_id, tags in raw.items()}
|
||||
|
||||
|
||||
TOOL_TAG_MAPPING_CONTENT = _read_ontology_data_text(TOOL_TAG_MAPPING_FILENAME)
|
||||
TOOL_TAG_MAPPING: Dict[str, List[str]] = _load_tool_tag_mapping(TOOL_TAG_MAPPING_CONTENT)
|
||||
_TOOL_TAG_MAPPING_OVERRIDE: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
|
||||
def _tool_tag_mapping() -> Dict[str, List[str]]:
|
||||
if _TOOL_TAG_MAPPING_OVERRIDE is not None:
|
||||
return _TOOL_TAG_MAPPING_OVERRIDE
|
||||
return _bundled_tool_tag_mapping()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _bundled_tool_tag_mapping() -> Dict[str, List[str]]:
|
||||
return _load_tool_tag_mapping(_read_ontology_data_text(TOOL_TAG_MAPPING_FILENAME))
|
||||
|
||||
|
||||
def configure_tool_tag_mapping(file_path: Optional[str]) -> None:
|
||||
@@ -81,15 +104,13 @@ def configure_tool_tag_mapping(file_path: Optional[str]) -> None:
|
||||
new_mapping = _load_tool_tag_mapping(fh.read())
|
||||
except OSError:
|
||||
# Surface the failure but keep the bundled fallback active.
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
log.warning(
|
||||
"Could not read tool_tag_mappings_file %s; falling back to bundled mapping.",
|
||||
file_path,
|
||||
)
|
||||
return
|
||||
global TOOL_TAG_MAPPING
|
||||
TOOL_TAG_MAPPING = new_mapping
|
||||
global _TOOL_TAG_MAPPING_OVERRIDE
|
||||
_TOOL_TAG_MAPPING_OVERRIDE = new_mapping
|
||||
|
||||
|
||||
class OntologyData(NamedTuple):
|
||||
@@ -107,17 +128,19 @@ def biotools_reference(xrefs):
|
||||
|
||||
|
||||
def legacy_biotools_external_reference(all_ids: List[str]) -> List[str]:
|
||||
biotools_mapping = _biotools_mapping()
|
||||
for tool_id in all_ids:
|
||||
if tool_id in BIOTOOLS_MAPPING:
|
||||
return BIOTOOLS_MAPPING[tool_id]
|
||||
if tool_id in biotools_mapping:
|
||||
return biotools_mapping[tool_id]
|
||||
return []
|
||||
|
||||
|
||||
def curated_tool_tags(all_ids: List[str]) -> List[str]:
|
||||
mapping = _tool_tag_mapping()
|
||||
seen = set()
|
||||
tags: List[str] = []
|
||||
for tool_id in all_ids:
|
||||
for tag in TOOL_TAG_MAPPING.get(tool_id, []):
|
||||
for tag in mapping.get(tool_id, []):
|
||||
if tag not in seen:
|
||||
seen.add(tag)
|
||||
tags.append(tag)
|
||||
@@ -137,14 +160,16 @@ def expand_ontology_data(
|
||||
edam_operations = tool_source.parse_edam_operations()
|
||||
edam_topics = tool_source.parse_edam_topics()
|
||||
|
||||
edam_operation_mapping = _edam_operation_mapping()
|
||||
for tool_id in all_ids:
|
||||
if tool_id in EDAM_OPERATION_MAPPING:
|
||||
edam_operations = EDAM_OPERATION_MAPPING[tool_id]
|
||||
if tool_id in edam_operation_mapping:
|
||||
edam_operations = edam_operation_mapping[tool_id]
|
||||
break
|
||||
|
||||
edam_topic_mapping = _edam_topic_mapping()
|
||||
for tool_id in all_ids:
|
||||
if tool_id in EDAM_TOPIC_MAPPING:
|
||||
edam_topics = EDAM_TOPIC_MAPPING[tool_id]
|
||||
if tool_id in edam_topic_mapping:
|
||||
edam_topics = edam_topic_mapping[tool_id]
|
||||
break
|
||||
|
||||
has_missing_data = len(edam_operations) == 0 or len(edam_topics) == 0
|
||||
|
||||
@@ -210,10 +210,6 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
|
||||
self._curated_tool_tags: Optional[FrozenSet[str]] = None
|
||||
self._tool_edam_operations: Optional[FrozenSet[str]] = None
|
||||
self._tool_edam_topics: Optional[FrozenSet[str]] = None
|
||||
# Lazy {tool_id -> [tag, ...]} mapping for tools that carry curated tags.
|
||||
# Served verbatim from the /api/tools/tags endpoint to keep tool_tags
|
||||
# out of the bulk /api/tools payload (and out of its cache key).
|
||||
self._curated_tool_tags_by_id: Optional[Dict[str, List[str]]] = None
|
||||
# In-memory dictionary that defines the layout of the tool panel.
|
||||
self._tool_panel = ToolPanelElements()
|
||||
self._index = 0
|
||||
@@ -1315,7 +1311,6 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
|
||||
self._curated_tool_tags = None
|
||||
self._tool_edam_operations = None
|
||||
self._tool_edam_topics = None
|
||||
self._curated_tool_tags_by_id = None
|
||||
|
||||
def _collect_tool_attribute_set(self, attribute: str) -> FrozenSet[str]:
|
||||
values: set = set()
|
||||
@@ -1345,23 +1340,6 @@ class AbstractToolBox(ManagesIntegratedToolPanelMixin):
|
||||
self._tool_edam_topics = self._collect_tool_attribute_set("edam_topics")
|
||||
return self._tool_edam_topics
|
||||
|
||||
@property
|
||||
def curated_tool_tags_by_id(self) -> Dict[str, List[str]]:
|
||||
"""Mapping of tool id -> curated tag names for tools that carry any.
|
||||
|
||||
Served from the /api/tools/tags endpoint so the My Tools panel can
|
||||
fetch the curated tag mapping out-of-band, instead of bloating
|
||||
/api/tools.
|
||||
"""
|
||||
if self._curated_tool_tags_by_id is None:
|
||||
mapping: Dict[str, List[str]] = {}
|
||||
for _, tool in self.tools():
|
||||
tags = getattr(tool, "tool_tags", None)
|
||||
if tags:
|
||||
mapping[tool.id] = list(tags)
|
||||
self._curated_tool_tags_by_id = mapping
|
||||
return self._curated_tool_tags_by_id
|
||||
|
||||
def package_tool(self, trans, tool_id):
|
||||
"""
|
||||
Create a tarball with the tool's xml, help images, and test data.
|
||||
|
||||
@@ -423,14 +423,12 @@ class FetchTools:
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/tools/tags",
|
||||
operation_id="tools__tags",
|
||||
"/api/tags/tool_tags",
|
||||
operation_id="tags__tool_tags",
|
||||
summary="Return the curated tool-id to tag-name mapping for currently-loaded tools.",
|
||||
)
|
||||
def tool_tags(self, trans: ProvidesHistoryContext = DependsOnTrans) -> dict[str, list[str]]:
|
||||
# Sidecar for the My Tools panel: keeps `tool_tags` out of the bulk
|
||||
# /api/tools payload and out of its toolbox-level cache key.
|
||||
return trans.app.toolbox.curated_tool_tags_by_id
|
||||
return self.service.curated_tool_tags_by_id(trans)
|
||||
|
||||
|
||||
class ToolsController(BaseGalaxyAPIController, UsesVisualizationMixin):
|
||||
|
||||
@@ -32,6 +32,7 @@ from galaxy.managers.context import (
|
||||
ProvidesHistoryContext,
|
||||
ProvidesUserContext,
|
||||
)
|
||||
from galaxy.managers.favorites import FavoritesManager
|
||||
from galaxy.model import (
|
||||
Dataset,
|
||||
FormDefinition,
|
||||
@@ -140,135 +141,6 @@ RecalculateDiskUsageResponseDescriptions = {
|
||||
},
|
||||
}
|
||||
|
||||
FAVORITE_OBJECT_TYPE_VALUES = tuple(object_type.value for object_type in FavoriteObjectType)
|
||||
|
||||
|
||||
def _favorite_order_entry(object_type: str, object_id: str) -> dict[str, str]:
|
||||
return {
|
||||
"object_type": object_type,
|
||||
"object_id": object_id,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_favorites(favorites: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized: dict[str, Any] = {}
|
||||
for object_type in FAVORITE_OBJECT_TYPE_VALUES:
|
||||
object_ids = favorites.get(object_type) or []
|
||||
seen_ids: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for object_id in object_ids:
|
||||
if isinstance(object_id, str) and object_id and object_id not in seen_ids:
|
||||
seen_ids.add(object_id)
|
||||
deduped.append(object_id)
|
||||
normalized[object_type] = deduped
|
||||
|
||||
seen_entries: set[tuple[str, str]] = set()
|
||||
order: list[dict[str, str]] = []
|
||||
for raw_entry in favorites.get("order") or []:
|
||||
if not isinstance(raw_entry, dict):
|
||||
continue
|
||||
raw_object_type = raw_entry.get("object_type")
|
||||
raw_object_id = raw_entry.get("object_id")
|
||||
if not isinstance(raw_object_type, str) or not isinstance(raw_object_id, str):
|
||||
continue
|
||||
object_type = raw_object_type
|
||||
object_id = raw_object_id
|
||||
entry_key = (object_type, object_id)
|
||||
if (
|
||||
object_type in FAVORITE_OBJECT_TYPE_VALUES
|
||||
and object_id in normalized[object_type]
|
||||
and entry_key not in seen_entries
|
||||
):
|
||||
seen_entries.add(entry_key)
|
||||
order.append(_favorite_order_entry(object_type, object_id))
|
||||
|
||||
for object_type in FAVORITE_OBJECT_TYPE_VALUES:
|
||||
for object_id in normalized[object_type]:
|
||||
entry_key = (object_type, object_id)
|
||||
if entry_key not in seen_entries:
|
||||
seen_entries.add(entry_key)
|
||||
order.append(_favorite_order_entry(object_type, object_id))
|
||||
|
||||
normalized["order"] = order
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_favorite_tool_id(trans: ProvidesUserContext, user: User, raw_object_id: str) -> str:
|
||||
tool = trans.app.toolbox.get_tool(raw_object_id)
|
||||
if not tool:
|
||||
raise exceptions.ObjectNotFound(f"Could not find tool with id '{raw_object_id}'.")
|
||||
if not tool.allow_user_access(user):
|
||||
raise exceptions.AuthenticationFailed(f"Access denied for tool with id '{raw_object_id}'.")
|
||||
# `get_tool` resolves aliases, old_ids, and versioned ids; persist the
|
||||
# canonical `tool.id` so the client (which keys `toolStore.toolsById` by
|
||||
# the canonical id) can always render the favorite. Without this, posting
|
||||
# an alias like `cat1/1.0.0` would be accepted, stored verbatim, and then
|
||||
# silently dropped from the My Tools panel because `localToolsById` has
|
||||
# no `cat1/1.0.0` entry. `Tool.id` is typed Optional only because tools
|
||||
# mid-construction may not yet have one; a fully-loaded toolbox tool
|
||||
# always does.
|
||||
assert tool.id is not None
|
||||
return tool.id
|
||||
|
||||
|
||||
def _resolve_favorite_against_set(
|
||||
raw_object_id: str,
|
||||
valid_ids: frozenset[str],
|
||||
empty_message: str,
|
||||
not_found_template: str,
|
||||
) -> str:
|
||||
object_id = raw_object_id.strip()
|
||||
if not object_id:
|
||||
raise exceptions.RequestParameterInvalidException(empty_message)
|
||||
if object_id not in valid_ids:
|
||||
raise exceptions.ObjectNotFound(not_found_template.format(object_id=object_id))
|
||||
return object_id
|
||||
|
||||
|
||||
def _resolve_favorite_object_id(
|
||||
object_type: FavoriteObjectType,
|
||||
raw_object_id: str,
|
||||
trans: ProvidesUserContext,
|
||||
user: User,
|
||||
) -> str:
|
||||
"""Validate `raw_object_id` for `object_type` and return its canonical form.
|
||||
|
||||
Raises Galaxy exceptions on missing/empty input and on unknown ids.
|
||||
"""
|
||||
if object_type.value == FavoriteObjectType.tools.value:
|
||||
return _resolve_favorite_tool_id(trans, user, raw_object_id)
|
||||
if object_type.value == FavoriteObjectType.tags.value:
|
||||
return _resolve_favorite_against_set(
|
||||
raw_object_id,
|
||||
trans.app.toolbox.curated_tool_tags,
|
||||
"Favorite tag cannot be empty.",
|
||||
"Could not find a curated tool tag named '{object_id}'.",
|
||||
)
|
||||
if object_type.value == FavoriteObjectType.edam_operations.value:
|
||||
return _resolve_favorite_against_set(
|
||||
raw_object_id,
|
||||
trans.app.toolbox.tool_edam_operations,
|
||||
"Favorite EDAM operation cannot be empty.",
|
||||
"Could not find an EDAM operation named '{object_id}'.",
|
||||
)
|
||||
if object_type.value == FavoriteObjectType.edam_topics.value:
|
||||
return _resolve_favorite_against_set(
|
||||
raw_object_id,
|
||||
trans.app.toolbox.tool_edam_topics,
|
||||
"Favorite EDAM topic cannot be empty.",
|
||||
"Could not find an EDAM topic named '{object_id}'.",
|
||||
)
|
||||
# FavoriteObjectType is exhaustive; FastAPI rejects unknown values upstream.
|
||||
raise exceptions.RequestParameterInvalidException(f"Unsupported favorite object type '{object_type}'.")
|
||||
|
||||
|
||||
def _persist_favorites(user: User, trans: ProvidesUserContext, favorites: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = _normalize_favorites(favorites)
|
||||
user.preferences["favorites"] = json.dumps(normalized)
|
||||
trans.sa_session.commit()
|
||||
return normalized
|
||||
|
||||
|
||||
UserUpdateBody = Body(default=..., title="Update user", description="The user values to update.")
|
||||
FavoriteObjectBody = Body(
|
||||
default=..., title="Set favorite", description="The id of an object the user wants to favorite."
|
||||
@@ -285,6 +157,7 @@ AnyUserModel = Union[DetailedUserModel, AnonUserModel]
|
||||
class FastAPIUsers:
|
||||
service: UsersService = depends(UsersService)
|
||||
user_serializer: users.UserSerializer = depends(users.UserSerializer)
|
||||
favorites_manager: FavoritesManager = depends(FavoritesManager)
|
||||
|
||||
@router.put(
|
||||
"/api/users/current/recalculate_disk_usage",
|
||||
@@ -525,20 +398,13 @@ class FastAPIUsers:
|
||||
object_id: str = ObjectIDPathParam,
|
||||
) -> FavoriteObjectsSummary:
|
||||
user = self.service.get_user(trans, user_id)
|
||||
favorites = _normalize_favorites(
|
||||
json.loads(user.preferences["favorites"]) if "favorites" in user.preferences else {}
|
||||
)
|
||||
favorite_list = favorites.get(object_type.value, [])
|
||||
if object_id not in favorite_list:
|
||||
raise exceptions.ObjectNotFound("Given object is not in the list of favorites")
|
||||
favorite_list.remove(object_id)
|
||||
favorites = _persist_favorites(user, trans, favorites)
|
||||
favorites = self.favorites_manager.remove(trans, user, object_type, object_id)
|
||||
return FavoriteObjectsSummary.model_validate(favorites)
|
||||
|
||||
@router.put(
|
||||
"/api/users/{user_id}/favorites/order",
|
||||
name="set_favorite_order",
|
||||
summary="Persist the user's top-level favorites order",
|
||||
summary="Persist the order of the user's favorites",
|
||||
)
|
||||
def set_favorite_order(
|
||||
self,
|
||||
@@ -547,30 +413,7 @@ class FastAPIUsers:
|
||||
payload: FavoriteOrderPayload = FavoriteOrderBody,
|
||||
) -> FavoriteObjectsSummary:
|
||||
user = self.service.get_user(trans, user_id)
|
||||
favorites = _normalize_favorites(
|
||||
json.loads(user.preferences["favorites"]) if "favorites" in user.preferences else {}
|
||||
)
|
||||
|
||||
# `Model` (base of `FavoriteOrderItem`) sets `use_enum_values=True`, so
|
||||
# pydantic stores `object_type` as the string value of the enum at
|
||||
# deserialization — accessing `.value` on it would AttributeError.
|
||||
requested_order = [
|
||||
_favorite_order_entry(order_item.object_type, order_item.object_id) for order_item in payload.order
|
||||
]
|
||||
expected_keys = {(entry["object_type"], entry["object_id"]) for entry in favorites["order"]}
|
||||
requested_keys = {(entry["object_type"], entry["object_id"]) for entry in requested_order}
|
||||
|
||||
if len(requested_order) != len(favorites["order"]) or requested_keys != expected_keys:
|
||||
raise exceptions.RequestParameterInvalidException(
|
||||
"Favorite order must contain every current favorite exactly once."
|
||||
)
|
||||
|
||||
favorites["order"] = requested_order
|
||||
for object_type in FAVORITE_OBJECT_TYPE_VALUES:
|
||||
favorites[object_type] = [
|
||||
entry["object_id"] for entry in requested_order if entry["object_type"] == object_type
|
||||
]
|
||||
favorites = _persist_favorites(user, trans, favorites)
|
||||
favorites = self.favorites_manager.set_order(trans, user, payload)
|
||||
return FavoriteObjectsSummary.model_validate(favorites)
|
||||
|
||||
@router.put(
|
||||
@@ -586,15 +429,7 @@ class FastAPIUsers:
|
||||
payload: FavoriteObject = FavoriteObjectBody,
|
||||
) -> FavoriteObjectsSummary:
|
||||
user = self.service.get_user(trans, user_id)
|
||||
favorites = _normalize_favorites(
|
||||
json.loads(user.preferences["favorites"]) if "favorites" in user.preferences else {}
|
||||
)
|
||||
canonical_object_id = _resolve_favorite_object_id(object_type, payload.object_id, trans, user)
|
||||
favorite_list = favorites.get(object_type.value, [])
|
||||
if canonical_object_id not in favorite_list:
|
||||
favorite_list.append(canonical_object_id)
|
||||
favorites[object_type.value] = favorite_list
|
||||
favorites = _persist_favorites(user, trans, favorites)
|
||||
favorites = self.favorites_manager.add(trans, user, object_type, payload.object_id)
|
||||
return FavoriteObjectsSummary.model_validate(favorites)
|
||||
|
||||
@router.put(
|
||||
|
||||
@@ -207,6 +207,18 @@ class ToolsService(ServiceBase):
|
||||
self.toolbox_search = toolbox_search
|
||||
self.history_manager = history_manager
|
||||
|
||||
def curated_tool_tags_by_id(self, trans: ProvidesUserContext) -> dict[str, list[str]]:
|
||||
"""Return {tool_id: [tag, ...]} for currently-loaded tools that carry curated tags.
|
||||
|
||||
Sidecar for the My Tools panel; keeps `tool_tags` out of the bulk /api/tools
|
||||
payload (and out of its cache key).
|
||||
"""
|
||||
mapping: dict[str, list[str]] = {}
|
||||
for _, tool in trans.app.toolbox.tools():
|
||||
if tool.tool_tags:
|
||||
mapping[tool.id] = list(tool.tool_tags)
|
||||
return mapping
|
||||
|
||||
def file_landing_to_tool_landing(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
|
||||
@@ -162,8 +162,8 @@ class TestToolsApi(ApiTestCase, TestsTools):
|
||||
# lives under. Verify the sidecar API and the Whoosh-backed search
|
||||
# both respect the mapping.
|
||||
|
||||
# /api/tools/tags exposes the raw mapping consumed by the My Tools panel.
|
||||
tag_map = self._get("tools/tags").json()
|
||||
# /api/tags/tool_tags exposes the raw mapping consumed by the My Tools panel.
|
||||
tag_map = self._get("tags/tool_tags").json()
|
||||
assert tag_map.get("__UNZIP_COLLECTION__") == ["Collection Operations"]
|
||||
assert tag_map.get("liftOver1") == ["Lift-Over"]
|
||||
assert tag_map.get("upload1") == ["Get Data"]
|
||||
|
||||
@@ -423,6 +423,32 @@ class TestWithSeleniumMixin(GalaxyTestSeleniumContext, UsesApiTestCaseMixin, Use
|
||||
axe_results = self.axe_eval()
|
||||
assert_baseline_accessible(axe_results)
|
||||
|
||||
def playwright_drag_item_above(self, source_locator, target_locator):
|
||||
"""Drag ``source_locator`` so it is dropped immediately above ``target_locator``.
|
||||
|
||||
Playwright's ``drag_to`` jumps the cursor and is fooled by drop zones
|
||||
that activate only after a sustained mouse-move. Hand-rolled mouse
|
||||
moves (with intermediate steps and a small initial nudge) are reliable
|
||||
for HTML5-DnD targets used across the tool-panel UIs.
|
||||
"""
|
||||
source_locator.scroll_into_view_if_needed()
|
||||
target_locator.scroll_into_view_if_needed()
|
||||
source_box = source_locator.bounding_box()
|
||||
target_box = target_locator.bounding_box()
|
||||
assert source_box is not None, "source element has no bounding box (off-screen?)"
|
||||
assert target_box is not None, "target element has no bounding box (off-screen?)"
|
||||
|
||||
source_x = source_box["x"] + (source_box["width"] / 2)
|
||||
source_y = source_box["y"] + (source_box["height"] / 2)
|
||||
target_x = target_box["x"] + (target_box["width"] / 2)
|
||||
target_y = target_box["y"] + min(8, target_box["height"] / 4)
|
||||
|
||||
self.page.mouse.move(source_x, source_y)
|
||||
self.page.mouse.down()
|
||||
self.page.mouse.move(source_x, source_y + 12, steps=4)
|
||||
self.page.mouse.move(target_x, target_y, steps=20)
|
||||
self.page.mouse.up()
|
||||
|
||||
def _target_url_from_selenium(self):
|
||||
# Deal with the case when Galaxy has a different URL when being accessed by Selenium
|
||||
# then when being accessed by local API calls.
|
||||
|
||||
@@ -17,54 +17,66 @@ class TestToolDiscoveryViewAnonymous(SeleniumTestCase):
|
||||
advanced search, and list vs grid view toggling.
|
||||
"""
|
||||
|
||||
FILTER_FAILED_TOOL_ID = "__FILTER_FAILED_DATASETS__"
|
||||
|
||||
def _open_tools_list_with_filter_failed(self):
|
||||
"""Navigate to the Tool Discovery view and search for the FILTER_FAILED tool.
|
||||
|
||||
Most discovery-view tests want the tools list visible and the FILTER_FAILED
|
||||
card rendered before the test exercises a specific behavior on it.
|
||||
"""
|
||||
self.home()
|
||||
self.components.tool_panel.discover_tools_link.wait_for_and_click()
|
||||
tools_list = self.components.tools_list
|
||||
tools_list._.wait_for_visible()
|
||||
tools_list.search_input.wait_for_and_send_keys("filter failed")
|
||||
tools_list.tool_card(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_visible()
|
||||
return tools_list
|
||||
|
||||
@transient_failure(issue=21225, potentially_fixed=True)
|
||||
@selenium_test
|
||||
def test_tool_discovery_landing(self):
|
||||
"""Test navigation to the tool discovery view."""
|
||||
# Navigate to home page
|
||||
"""Discover-Tools link navigates to the rich tools list."""
|
||||
self.home()
|
||||
|
||||
# Access the tool discovery view via the "Discover Tools" link
|
||||
tool_panel = self.components.tool_panel
|
||||
tool_panel.discover_tools_link.wait_for_and_click()
|
||||
|
||||
# Verify the tools list view is displayed
|
||||
self.components.tool_panel.discover_tools_link.wait_for_and_click()
|
||||
tools_list = self.components.tools_list
|
||||
tools_list._.wait_for_visible()
|
||||
self.screenshot("tools_list_landing")
|
||||
|
||||
tools_list.search_input.wait_for_and_send_keys("filter failed")
|
||||
|
||||
# Verify the filtered tool card appears
|
||||
tools_list.tool_card(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
|
||||
@selenium_test
|
||||
def test_tool_discovery_search_renders_matching_card(self):
|
||||
tools_list = self._open_tools_list_with_filter_failed()
|
||||
self.screenshot("tools_list_filtered")
|
||||
tools_list.tool_card(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_visible()
|
||||
|
||||
# Click the version button to show version information
|
||||
tools_list.version_button(tool_id="__FILTER_FAILED_DATASETS__").wait_for_and_click()
|
||||
@selenium_test
|
||||
def test_tool_discovery_version_button(self):
|
||||
tools_list = self._open_tools_list_with_filter_failed()
|
||||
tools_list.version_button(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_and_click()
|
||||
self.screenshot("tools_list_show_version")
|
||||
|
||||
# Verify favorite button is available (not showing login message)
|
||||
button = tools_list.favorite_tool_button(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
|
||||
# When logged in, the title should not be the login prompt
|
||||
title = button.get_attribute("title")
|
||||
assert title == "Login or Register to Favorite Tools"
|
||||
@selenium_test
|
||||
def test_tool_discovery_favorite_button_prompts_login_anonymous(self):
|
||||
tools_list = self._open_tools_list_with_filter_failed()
|
||||
button = tools_list.favorite_tool_button(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_visible()
|
||||
assert button.get_attribute("title") == "Login or Register to Favorite Tools"
|
||||
|
||||
# Verify help is initially hidden
|
||||
tools_list.tool_help(tool_id="__FILTER_FAILED_DATASETS__").assert_absent()
|
||||
@selenium_test
|
||||
def test_tool_discovery_help_toggle_shows_and_hides(self):
|
||||
tools_list = self._open_tools_list_with_filter_failed()
|
||||
tools_list.tool_help(tool_id=self.FILTER_FAILED_TOOL_ID).assert_absent()
|
||||
|
||||
# Toggle help to show it
|
||||
tools_list.toggle_help(tool_id="__FILTER_FAILED_DATASETS__").wait_for_and_click()
|
||||
tools_list.tool_help(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible()
|
||||
tools_list.toggle_help(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_and_click()
|
||||
tools_list.tool_help(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_visible()
|
||||
self.screenshot("tools_list_show_help")
|
||||
|
||||
# Toggle help to hide it again
|
||||
tools_list.toggle_help(tool_id="__FILTER_FAILED_DATASETS__").wait_for_and_click()
|
||||
tools_list.tool_help(tool_id="__FILTER_FAILED_DATASETS__").wait_for_absent()
|
||||
tools_list.toggle_help(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_and_click()
|
||||
tools_list.tool_help(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_absent()
|
||||
|
||||
# Click the open tool button to navigate to the tool
|
||||
tools_list.open_tool_button(tool_id="__FILTER_FAILED_DATASETS__").wait_for_and_click()
|
||||
|
||||
# Verify we've left the tools list view and the tool form is displayed
|
||||
@selenium_test
|
||||
def test_tool_discovery_open_tool_navigates_away(self):
|
||||
tools_list = self._open_tools_list_with_filter_failed()
|
||||
tools_list.open_tool_button(tool_id=self.FILTER_FAILED_TOOL_ID).wait_for_and_click()
|
||||
tools_list._.wait_for_absent()
|
||||
self.screenshot("tools_list_navigated_to_tool")
|
||||
|
||||
|
||||
@@ -30,23 +30,7 @@ class TestToolPanelSearchPlaywright(SeleniumTestCase):
|
||||
f'[data-favorite-type="{source_type}"] [data-description="favorite-top-level-drag-target"]'
|
||||
)
|
||||
target_item = self.page.locator(f'[data-favorite-type="{target_type}"]')
|
||||
source_handle.scroll_into_view_if_needed()
|
||||
target_item.scroll_into_view_if_needed()
|
||||
source_box = source_handle.bounding_box()
|
||||
target_box = target_item.bounding_box()
|
||||
assert source_box is not None
|
||||
assert target_box is not None
|
||||
|
||||
source_x = source_box["x"] + (source_box["width"] / 2)
|
||||
source_y = source_box["y"] + (source_box["height"] / 2)
|
||||
target_x = target_box["x"] + (target_box["width"] / 2)
|
||||
target_y = target_box["y"] + min(8, target_box["height"] / 4)
|
||||
|
||||
self.page.mouse.move(source_x, source_y)
|
||||
self.page.mouse.down()
|
||||
self.page.mouse.move(source_x, source_y + 12, steps=4)
|
||||
self.page.mouse.move(target_x, target_y, steps=20)
|
||||
self.page.mouse.up()
|
||||
self.playwright_drag_item_above(source_handle, target_item)
|
||||
|
||||
@playwright_only("Validates tool panel search behavior with Playwright backend.")
|
||||
@selenium_test
|
||||
|
||||
@@ -14,6 +14,10 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
)
|
||||
|
||||
import yaml
|
||||
from yaml.events import (
|
||||
@@ -71,14 +75,14 @@ class QuotingDumper(yaml.SafeDumper):
|
||||
)
|
||||
|
||||
|
||||
def represent_quoted_string(dumper, data):
|
||||
def represent_quoted_string(dumper: yaml.Dumper, data: str) -> yaml.ScalarNode:
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
|
||||
|
||||
|
||||
QuotingDumper.add_representer(QuotedString, represent_quoted_string)
|
||||
|
||||
|
||||
def fetch_tools(api_url=None, timeout=DEFAULT_TIMEOUT_SECONDS):
|
||||
def fetch_tools(api_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> list[dict[str, Any]]:
|
||||
"""Fetch tools from a Galaxy `/api/tools` endpoint.
|
||||
|
||||
Raises ``RuntimeError`` on transport / HTTP / decoding failures so the caller
|
||||
@@ -95,11 +99,11 @@ def fetch_tools(api_url=None, timeout=DEFAULT_TIMEOUT_SECONDS):
|
||||
raise RuntimeError(f"Server at {target_url} returned an unparseable response: {exc}") from exc
|
||||
|
||||
|
||||
def normalize_section_alias(value):
|
||||
def normalize_section_alias(value: str) -> str:
|
||||
return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]+", "_", value.lower())).strip("_")
|
||||
|
||||
|
||||
def tool_id_candidates(tool_id):
|
||||
def tool_id_candidates(tool_id: str) -> list[str]:
|
||||
candidates = [tool_id]
|
||||
if tool_id.startswith("toolshed.") and tool_id.count("/") >= 5:
|
||||
short_id = tool_id.rsplit("/", 1)[0]
|
||||
@@ -108,10 +112,10 @@ def tool_id_candidates(tool_id):
|
||||
return candidates
|
||||
|
||||
|
||||
def extract_sections_and_tools(data):
|
||||
def extract_sections_and_tools(data: list[dict[str, Any]]) -> tuple[dict[str, list[str]], dict[str, str]]:
|
||||
"""Extract ToolSections and their tools from API response."""
|
||||
sections = {}
|
||||
section_ids = {}
|
||||
sections: dict[str, list[str]] = {}
|
||||
section_ids: dict[str, str] = {}
|
||||
|
||||
for item in data:
|
||||
if item.get("model_class") == "ToolSection":
|
||||
@@ -139,7 +143,7 @@ def extract_sections_and_tools(data):
|
||||
return sections, section_ids
|
||||
|
||||
|
||||
def load_existing_mappings():
|
||||
def load_existing_mappings() -> dict[str, Any]:
|
||||
"""Load existing tool_tag_mappings.yml"""
|
||||
if OUTPUT_FILE.exists():
|
||||
with open(OUTPUT_FILE, encoding="utf-8") as f:
|
||||
@@ -147,10 +151,10 @@ def load_existing_mappings():
|
||||
return {"tool_tags": {}}
|
||||
|
||||
|
||||
def normalize_tool_tags(tool_tags):
|
||||
normalized = {}
|
||||
def normalize_tool_tags(tool_tags: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||
normalized: dict[str, list[str]] = {}
|
||||
for tool_id, tags in tool_tags.items():
|
||||
unique_tags = []
|
||||
unique_tags: list[str] = []
|
||||
for tag in tags:
|
||||
if tag not in unique_tags:
|
||||
unique_tags.append(tag)
|
||||
@@ -158,7 +162,7 @@ def normalize_tool_tags(tool_tags):
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_mappings_data(data):
|
||||
def validate_mappings_data(data: dict[str, Any]) -> dict[str, dict[str, list[str]]]:
|
||||
tool_tags = data.get("tool_tags", {})
|
||||
if not isinstance(tool_tags, dict):
|
||||
raise ValueError("tool_tag_mappings.yml must contain a top-level 'tool_tags' mapping.")
|
||||
@@ -170,11 +174,11 @@ def validate_mappings_data(data):
|
||||
return {"tool_tags": normalize_tool_tags(tool_tags)}
|
||||
|
||||
|
||||
def needs_quotes(value):
|
||||
def needs_quotes(value: str) -> bool:
|
||||
return value in YAML_BOOLEAN_LIKE_VALUES or not SAFE_UNQUOTED_RE.fullmatch(value)
|
||||
|
||||
|
||||
def prepare_for_dump(value):
|
||||
def prepare_for_dump(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {prepare_for_dump(key): prepare_for_dump(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
@@ -184,10 +188,14 @@ def prepare_for_dump(value):
|
||||
return value
|
||||
|
||||
|
||||
def update_mappings(existing_data, sections, section_ids):
|
||||
def update_mappings(
|
||||
existing_data: dict[str, Any],
|
||||
sections: dict[str, list[str]],
|
||||
section_ids: dict[str, str],
|
||||
) -> dict[str, dict[str, list[str]]]:
|
||||
"""Refresh section tags while preserving existing non-section curated tags."""
|
||||
section_aliases = {normalize_section_alias(alias) for alias in set(section_ids) | set(section_ids.values())}
|
||||
tool_tags = {}
|
||||
tool_tags: dict[str, list[str]] = {}
|
||||
|
||||
for tool_id, tags in existing_data.get("tool_tags", {}).items():
|
||||
custom_tags = [tag for tag in tags if normalize_section_alias(tag) not in section_aliases]
|
||||
@@ -221,7 +229,7 @@ def update_mappings(existing_data, sections, section_ids):
|
||||
return {"tool_tags": normalize_tool_tags(tool_tags)}
|
||||
|
||||
|
||||
def save_mappings(data):
|
||||
def save_mappings(data: dict[str, Any]) -> None:
|
||||
"""Save updated tool_tag_mappings.yml"""
|
||||
validated_data = validate_mappings_data(data)
|
||||
serialized = yaml.dump(
|
||||
@@ -266,7 +274,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = _build_arg_parser().parse_args(argv)
|
||||
|
||||
global API_URL, OUTPUT_FILE
|
||||
|
||||
@@ -151,23 +151,11 @@ class TestToolBox(BaseToolBoxTestCase):
|
||||
tool.tool_tags = ["curated_tag"]
|
||||
|
||||
# The bulk /api/tools payload never carries `tool_tags`; clients pull
|
||||
# them from /api/tools/tags via `curated_tool_tags_by_id`.
|
||||
# them from /api/tags/tool_tags (served by ToolsService).
|
||||
as_dict = self.toolbox.to_dict(mock_trans(), in_panel=False)
|
||||
assert as_dict[0]["id"] == "test_tool"
|
||||
assert "tool_tags" not in as_dict[0]
|
||||
|
||||
def test_curated_tool_tags_by_id_returns_loaded_mapping(self):
|
||||
self._init_tool_in_section()
|
||||
mapper = routes.Mapper()
|
||||
mapper.connect("tool_runner", "/test/tool_runner")
|
||||
|
||||
tool = self.toolbox.get_tool("test_tool")
|
||||
tool.tool_tags = ["curated_tag", "another_tag"]
|
||||
# Re-register so the curated id caches pick up the mutation.
|
||||
self.toolbox.register_tool(tool)
|
||||
|
||||
assert self.toolbox.curated_tool_tags_by_id == {"test_tool": ["curated_tag", "another_tag"]}
|
||||
|
||||
def test_curated_id_caches_invalidate_on_tool_change(self):
|
||||
self._init_tool_in_section()
|
||||
mapper = routes.Mapper()
|
||||
@@ -188,17 +176,21 @@ class TestToolBox(BaseToolBoxTestCase):
|
||||
assert "operation_0224" in self.toolbox.tool_edam_operations
|
||||
assert "topic_3173" in self.toolbox.tool_edam_topics
|
||||
|
||||
def test_to_dict_cache_drops_when_tool_removed(self):
|
||||
def test_to_dict_omits_removed_tool(self):
|
||||
self._init_tool_in_section()
|
||||
mapper = routes.Mapper()
|
||||
mapper.connect("tool_runner", "/test/tool_runner")
|
||||
|
||||
# Populate the to_dict cache.
|
||||
self.toolbox.to_dict(mock_trans(), in_panel=False)
|
||||
assert "test_tool" in self.toolbox._tool_to_dict_cache
|
||||
# Populate the to_dict payload (and its cache).
|
||||
before = self.toolbox.to_dict(mock_trans(), in_panel=False)
|
||||
assert any(entry["id"] == "test_tool" for entry in before)
|
||||
|
||||
# Removing the tool must reflect in the next payload — proving the
|
||||
# cache is invalidated through observable behavior, not by poking
|
||||
# `_tool_to_dict_cache` directly.
|
||||
self.toolbox.remove_tool_by_id("test_tool")
|
||||
assert "test_tool" not in self.toolbox._tool_to_dict_cache
|
||||
after = self.toolbox.to_dict(mock_trans(), in_panel=False)
|
||||
assert not any(entry["id"] == "test_tool" for entry in after)
|
||||
|
||||
def test_my_tools_panel_view_is_registered(self):
|
||||
self._init_tool_in_section()
|
||||
@@ -362,7 +354,7 @@ class TestToolBox(BaseToolBoxTestCase):
|
||||
self._setup_two_versions()
|
||||
monkeypatch.setattr(
|
||||
ontology_data,
|
||||
"TOOL_TAG_MAPPING",
|
||||
"_TOOL_TAG_MAPPING_OVERRIDE",
|
||||
{
|
||||
"github.com/galaxyproject/example/test_tool/0.2": ["version_specific", "shared"],
|
||||
"github.com/galaxyproject/example/test_tool": ["toolshed_family", "shared"],
|
||||
@@ -381,7 +373,7 @@ class TestToolBox(BaseToolBoxTestCase):
|
||||
def test_curated_tool_tags_support_tool_ids_with_spaces(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ontology_data,
|
||||
"TOOL_TAG_MAPPING",
|
||||
"_TOOL_TAG_MAPPING_OVERRIDE",
|
||||
{
|
||||
"Remove beginning1": ["Text Manipulation"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user