diff --git a/client/src/components/Form/FormDisplay.vue b/client/src/components/Form/FormDisplay.vue index 98265e052a9..a927c4f3b4a 100644 --- a/client/src/components/Form/FormDisplay.vue +++ b/client/src/components/Form/FormDisplay.vue @@ -80,8 +80,8 @@ const emit = defineEmits<{ (e: "onValidation", validation: [string, string] | null): void; (e: "stop-flagging"): void; (e: "update:active-node-id", id: number): void; - (e: "load-more", payload: unknown): void; - (e: "search-change", payload: unknown): void; + (e: "load-more", payload: { name: string; src: string; offset: number; limit: number; search?: string }): void; + (e: "search-change", payload: { name: string; src: string; query: string; limit: number }): void; }>(); const { diff --git a/client/src/components/Workflow/Run/WorkflowRunForm.vue b/client/src/components/Workflow/Run/WorkflowRunForm.vue index 9481db04985..4f7113a3b28 100644 --- a/client/src/components/Workflow/Run/WorkflowRunForm.vue +++ b/client/src/components/Workflow/Run/WorkflowRunForm.vue @@ -84,6 +84,7 @@ v-else :model="step" :validation-scroll-to="getValidationScrollTo(step.index)" + :history-id="currentHistoryId" @onChange="onDefaultStepInputs" @onValidation="onValidation" /> diff --git a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue index 8b797e651d6..06a2aecd549 100644 --- a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue +++ b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue @@ -26,7 +26,7 @@ import type { Step } from "@/stores/workflowStepStore"; import localize from "@/utils/localization"; import { errorMessageAsString } from "@/utils/simple-error"; -import { invokeWorkflow } from "./services"; +import { invokeWorkflow, searchHistoryContents } from "./services"; import WorkflowAnnotation from "../WorkflowAnnotation.vue"; import WorkflowNavigationTitle from "../WorkflowNavigationTitle.vue"; @@ -133,8 +133,23 @@ const computedActiveNodeId = computed(() => { return undefined; }); -const formInputs = computed(() => { - const inputs = [] as any[]; +// Build the form inputs once into a stable ref so paginated mutations +// (``onLoadMore`` / ``onSearchChange`` set ``input.options`` / ``options_meta`` +// on the matching step-input object below) don't rebuild the array. Vue's +// ``v-for`` in the child ``FormDisplay`` then doesn't unmount the dropdown's +// ```` element across paginated refreshes — important for selenium +// tests like ``test_workflow_rerun`` that ``select_set_value`` against the +// dropdown (type → wait UX_RENDER → send Enter on the same element ref). +// +// ``stepInputByIndex`` maps ``step.step_index`` (as string) to the live +// step-input object inside ``formInputs.value`` so the paginated-fetch +// handlers can locate and mutate it in O(1) without walking the array. +const formInputs = ref([]); +const stepInputByIndex = new Map(); + +function buildFormInputs() { + const inputs: any[] = []; + stepInputByIndex.clear(); // Add workflow parameters. Object.values(props.model.wpInputs).forEach((input) => { const inputCopy = Object.assign({}, input) as any; @@ -195,11 +210,15 @@ const formInputs = computed(() => { // disable collection mapping... stepAsInput.flavor = "module"; inputs.push(stepAsInput); + stepInputByIndex.set(String(step.step_index), stepAsInput); inputTypes.value[stepName] = stepType; } }); - return inputs; -}); + formInputs.value = inputs; +} + +buildFormInputs(); +watch(() => [props.model, props.requestState], buildFormInputs); /** * Returns the list of steps that do not match the workflow rerun `props.requestState`. @@ -287,6 +306,93 @@ function onChange(data: any) { formData.value = data; } +function shapeContentsRow(row: any) { + const src = row.history_content_type === "dataset_collection" ? "hdca" : "hda"; + return { + id: row.id, + src, + name: row.name, + hid: row.hid, + keep: false, + tags: row.tags || [], + }; +} + +async function fetchStepOptions( + name: string, + src: string, + payload: { offset?: number; limit?: number; search?: string } = {}, + mode: "append" | "replace" = "append", +) { + // Locate the live step-input object inside ``formInputs.value`` and + // mutate it in place — ``formInputs`` is a stable ref built once, so + // mutating ``input.options`` / ``input.options_meta`` doesn't rebuild + // the array and doesn't unmount the dropdown's ```` element. + // (``stepAsInput`` is a local copy built in ``buildFormInputs``; this + // is not prop mutation.) + const input = stepInputByIndex.get(String(name)); + if (!input) { + return; + } + const type = src === "hdca" ? "dataset_collection" : "dataset"; + const extensions = (input.acceptable_extensions || []) as string[]; + const limit = payload.limit || 50; + const offset = payload.offset || 0; + try { + const rows = await searchHistoryContents(props.model.historyId, { + extensions, + type, + search: payload.search, + offset, + limit, + }); + const shaped = (rows || []).map(shapeContentsRow); + let merged: any[]; + if (mode === "replace") { + merged = shaped; + } else { + const seen = new Set(); + const base = (input.options?.[src] as any[]) || []; + merged = [...base, ...shaped].filter((item) => { + const k = `${item.id}_${item.src}`; + if (seen.has(k)) { + return false; + } + seen.add(k); + return true; + }); + } + input.options = { ...(input.options || {}), [src]: merged }; + input.options_meta = { + ...(input.options_meta || {}), + [src]: { offset, limit, has_more: shaped.length === limit }, + }; + } catch (e) { + // intentionally silent — paging failures don't block the rest of the form + console.warn("history-contents pagination failed", e); + } +} + +function onLoadMore({ + name, + src, + offset, + limit, + search, +}: { + name: string; + src: string; + offset: number; + limit: number; + search?: string; +}) { + fetchStepOptions(name, src, { offset, limit, search }, "append"); +} + +function onSearchChange({ name, src, query, limit }: { name: string; src: string; query: string; limit?: number }) { + fetchStepOptions(name, src, { offset: 0, limit: limit || 50, search: query }, "replace"); +} + function onStorageUpdate(objectStoreId: string, intermediate: boolean) { if (intermediate) { preferredIntermediateObjectStoreId.value = objectStoreId; @@ -616,6 +722,8 @@ onBeforeMount(() => { :steps-not-matching-request="stepsNotMatchingRequest" @onChange="onChange" @onValidation="onValidation" + @load-more="onLoadMore" + @search-change="onSearchChange" @stop-flagging="checkInputMatching = false" @update:active-node-id="updateActiveNodeId" /> diff --git a/client/src/components/Workflow/Run/WorkflowRunInputStep.vue b/client/src/components/Workflow/Run/WorkflowRunInputStep.vue index 3adaf2bdad4..3b226b72fa7 100644 --- a/client/src/components/Workflow/Run/WorkflowRunInputStep.vue +++ b/client/src/components/Workflow/Run/WorkflowRunInputStep.vue @@ -7,7 +7,9 @@ :inputs="inputs" :validation-scroll-to="validationScrollTo" @onChange="onChange" - @onValidation="onValidation" /> + @onValidation="onValidation" + @load-more="onLoadMore" + @search-change="onSearchChange" />
No options available.
@@ -17,6 +19,8 @@ diff --git a/client/src/components/Workflow/Run/services.js b/client/src/components/Workflow/Run/services.js index 2ff1dc19a52..55457c3300e 100644 --- a/client/src/components/Workflow/Run/services.js +++ b/client/src/components/Workflow/Run/services.js @@ -26,6 +26,62 @@ export async function getRunData(workflowId, version = null, instance = false) { } } +/** + * Search history contents (HDAs + HDCAs) for a workflow run dropdown. Filters + * server-side by extension (canonical accept-set including implicit conversion + * targets), name/hid (search), and HDA-vs-HDCA type. Returns the raw API list. + * + * @param {String} historyId + * @param {Object} opts + * @param {Array} [opts.extensions] - sorted accept-set; empty/missing → no extension filter. + * @param {String} [opts.type] - "dataset" | "dataset_collection". + * @param {String} [opts.search] - name substring or numeric hid match. + * @param {Number} [opts.offset] + * @param {Number} [opts.limit] + */ +export async function searchHistoryContents(historyId, { extensions, type, search, offset = 0, limit = 50 } = {}) { + const q = []; + const qv = []; + q.push("visible-eq"); + qv.push("True"); + q.push("deleted-eq"); + qv.push("False"); + if (type) { + q.push("history_content_type-eq"); + qv.push(type); + } + if (extensions && extensions.length) { + q.push("extension-in"); + qv.push(extensions.join(",")); + } + if (search) { + const trimmed = String(search).trim(); + if (trimmed) { + if (/^\d+$/.test(trimmed)) { + q.push("hid-eq"); + qv.push(trimmed); + } else { + q.push("name-contains"); + qv.push(trimmed); + } + } + } + const params = new URLSearchParams(); + params.set("v", "dev"); + params.set("offset", String(offset)); + params.set("limit", String(limit)); + params.set("order", "hid-dsc"); + q.forEach((key) => params.append("q", key)); + qv.forEach((value) => params.append("qv", value)); + try { + const url = `${getAppRoot()}api/histories/${historyId}/contents?${params.toString()}`; + const response = await axios.get(url); + return response.data; + } catch (e) { + rethrowSimple(e); + } +} + /** * Invoke the specified workflow using the supplied data. * diff --git a/lib/galaxy/managers/history_contents.py b/lib/galaxy/managers/history_contents.py index 09dff7d3189..27ef9f176e2 100644 --- a/lib/galaxy/managers/history_contents.py +++ b/lib/galaxy/managers/history_contents.py @@ -7,6 +7,7 @@ import json import logging from typing import ( Any, + Optional, ) from sqlalchemy import ( @@ -601,29 +602,40 @@ class HistoryContentsFilters( ): # surprisingly (but ominously), this works for both content classes in the union that's filtered model_class = model.HistoryDatasetAssociation + # Threaded from ``parse_query_filters_with_relations`` so the depth-arbitrary + # ``extension`` HDCA clause can scope its recursive CTE to the current + # history. ``None`` means the filter is being parsed outside a history + # scope (e.g. ``/api/datasets``); in that case the HDCA branch of the + # extension filter degrades to a no-op (HDA filter still applies). + _current_history_id: Optional[int] = None def parse_query_filters_with_relations(self, query_filters: ValueFilterQueryParams, history_id): """Parse query filters but consider case where related filter is included.""" - has_related_q = [q for q in ("related-eq", "related") if query_filters.q and q in query_filters.q] - if query_filters.q and query_filters.qv and has_related_q: - qv_index = query_filters.q.index(has_related_q[0]) - qv_hid = query_filters.qv[qv_index] + prev_history_id = self._current_history_id + self._current_history_id = history_id + try: + has_related_q = [q for q in ("related-eq", "related") if query_filters.q and q in query_filters.q] + if query_filters.q and query_filters.qv and has_related_q: + qv_index = query_filters.q.index(has_related_q[0]) + qv_hid = query_filters.qv[qv_index] - # Type check whether hid is int - if not qv_hid.isdigit(): - raise glx_exceptions.RequestParameterInvalidException( - "unparsable value for related filter", - column="related", - operation="eq", - value=qv_hid, - ValueError="invalid type in filter", + # Type check whether hid is int + if not qv_hid.isdigit(): + raise glx_exceptions.RequestParameterInvalidException( + "unparsable value for related filter", + column="related", + operation="eq", + value=qv_hid, + ValueError="invalid type in filter", + ) + + query_filters_with_relations = self.get_query_filters_with_relations( + query_filters=query_filters, related_q=has_related_q[0], history_id=history_id ) - - query_filters_with_relations = self.get_query_filters_with_relations( - query_filters=query_filters, related_q=has_related_q[0], history_id=history_id - ) - return super().parse_query_filters(query_filters_with_relations) - return super().parse_query_filters(query_filters) + return super().parse_query_filters(query_filters_with_relations) + return super().parse_query_filters(query_filters) + finally: + self._current_history_id = prev_history_id def _parse_orm_filter(self, attr, op, val): # we need to use some manual/text/column fu here since some where clauses on the union don't work @@ -699,6 +711,39 @@ class HistoryContentsFilters( raise_filter_err(attr, op, val, "bad op in filter") + if attr == "extension" and op in ("eq", "in"): + # Apply different filter expressions to the HDA and HDCA branches + # of the union: HDAs filter by their own ``extension`` column, + # while HDCAs go through the depth-arbitrary recursive-CTE clause + # built by ``History._hdca_extensions_only_in_clause`` (a + # collection matches only when every leaf HDA extension is in the + # set, mirroring ``SummaryDatasetCollectionMatcher.hdca_match``). + # The CTE needs the current history_id; ``parse_query_filters_with_relations`` + # stashes it on ``self``. When parsed without a history scope + # (e.g. ``/api/datasets``), the HDCA branch degrades to a no-op + # — HDA filtering still applies, which covers the documented + # behavior of ``/api/datasets``. Other ops (``like``) fall through + # to the base parser's standard column filter (HDCAs are excluded + # there because the union's HDCA branch has ``extension=null()``). + if op == "eq": + extensions = {val} + else: + extensions = {e for e in val.split(",") if e} + if not extensions: + raise_filter_err(attr, op, val, "empty extension list") + history_id = self._current_history_id + + def extension_filter(component_class): + if component_class is model.HistoryDatasetAssociation: + return model.HistoryDatasetAssociation.extension.in_(extensions) + if component_class is model.HistoryDatasetCollectionAssociation: + if history_id is None: + return true() + return model.History._hdca_extensions_only_in_clause(extensions, history_id) + return None + + return self.parsed_filter(filter_type="orm_function", filter=extension_filter) + if (column_filter := get_filter(attr, op, val)) is not None: return self.parsed_filter(filter_type="orm", filter=column_filter) return super()._parse_orm_filter(attr, op, val) @@ -754,6 +799,10 @@ class HistoryContentsFilters( # 'hid-in' : { 'op': ( 'in' ), 'val': self.parse_int_list }, "name": {"op": ("eq", "contains", "like")}, "state": {"op": ("eq", "in")}, + # ``eq`` / ``in`` are handled specially in ``_parse_orm_filter`` + # so the HDCA branch can use a depth-arbitrary recursive CTE; + # ``like`` falls through to the base parser's column filter. + "extension": {"op": ("eq", "like", "in"), "val": {"in": lambda v: v.split(",")}}, "object_store_id": {"op": ("eq", "in")}, "quota_source_label": {"op": ("eq")}, "visible": {"op": ("eq"), "val": parse_bool}, diff --git a/lib/galaxy/managers/workflows.py b/lib/galaxy/managers/workflows.py index 7a73d1443dd..733dfa91183 100644 --- a/lib/galaxy/managers/workflows.py +++ b/lib/galaxy/managers/workflows.py @@ -1172,7 +1172,18 @@ class WorkflowContentsManager(UsesAnnotations): ] else: inputs = step.module.get_runtime_inputs(step, connections=step.output_connections) - step_model = {"inputs": [input.to_dict(trans) for input in inputs.values()]} + input_dicts = [] + for input in inputs.values(): + input_dict = input.to_dict(trans) + # Frontend paginates ``data`` / ``data_collection`` workflow + # input dropdowns directly against ``/api/histories/{id}/contents``; + # ship the precomputed accept-set (formats ∪ implicit-conversion + # sources) so the client can pass ``q=extension-in&qv=…``. + if isinstance(input, (DataToolParameter, DataCollectionToolParameter)): + accepted = input._acceptable_extensions() + input_dict["acceptable_extensions"] = sorted(accepted) if accepted else [] + input_dicts.append(input_dict) + step_model = {"inputs": input_dicts} step_model["when"] = step.when_expression step_model["replacement_parameters"] = step.module.get_informal_replacement_parameters(step) step_model["step_type"] = step.type diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index b6087e58fa5..b59031b85a9 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -4104,6 +4104,61 @@ class History(Base, HasTags, UsesAnnotations, HasName, Serializable, UsesCreateA total = session.scalar(count_stmt) or 0 return rows, total + @staticmethod + def _hdca_leaf_hda_descendants_cte(history_id): + """Recursive CTE: for every non-deleted HDCA in ``history_id``, walk + ``dataset_collection_element`` arbitrarily deep to enumerate every + leaf HDA. Columns: ``root_hdca_id``, ``hda_id``, + ``child_collection_id``. Used by depth-arbitrary HDCA filters (e.g. + extension match) — single source of truth for "walk all leaves of + the HDCAs in this history". Works on PostgreSQL and SQLite. + """ + base = ( + select( + HistoryDatasetCollectionAssociation.id.label("root_hdca_id"), + DatasetCollectionElement.hda_id.label("hda_id"), + DatasetCollectionElement.child_collection_id.label("child_collection_id"), + ) + .select_from(HistoryDatasetCollectionAssociation) + .join( + DatasetCollectionElement, + DatasetCollectionElement.dataset_collection_id == HistoryDatasetCollectionAssociation.collection_id, + ) + .where( + HistoryDatasetCollectionAssociation.history_id == history_id, + not_(HistoryDatasetCollectionAssociation.deleted), + ) + ) + cte = base.cte(name="hdca_leaf_hda_descendants", recursive=True) + dce_rec = aliased(DatasetCollectionElement) + cte = cte.union_all( + select( + cte.c.root_hdca_id, + dce_rec.hda_id, + dce_rec.child_collection_id, + ) + .select_from(cte) + .join(dce_rec, dce_rec.dataset_collection_id == cte.c.child_collection_id) + ) + return cte + + @staticmethod + def _hdca_extensions_only_in_clause(extensions: set[str], history_id): + """Build a WHERE clause: True for HDCAs whose every leaf HDA's + extension is in ``extensions`` (mirrors + ``SummaryDatasetCollectionMatcher.hdca_match``). Walks arbitrary + depth via :py:meth:`_hdca_leaf_hda_descendants_cte`. + """ + cte = History._hdca_leaf_hda_descendants_cte(history_id) + hda_check = aliased(HistoryDatasetAssociation) + bad_hdca_ids = ( + select(cte.c.root_hdca_id) + .select_from(cte) + .join(hda_check, hda_check.id == cte.c.hda_id) + .where(hda_check.extension.notin_(extensions)) + ) + return HistoryDatasetCollectionAssociation.id.notin_(bad_hdca_ids) + def paginated_active_dataset_collections( self, *, @@ -4112,12 +4167,13 @@ class History(Base, HasTags, UsesAnnotations, HasName, Serializable, UsesCreateA offset: int = 0, limit: int = 50, ) -> tuple[list["HistoryDatasetCollectionAssociation"], int]: - """Active HDCAs paginated. Extension filtering for collections is handled - in Python by the dataset-collection matcher because collections aggregate - per-element extensions. Pass ``visible_only=False`` to include hidden - collections (matches the legacy ``active_dataset_collections`` semantics - used by some tool-form paths). ``search`` matches case-insensitively - against the collection name and (when numeric) against the hid. + """Active HDCAs paginated. Pass ``visible_only=False`` to include + hidden collections (matches the legacy ``active_dataset_collections`` + semantics used by some tool-form paths). ``search`` matches + case-insensitively against the collection name and (when numeric) + against the hid. Extension filtering for collections is exposed via + the history-contents filter parser (see + :py:meth:`_hdca_extensions_only_in_clause`). """ filters = [ HistoryDatasetCollectionAssociation.history_id == self.id, diff --git a/lib/galaxy_test/api/test_history_contents.py b/lib/galaxy_test/api/test_history_contents.py index 1de71edf00d..dd0f929cc32 100644 --- a/lib/galaxy_test/api/test_history_contents.py +++ b/lib/galaxy_test/api/test_history_contents.py @@ -1041,6 +1041,130 @@ class TestHistoryContentsApi(ApiTestCase): collection = contents_response.json()[0] assert sorted(collection["elements_datatypes"]) == sorted(expected_datatypes) + def test_index_filter_by_extension(self, history_id): + self.dataset_populator.new_dataset(history_id, file_type="bed", wait=True) + self.dataset_populator.new_dataset(history_id, file_type="bed", wait=True) + self.dataset_populator.new_dataset(history_id, file_type="tabular", wait=True) + self.dataset_populator.new_dataset(history_id, file_type="txt", wait=True) + + # extension-eq (single value): only matching HDAs + response = self._get(f"histories/{history_id}/contents?v=dev&q=extension-eq&qv=bed").json() + bed_ids = {item["id"] for item in response} + assert len(bed_ids) == 2 + + # extension-in (multiple values, comma-separated) + response = self._get(f"histories/{history_id}/contents?v=dev&q=extension-in&qv=bed,tabular").json() + ids = {item["id"] for item in response} + assert len(ids) == 3 + + def test_index_filter_collections_by_extension(self, history_id): + all_bed = [ + {"name": "a", "src": "pasted", "paste_content": "chr1\t1\t10", "ext": "bed"}, + {"name": "b", "src": "pasted", "paste_content": "chr1\t1\t10", "ext": "bed"}, + ] + self._upload_collection_list_with_elements(history_id, "all_bed", all_bed) + mixed = [ + {"name": "a", "src": "pasted", "paste_content": "chr1\t1\t10", "ext": "bed"}, + {"name": "b", "src": "pasted", "paste_content": "abc", "ext": "txt"}, + ] + self._upload_collection_list_with_elements(history_id, "mixed", mixed) + all_txt = [ + {"name": "a", "src": "pasted", "paste_content": "abc", "ext": "txt"}, + {"name": "b", "src": "pasted", "paste_content": "abc", "ext": "txt"}, + ] + self._upload_collection_list_with_elements(history_id, "all_txt", all_txt) + + # extension-eq matches only the all-bed collection (mixed has a non-bed leaf) + response = self._get( + f"histories/{history_id}/contents?v=dev" + "&q=history_content_type-eq&qv=dataset_collection" + "&q=extension-eq&qv=bed" + ).json() + names = {c["name"] for c in response} + assert names == {"all_bed"}, names + + # extension-in with bed + txt accepts both the bed and txt collections AND the mixed + # collection (all of its leaves are within the acceptable set). + response = self._get( + f"histories/{history_id}/contents?v=dev" + "&q=history_content_type-eq&qv=dataset_collection" + "&q=extension-in&qv=bed,txt" + ).json() + names = {c["name"] for c in response} + assert names == {"all_bed", "mixed", "all_txt"}, names + + def test_index_filter_by_name_unique_sentinel(self, history_id): + """Real-DB integration coverage for ``History.paginated_active_visible_datasets`` + name-search path — a unique sentinel name must return exactly one HDA + (no substring leakage). + """ + for i in range(20): + self.dataset_populator.new_dataset(history_id, name=f"Sample {i}", wait=True) + sentinel = "unique-zebra-xyz" + self.dataset_populator.new_dataset(history_id, name=sentinel, wait=True) + + response = self._get(f"histories/{history_id}/contents?v=dev&q=name-contains&qv=zebra").json() + assert len(response) == 1, response + assert response[0]["name"] == sentinel + + def test_index_filter_by_hid_exact(self, history_id): + """``q=hid-eq`` returns the HDA with the requested hid — covers the + numeric branch of ``paginated_active_visible_datasets.search``. + """ + for i in range(5): + self.dataset_populator.new_dataset(history_id, name=f"D{i}", wait=True) + + response = self._get(f"histories/{history_id}/contents?v=dev&q=hid-eq&qv=3").json() + assert len(response) == 1, response + assert response[0]["hid"] == 3 + + def test_index_filter_collections_by_extension_nested(self, history_id): + """Depth-arbitrary regression test for the ``extension`` HDCA filter + (recursive CTE in ``History._hdca_leaf_hda_descendants_cte``). A + ``list:paired`` collection nests one level deeper than a flat list — + verify the recursive walk still catches non-matching leaves. + """ + all_bed_response = self.dataset_collection_populator.upload_collection( + history_id, + "list:paired", + elements=[ + { + "name": "p0", + "elements": [ + {"src": "pasted", "paste_content": "chr1\t1\t10", "name": "forward", "ext": "bed"}, + {"src": "pasted", "paste_content": "chr1\t1\t10", "name": "reverse", "ext": "bed"}, + ], + }, + ], + name="lp_all_bed", + wait=True, + ) + self._assert_status_code_is_ok(all_bed_response) + mixed_response = self.dataset_collection_populator.upload_collection( + history_id, + "list:paired", + elements=[ + { + "name": "p0", + "elements": [ + {"src": "pasted", "paste_content": "chr1\t1\t10", "name": "forward", "ext": "bed"}, + {"src": "pasted", "paste_content": "abc", "name": "reverse", "ext": "txt"}, + ], + }, + ], + name="lp_mixed", + wait=True, + ) + self._assert_status_code_is_ok(mixed_response) + + response = self._get( + f"histories/{history_id}/contents?v=dev" + "&q=history_content_type-eq&qv=dataset_collection" + "&q=extension-eq&qv=bed" + ).json() + names = {c["name"] for c in response} + assert names == {"lp_all_bed"}, names + @skip_without_tool("cat1") def test_cannot_run_tools_on_immutable_histories(self, history_id): create_response = self.dataset_collection_populator.create_pair_in_history( diff --git a/lib/galaxy_test/api/test_tools.py b/lib/galaxy_test/api/test_tools.py index 4f855cab7c2..fec07155db6 100644 --- a/lib/galaxy_test/api/test_tools.py +++ b/lib/galaxy_test/api/test_tools.py @@ -450,24 +450,54 @@ class TestToolsApi(ApiTestCase, TestsTools): """``options_pagination[...].search`` filters HDAs by name (ilike) before pagination, so users can find datasets outside the default page window by typing into the dropdown.""" + sentinel = "unique-zebra-xyz" with self.dataset_populator.test_history() as history_id: - # Distinct names so we can assert exact match counts. - hdas = self.dataset_populator.fetch_hdas( + # 60 distinct "Sample N" HDAs plus one sentinel name that shares + # no substring with the others. Searching for the sentinel proves + # ilike is actually filtering (rather than passing everything + # through). + self.dataset_populator.fetch_hdas( history_id, [{"src": "pasted", "paste_content": "x", "name": f"Sample {i}"} for i in range(60)], ) - target_name = hdas[7]["name"] # e.g., "Sample 7" - payload = { - "history_id": history_id, - "options_pagination": {"input1": {"hda": {"search": target_name}}}, - } - response = self.dataset_populator._post("tools/cat1/build", data=payload, json=True) + self.dataset_populator.fetch_hdas( + history_id, + [{"src": "pasted", "paste_content": "x", "name": sentinel}], + ) + + # Sentinel: exactly one match, with the expected name. + response = self.dataset_populator._post( + "tools/cat1/build", + data={ + "history_id": history_id, + "options_pagination": {"input1": {"hda": {"search": "zebra"}}}, + }, + json=True, + ) response.raise_for_status() build = response.json() input_param = next(i for i in build["inputs"] if i["name"] == "input1") - returned_names = [entry["name"] for entry in input_param["options"]["hda"]] - assert all(target_name in n for n in returned_names), returned_names - assert input_param["options_meta"]["hda"]["total_estimate"] == len(returned_names) + returned = input_param["options"]["hda"] + assert len(returned) == 1, returned + assert returned[0]["name"] == sentinel + assert input_param["options_meta"]["hda"]["total_estimate"] == 1 + + # Broad term ("Sample") matches all 60 Sample HDAs — exceeds the + # default 50-page so we get a partial page with has_more=True. + response = self.dataset_populator._post( + "tools/cat1/build", + data={ + "history_id": history_id, + "options_pagination": {"input1": {"hda": {"search": "Sample"}}}, + }, + json=True, + ) + response.raise_for_status() + build = response.json() + input_param = next(i for i in build["inputs"] if i["name"] == "input1") + assert len(input_param["options"]["hda"]) == 50 + assert input_param["options_meta"]["hda"]["has_more"] is True + assert all("Sample" in entry["name"] for entry in input_param["options"]["hda"]) @skip_without_tool("cat1") def test_build_data_options_search_by_hid(self): diff --git a/lib/galaxy_test/selenium/test_workflow_run.py b/lib/galaxy_test/selenium/test_workflow_run.py index c65d6e3ce84..6db5371bf85 100644 --- a/lib/galaxy_test/selenium/test_workflow_run.py +++ b/lib/galaxy_test/selenium/test_workflow_run.py @@ -129,6 +129,13 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows and asserts the dropdown narrows to the backend-matched options.""" history_id = self.current_history_id() self.dataset_populator.fetch_hdas(history_id, [{"src": "pasted", "paste_content": "x"}] * 60) + # Sentinel-named HDA so we can prove options are *actually rendering* + # (a vacuous pass — empty dropdown — would clear the ``<= 50`` upper bound). + legacy_sentinel = "unique-pagination-sentinel" + self.dataset_populator.fetch_hdas( + history_id, + [{"src": "pasted", "paste_content": "y", "name": legacy_sentinel}], + ) self.home() # A single cat1 step with no workflow-level inputs — the step's # ``input1`` is unconnected, so it renders as a dropdown the user @@ -144,6 +151,11 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows self.sleep_for(self.wait_types.UX_RENDER) baseline_options = select_field.find_elements(By.CSS_SELECTOR, "[role='option']") assert len(baseline_options) <= 50, f"Expected default page to cap at 50 options, got {len(baseline_options)}" + # Positive lower-bound: the sentinel HDA is newest (hid=61) so it must + # appear in the first page of (newest-first) options. Without this the + # ``<= 50`` upper bound passes vacuously on an empty dropdown. + baseline_labels = [opt.text for opt in baseline_options] + assert any(legacy_sentinel in label for label in baseline_labels), baseline_labels search_input = select_field.find_element(By.CSS_SELECTOR, "input.multiselect__input") search_input.send_keys("1") # Wait past the FormSelect search debounce (300 ms) plus the network @@ -171,6 +183,12 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows component doesn't yet wire interactive load-more.""" history_id = self.current_history_id() self.dataset_populator.fetch_hdas(history_id, [{"src": "pasted", "paste_content": "x"}] * 60) + # Sentinel for positive lower-bound (see legacy-form test). + simplified_sentinel = "unique-simplified-sentinel" + self.dataset_populator.fetch_hdas( + history_id, + [{"src": "pasted", "paste_content": "y", "name": simplified_sentinel}], + ) self.home() self.workflow_run_open_workflow(WORKFLOW_SIMPLE_CAT_TWICE) # Ensure the legacy/expanded form is fully rendered before reaching for @@ -196,6 +214,11 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows assert ( len(options) <= 50 ), f"Simplified form dropdown must respect the 50-per-page cap; got {len(options)} options" + # Positive lower-bound: the sentinel HDA is newest (hid=61) so it must + # appear in the first page of options. Without this the ``<= 50`` upper + # bound passes vacuously on an empty dropdown. + labels = [opt.text for opt in options] + assert any(simplified_sentinel in label for label in labels), labels @selenium_only("Not yet migrated to support Playwright backend") @selenium_test