From 7dd7e49168cafd7c9f9644994de6f62fb7f74125 Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Thu, 26 Sep 2024 18:13:53 -0500 Subject: [PATCH] fully implement `list` collection creator in `FormData` This allows a collection type `list` to be created via the collection creater from the workflow/tool form directly. It tracks the current history changes via the new `useHistoryItemsForType` composable. It utilises the `FormSelectMany` component to easily move items between selected and unselected for list columns. The items in the list creator can be filtered for extension, parent datatype or all items in the history, based on whether the form field required a certain extension(s) as input for the list. --- client/src/api/histories.ts | 3 + .../Collections/ListCollectionCreator.vue | 338 ++++++++++++++---- .../Collections/ListCollectionCreatorModal.js | 16 +- .../ListDatasetCollectionElementView.vue | 43 ++- .../Collections/PairCollectionCreator.vue | 1 + .../PairedListCollectionCreator.vue | 1 + .../Collections/common/ClickToEdit.vue | 52 +-- .../Collections/common/CollectionCreator.vue | 163 +++++---- .../src/components/Common/ButtonSpinner.vue | 14 +- client/src/components/Datatypes/model.ts | 10 + .../Form/Elements/FormData/FormData.vue | 93 +++-- .../components/Form/Elements/FormSelect.vue | 3 +- .../FormSelectMany/FormSelectMany.vue | 8 +- client/src/components/Help/terms.yml | 11 + .../HistoryOperations/SelectionOperations.vue | 4 +- .../History/adapters/HistoryPanelProxy.js | 2 +- ...ectionModal.js => buildCollectionModal.ts} | 43 ++- .../src/composables/useHistoryItemsForType.ts | 90 +++++ 18 files changed, 665 insertions(+), 230 deletions(-) create mode 100644 client/src/api/histories.ts rename client/src/components/History/adapters/{buildCollectionModal.js => buildCollectionModal.ts} (55%) create mode 100644 client/src/composables/useHistoryItemsForType.ts diff --git a/client/src/api/histories.ts b/client/src/api/histories.ts new file mode 100644 index 000000000000..ed10bb8efb1f --- /dev/null +++ b/client/src/api/histories.ts @@ -0,0 +1,3 @@ +import { type components } from "@/api"; + +export type HistoryContentsResult = components["schemas"]["HistoryContentsResult"]; diff --git a/client/src/components/Collections/ListCollectionCreator.vue b/client/src/components/Collections/ListCollectionCreator.vue index 5798187943f2..e5590be15fd1 100644 --- a/client/src/components/Collections/ListCollectionCreator.vue +++ b/client/src/components/Collections/ListCollectionCreator.vue @@ -1,21 +1,29 @@ diff --git a/client/src/components/Collections/PairCollectionCreator.vue b/client/src/components/Collections/PairCollectionCreator.vue index 2520a3a022f8..e0641fe50f34 100644 --- a/client/src/components/Collections/PairCollectionCreator.vue +++ b/client/src/components/Collections/PairCollectionCreator.vue @@ -298,6 +298,7 @@ onMounted(() => { :oncancel="oncancel" :hide-source-items="hideSourceItems" :suggested-name="initialSuggestedName" + :extensions-toggle="removeExtensions" @onUpdateHideSourceItems="onUpdateHideSourceItems" @clicked-create="clickedCreate" @remove-extensions-toggle="removeExtensionsToggle"> diff --git a/client/src/components/Collections/PairedListCollectionCreator.vue b/client/src/components/Collections/PairedListCollectionCreator.vue index c699c0de7077..d18767c32572 100644 --- a/client/src/components/Collections/PairedListCollectionCreator.vue +++ b/client/src/components/Collections/PairedListCollectionCreator.vue @@ -102,6 +102,7 @@ :oncancel="oncancel" :hide-source-items="hideSourceItems" :render-extensions-toggle="true" + :extensions-toggle="removeExtensions" @onUpdateHideSourceItems="onUpdateHideSourceItems" @clicked-create="clickedCreate" @remove-extensions-toggle="removeExtensionsToggle"> diff --git a/client/src/components/Collections/common/ClickToEdit.vue b/client/src/components/Collections/common/ClickToEdit.vue index da60f01880d9..84c413d7f1f1 100644 --- a/client/src/components/Collections/common/ClickToEdit.vue +++ b/client/src/components/Collections/common/ClickToEdit.vue @@ -1,4 +1,7 @@ diff --git a/client/src/components/Collections/common/CollectionCreator.vue b/client/src/components/Collections/common/CollectionCreator.vue index 8ef14660d999..e257df302654 100644 --- a/client/src/components/Collections/common/CollectionCreator.vue +++ b/client/src/components/Collections/common/CollectionCreator.vue @@ -1,28 +1,38 @@ diff --git a/client/src/components/Datatypes/model.ts b/client/src/components/Datatypes/model.ts index 77826b73fc13..591f8f2814b0 100644 --- a/client/src/components/Datatypes/model.ts +++ b/client/src/components/Datatypes/model.ts @@ -39,4 +39,14 @@ export class DatatypesMapperModel { isSubTypeOfAny(child: string, parents: DatatypesCombinedMap["datatypes"]): boolean { return parents.some((parent) => this.isSubType(child, parent)); } + + /** For classes like `galaxy.datatypes.{parent}.{extension}`, get the extension's parent */ + getParentDatatype(extension: string) { + const fullClassName = this.datatypesMapping.ext_to_class_name[extension]; + return fullClassName?.split(".")[2]; + } + + isSubClassOfAny(child: string, parents: DatatypesCombinedMap["datatypes"]): boolean { + return parents.every((parent) => this.getParentDatatype(parent) === this.getParentDatatype(child)); + } } diff --git a/client/src/components/Form/Elements/FormData/FormData.vue b/client/src/components/Form/Elements/FormData/FormData.vue index 9cdb34812259..5002faa192b7 100644 --- a/client/src/components/Form/Elements/FormData/FormData.vue +++ b/client/src/components/Form/Elements/FormData/FormData.vue @@ -1,7 +1,7 @@ diff --git a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue index 0c762ae90bd4..7adcea8a5f86 100644 --- a/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue +++ b/client/src/components/Form/Elements/FormSelectMany/FormSelectMany.vue @@ -341,7 +341,9 @@ const selectedCount = computed(() => { :class="{ highlighted: highlightUnselected.highlightedIndexes.includes(i) }" @click="(e) => selectOption(e, i)" @keydown="(e) => optionOnKey('unselected', e, i)"> - {{ option.label }} + + {{ option.label }} + @@ -373,7 +375,9 @@ const selectedCount = computed(() => { :class="{ highlighted: highlightSelected.highlightedIndexes.includes(i) }" @click="(e) => deselectOption(e, i)" @keydown="(e) => optionOnKey('selected', e, i)"> - {{ option.label }} + + {{ option.label }} + diff --git a/client/src/components/Help/terms.yml b/client/src/components/Help/terms.yml index 8d1e9ca05d92..af463322dc6a 100644 --- a/client/src/components/Help/terms.yml +++ b/client/src/components/Help/terms.yml @@ -54,6 +54,17 @@ galaxy: These lists will be gathered together in a nested list structured (collection type ``list:list``) where the outer element count and structure matches that of the input and the inner list for each of those is just the outputs of the tool for the corresponding element of the input. + collectionBuilder: + hideOriginalElements: | + Toggling this on means that the original history items that will become a part of the collection + will be hidden from the history panel (they will still be searchable via the 'visible: false' filter). + filterForDatatypes: | + This option allows you to filter items shown here by datatype because this input requires specific + extension(s). By default, the toggle is at "Extension" and the list is filtered for the explicit extension(s) + required by the input; *if datasets with that extension(s) are available*. If you toggle to "Datatype", + the list will be filtered for the "parent" datatype of the required extension (for implicit conversion). + If you toggle to "All", the list will show all items regardless of datatype. + jobs: states: # upload, waiting, failed, paused, deleting, deleted, stop, stopped, skipped. diff --git a/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue b/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue index fca63e70dd68..71ccfa80e4c3 100644 --- a/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue +++ b/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue @@ -392,7 +392,9 @@ export default { if (contents === undefined) { contents = this.contentSelection; } - const modalResult = await buildCollectionModal(collectionType, contents, this.history.id); + const modalResult = await buildCollectionModal(collectionType, contents, this.history.id, { + fromSelection: true, + }); await createDatasetCollection(this.history, modalResult); // have to hide the source items if that was requested diff --git a/client/src/components/History/adapters/HistoryPanelProxy.js b/client/src/components/History/adapters/HistoryPanelProxy.js index 28b95b38627f..e79466d45052 100644 --- a/client/src/components/History/adapters/HistoryPanelProxy.js +++ b/client/src/components/History/adapters/HistoryPanelProxy.js @@ -56,7 +56,7 @@ export class HistoryPanelProxy { selectionContent.set(obj.id, obj); }); } - const modalResult = await buildCollectionModal(collectionType, selectionContent, historyId, fromRulesInput); + const modalResult = await buildCollectionModal(collectionType, selectionContent, historyId, { fromRulesInput }); if (modalResult) { console.debug("Submitting collection build request.", modalResult); await createDatasetCollection({ id: historyId }, modalResult); diff --git a/client/src/components/History/adapters/buildCollectionModal.js b/client/src/components/History/adapters/buildCollectionModal.ts similarity index 55% rename from client/src/components/History/adapters/buildCollectionModal.js rename to client/src/components/History/adapters/buildCollectionModal.ts index f5b5c030ea27..06be87275012 100644 --- a/client/src/components/History/adapters/buildCollectionModal.js +++ b/client/src/components/History/adapters/buildCollectionModal.ts @@ -8,15 +8,33 @@ * deprecated jquery Deferred object. */ -import LIST_COLLECTION_CREATOR from "components/Collections/ListCollectionCreatorModal"; -import PAIR_COLLECTION_CREATOR from "components/Collections/PairCollectionCreatorModal"; -import LIST_OF_PAIRS_COLLECTION_CREATOR from "components/Collections/PairedListCollectionCreatorModal"; -import RULE_BASED_COLLECTION_CREATOR from "components/Collections/RuleBasedCollectionCreatorModal"; import jQuery from "jquery"; +import type { HistoryItemSummary } from "@/api"; +import LIST_COLLECTION_CREATOR from "@/components/Collections/ListCollectionCreatorModal"; +import PAIR_COLLECTION_CREATOR from "@/components/Collections/PairCollectionCreatorModal"; +import LIST_OF_PAIRS_COLLECTION_CREATOR from "@/components/Collections/PairedListCollectionCreatorModal"; +import RULE_BASED_COLLECTION_CREATOR from "@/components/Collections/RuleBasedCollectionCreatorModal"; + +export type CollectionType = "list" | "paired" | "list:paired" | "rules"; +export interface BuildCollectionOptions { + fromRulesInput?: boolean; + fromSelection?: boolean; + extensions?: string[]; + title?: string; + defaultHideSourceItems?: boolean; + historyId?: string; +} + // stand-in for buildCollection from history-view-edit.js -export async function buildCollectionModal(collectionType, selectedContent, historyId, fromRulesInput = false) { +export async function buildCollectionModal( + collectionType: CollectionType, + selectedContent: HistoryItemSummary[], + historyId: string, + options: BuildCollectionOptions = {} +) { // select legacy function + const { fromRulesInput = false } = options; let createFunc; if (collectionType == "list") { createFunc = LIST_COLLECTION_CREATOR.createListCollection; @@ -33,19 +51,25 @@ export async function buildCollectionModal(collectionType, selectedContent, hist if (fromRulesInput) { return await createFunc(selectedContent); } else { - const fakeBackboneContent = createBackboneContent(historyId, selectedContent); + const fakeBackboneContent = createBackboneContent(historyId, selectedContent, options); return await createFunc(fakeBackboneContent); } } -const createBackboneContent = (historyId, selection) => { +const createBackboneContent = (historyId: string, selection: HistoryItemSummary[], options: BuildCollectionOptions) => { const selectionJson = Array.from(selection.values()); return { historyId, toJSON: () => selectionJson, // result must be a $.Deferred object instead of a promise because // that's the kind of deprecated data format that backbone likes to use. - createHDCA(element_identifiers, collection_type, name, hide_source_items, options = {}) { + createHDCA( + element_identifiers: any, + collection_type: CollectionType, + name: string, + hide_source_items: boolean, + options = {} + ) { const def = jQuery.Deferred(); return def.resolve(null, { collection_type, @@ -55,5 +79,8 @@ const createBackboneContent = (historyId, selection) => { options, }); }, + fromSelection: options.fromSelection, + extensions: options.extensions, + defaultHideSourceItems: options.defaultHideSourceItems === undefined ? true : options.defaultHideSourceItems, }; }; diff --git a/client/src/composables/useHistoryItemsForType.ts b/client/src/composables/useHistoryItemsForType.ts new file mode 100644 index 000000000000..765e411637da --- /dev/null +++ b/client/src/composables/useHistoryItemsForType.ts @@ -0,0 +1,90 @@ +import { computed, type Ref, ref, watch } from "vue"; + +import { GalaxyApi, type HistoryItemSummary } from "@/api"; +import { filtersToQueryValues } from "@/components/History/model/queries"; +import { useHistoryStore } from "@/stores/historyStore"; +import { errorMessageAsString } from "@/utils/simple-error"; + +const DEFAULT_FILTERS = { visible: true, deleted: false }; + +let singletonInstance: { + isFetchingItems: Ref; + errorMessage: Ref; + historyItems: Ref; +} | null = null; + +/** + * Creates a composable that fetches the given type of items from a history reactively. + * @param historyId The history ID to fetch items for. (TODO: make this a required parameter; only `string` allowed) + * @param type The type of items to fetch. Default is "dataset". + * @param filters Filters to apply to the items. + * @returns An object containing reactive properties for the fetch status and the fetched items. + */ +export function useHistoryItemsForType( + historyId: Ref, + type: "dataset" | "dataset_collection" = "dataset", + filters = DEFAULT_FILTERS +) { + if (singletonInstance) { + return singletonInstance; + } + const isFetchingItems = ref(false); + const errorMessage = ref(null); + const historyItems = ref([]); + const counter = ref(0); + + const historyStore = useHistoryStore(); + + const historyUpdateTime = computed( + () => historyId.value && historyStore.getHistoryById(historyId.value)?.update_time + ); + + // Fetch items when history ID or update time changes + watch( + () => ({ + time: historyUpdateTime.value, + id: historyId.value, + }), + async (newValues, oldValues) => { + if (newValues.time !== oldValues?.time || newValues.id !== oldValues?.id) { + await fetchItems(); + counter.value++; + } + }, + { immediate: true } + ); + + async function fetchItems() { + if (!historyId.value) { + errorMessage.value = "No history ID provided"; + return; + } + if (isFetchingItems.value) { + return; + } + const filterQuery = filtersToQueryValues(filters); + isFetchingItems.value = true; + const { data, error } = await GalaxyApi().GET("/api/histories/{history_id}/contents/{type}s", { + params: { + path: { history_id: historyId.value, type: type }, + query: { ...filterQuery, v: "dev" }, + }, + }); + isFetchingItems.value = false; + if (error) { + errorMessage.value = errorMessageAsString(error); + console.error("Error fetching history items", errorMessage.value); + } else { + historyItems.value = data as HistoryItemSummary[]; + errorMessage.value = null; + } + } + + singletonInstance = { + isFetchingItems, + errorMessage, + historyItems, + }; + + return singletonInstance; +}