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:
mvdbeek
2026-05-19 11:43:34 +02:00
parent ef71b896e4
commit df4b4c8a12
24 changed files with 691 additions and 826 deletions
+85 -76
View File
@@ -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;
+118 -333
View File
@@ -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.
});
+11 -14
View File
@@ -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 () => {
+16 -4
View File
@@ -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 () => {
+8 -20
View File
@@ -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();
+2 -2
View File
@@ -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)) {
+5 -7
View File
@@ -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,
},
},
+1 -1
View File
@@ -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,
+2 -3
View File
@@ -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()
+209
View File
@@ -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
-22
View File
@@ -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.
+3 -5
View File
@@ -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):
+6 -171
View File
@@ -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,
+2 -2
View File
@@ -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"]
+26
View File
@@ -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
+26 -18
View File
@@ -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
+12 -20
View 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"],
},