diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 3a9e6becb56..6da685ed12c 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -200,6 +200,10 @@ export interface SubCollection extends DCObject { */ export type CollectionEntry = HDCASummary | SubCollection; +export function isHDA(entry?: HistoryItemSummary): entry is HDASummary { + return entry !== undefined && "history_content_type" in entry && entry.history_content_type === "dataset"; +} + /** * Returns true if the given entry is a top level HDCA and false for sub-collections. */ diff --git a/client/src/api/workflows.ts b/client/src/api/workflows.ts index 2cc8371d74a..a118b8b5990 100644 --- a/client/src/api/workflows.ts +++ b/client/src/api/workflows.ts @@ -1,5 +1,6 @@ import { type components } from "@/api/schema"; +export type Creator = components["schemas"]["Person"] | components["schemas"]["galaxy__schema__schema__Organization"]; export type StoredWorkflowDetailed = components["schemas"]["StoredWorkflowDetailed"]; //TODO: replace with generated schema model when available diff --git a/client/src/components/Collections/CollectionCreatorModal.vue b/client/src/components/Collections/CollectionCreatorIndex.vue similarity index 79% rename from client/src/components/Collections/CollectionCreatorModal.vue rename to client/src/components/Collections/CollectionCreatorIndex.vue index 59b3099c533..d46a3ac7864 100644 --- a/client/src/components/Collections/CollectionCreatorModal.vue +++ b/client/src/components/Collections/CollectionCreatorIndex.vue @@ -7,9 +7,11 @@ import { computed, ref, watch } from "vue"; import type { HDASummary, HistoryItemSummary, HistorySummary } from "@/api"; import { createDatasetCollection } from "@/components/History/model/queries"; import { useCollectionBuilderItemsStore } from "@/stores/collectionBuilderItemsStore"; +import { useHistoryItemsStore } from "@/stores/historyItemsStore"; import { useHistoryStore } from "@/stores/historyStore"; import localize from "@/utils/localization"; import { orList } from "@/utils/strings"; +import { stateIsTerminal } from "@/utils/utils"; import type { CollectionType, DatasetPair } from "../History/adapters/buildCollectionModal"; @@ -22,27 +24,30 @@ import LoadingSpan from "@/components/LoadingSpan.vue"; interface Props { historyId: string; - showModal: boolean; + show: boolean; collectionType: CollectionType; selectedItems?: HistoryItemSummary[]; defaultHideSourceItems?: boolean; extensions?: string[]; fromRulesInput?: boolean; - hideModalOnCreate?: boolean; + hideOnCreate?: boolean; filterText?: string; + notModal?: boolean; + suggestedName?: string; } const props = defineProps(); const emit = defineEmits<{ (e: "created-collection", collection: any): void; - (e: "update:show-modal", showModal: boolean): void; + (e: "update:show", show: boolean): void; + (e: "on-hide"): void; }>(); -/** Computed toggle that handles opening and closing the modal */ +/** Computed toggle that handles showing and hiding the creator */ const localShowToggle = computed({ - get: () => props.showModal, + get: () => props.show, set: (value: boolean) => { - emit("update:show-modal", value); + emit("update:show", value); }, }); @@ -140,6 +145,17 @@ const modalTitle = computed(() => { } }); +const historyItemsStore = useHistoryItemsStore(); +/** The created collection accessed from the history items store */ +const createdCollectionInHistory = computed(() => { + const historyItems = historyItemsStore.getHistoryItems(props.historyId, ""); + return historyItems.find((item) => item.id === createdCollection.value?.id); +}); +/** If the created collection has achieved a terminal state */ +const createdCollectionInReadyState = computed( + () => createdCollectionInHistory.value && stateIsTerminal(createdCollectionInHistory.value) +); + // Methods function createListCollection(elements: HDASummary[], name: string, hideSourceItems: boolean) { const returnedElems = elements.map((element) => ({ @@ -198,11 +214,10 @@ async function createHDCA( options, }); - emit("created-collection", collection); createdCollection.value = collection; - if (props.hideModalOnCreate) { - hideModal(); + if (props.hideOnCreate) { + hideCreator(); } } catch (error) { createCollectionError.value = error as string; @@ -211,6 +226,15 @@ async function createHDCA( } } +watch( + () => createdCollectionInReadyState.value, + (stateReady) => { + if (stateReady) { + emit("created-collection", createdCollectionInHistory.value); + } + } +); + async function fetchHistoryDatasets() { const { error } = await collectionItemsStore.fetchDatasetsForFiltertext( historyId.value, @@ -225,18 +249,20 @@ async function fetchHistoryDatasets() { } } -function hideModal() { +function hideCreator() { localShowToggle.value = false; + emit("on-hide"); } -function resetModal() { +function resetCreator() { createCollectionError.value = null; createdCollection.value = null; } diff --git a/client/src/components/Form/FormElementHeader.vue b/client/src/components/Form/FormElementHeader.vue new file mode 100644 index 00000000000..e5cc38be12a --- /dev/null +++ b/client/src/components/Form/FormElementHeader.vue @@ -0,0 +1,77 @@ + + + diff --git a/client/src/components/Form/FormInputs.vue b/client/src/components/Form/FormInputs.vue index fafe1734b47..da2fbbdf4b3 100644 --- a/client/src/components/Form/FormInputs.vue +++ b/client/src/components/Form/FormInputs.vue @@ -1,6 +1,9 @@ @@ -131,6 +149,18 @@ export default { type: Boolean, default: false, }, + workflowRun: { + type: Boolean, + default: false, + }, + activeNodeId: { + type: Number, + default: null, + }, + syncWithGraph: { + type: Boolean, + default: false, + }, }, methods: { getPrefix(name, index) { @@ -170,3 +200,10 @@ export default { }, }; + + diff --git a/client/src/components/Form/_form-elements.scss b/client/src/components/Form/_form-elements.scss index 6a244b4a998..976830f15a8 100644 --- a/client/src/components/Form/_form-elements.scss +++ b/client/src/components/Form/_form-elements.scss @@ -1,4 +1,5 @@ @import "theme/blue.scss"; +@import "base.scss"; @import "~@fortawesome/fontawesome-free/scss/_variables"; .ui-form-element { @@ -7,6 +8,53 @@ overflow: visible; clear: both; + // Workflow Run Form + &.workflow-run-element { + // when a temporary focus is applied to the element + &.temp-focus { + border: solid 3px $brand-primary; + } + &:not(.temp-focus) { + border: solid 1px $portlet-bg-color; + box-shadow: 0 0 5px $portlet-bg-color; + } + + .ui-form-title { + display: flex; + align-items: center; + justify-content: space-between; + + // inherit the border radius from the parent .alert class + border-top-left-radius: inherit; + border-top-right-radius: inherit; + + &:deep(.form-element-header-badge) { + display: flex; + align-items: center; + font-weight: normal; + font-size: 100%; + padding-left: $spacer; + padding-right: $spacer; + + &.populated { + background-color: map-get($galaxy-state-bg, "ok"); + } + &.unpopulated { + background-color: $state-info-bg; + } + } + } + .form-element-content { + display: flex; + flex-direction: column; + row-gap: 0.25rem; + + .ui-form-info { + order: -1; + } + } + } + .ui-form-title { word-wrap: break-word; font-weight: bold; diff --git a/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue b/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue index d36a3fb0d59..a9dabda20c0 100644 --- a/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue +++ b/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue @@ -138,14 +138,14 @@

Remove the following tags from {{ numSelected }} items:

- @@ -173,11 +173,11 @@ import { useConfig } from "@/composables/config"; import { buildRuleCollectionModal } from "../../adapters/buildCollectionModal"; -import CollectionCreatorModal from "@/components/Collections/CollectionCreatorModal.vue"; +import CollectionCreatorIndex from "@/components/Collections/CollectionCreatorIndex.vue"; export default { components: { - CollectionCreatorModal, + CollectionCreatorIndex, DbKeyProvider, DatatypesProvider, SingleItemSelector, diff --git a/client/src/components/History/adapters/buildCollectionModal.ts b/client/src/components/History/adapters/buildCollectionModal.ts index 53a41b42491..02820bc7abf 100644 --- a/client/src/components/History/adapters/buildCollectionModal.ts +++ b/client/src/components/History/adapters/buildCollectionModal.ts @@ -21,6 +21,12 @@ export type DatasetPair = { name: string; }; +export const COLLECTION_TYPE_TO_LABEL: Record = { + list: "list", + "list:paired": "list of pairs", + paired: "dataset pair", +}; + // stand-in for buildCollection from history-view-edit.js export async function buildRuleCollectionModal( selectedContent: HistoryItemSummary[], diff --git a/client/src/components/Libraries/LibraryFolder/TopToolbar/FolderTopBar.vue b/client/src/components/Libraries/LibraryFolder/TopToolbar/FolderTopBar.vue index 2e619d5d0a1..9df1755a7c4 100644 --- a/client/src/components/Libraries/LibraryFolder/TopToolbar/FolderTopBar.vue +++ b/client/src/components/Libraries/LibraryFolder/TopToolbar/FolderTopBar.vue @@ -26,7 +26,7 @@ import { Toast } from "@/composables/toast"; import { getAppRoot } from "@/onload"; import { useUserStore } from "@/stores/userStore"; -import CollectionCreatorModal from "@/components/Collections/CollectionCreatorModal.vue"; +import CollectionCreatorIndex from "@/components/Collections/CollectionCreatorIndex.vue"; import FolderDetails from "@/components/Libraries/LibraryFolder/FolderDetails/FolderDetails.vue"; import LibraryBreadcrumb from "@/components/Libraries/LibraryFolder/LibraryBreadcrumb.vue"; import SearchField from "@/components/Libraries/LibraryFolder/SearchField.vue"; @@ -440,12 +440,12 @@ function onAddDatasetsDirectory(selectedDatasets: Record - diff --git a/client/src/components/Upload/CompositeBox.vue b/client/src/components/Upload/CompositeBox.vue index 33dcbebccd8..eb26f79c548 100644 --- a/client/src/components/Upload/CompositeBox.vue +++ b/client/src/components/Upload/CompositeBox.vue @@ -5,7 +5,7 @@ import Vue, { computed, ref } from "vue"; import { uploadPayload } from "@/utils/upload-payload.js"; import { uploadSubmit } from "@/utils/upload-submit.js"; -import { defaultModel } from "./model.js"; +import { defaultModel } from "./model"; import CompositeRow from "./CompositeRow.vue"; import UploadSelect from "./UploadSelect.vue"; diff --git a/client/src/components/Upload/DefaultBox.test.js b/client/src/components/Upload/DefaultBox.test.ts similarity index 73% rename from client/src/components/Upload/DefaultBox.test.js rename to client/src/components/Upload/DefaultBox.test.ts index 6aa9bb63de6..8530a59a8be 100644 --- a/client/src/components/Upload/DefaultBox.test.js +++ b/client/src/components/Upload/DefaultBox.test.ts @@ -1,13 +1,19 @@ +import { createTestingPinia } from "@pinia/testing"; import { mount } from "@vue/test-utils"; import flushPromises from "flush-promises"; import { getLocalVue } from "tests/jest/helpers"; -import mountTarget from "./DefaultBox.vue"; +import DefaultBox from "./DefaultBox.vue"; const localVue = getLocalVue(); +type IntersectionObserverType = { + new (callback: IntersectionObserverCallback, options?: IntersectionObserverInit): IntersectionObserver; + prototype: IntersectionObserver; +}; + function getWrapper() { - return mount(mountTarget, { + return mount(DefaultBox as object, { propsData: { chunkUploadSize: 100, defaultDbKey: "?", @@ -23,11 +29,12 @@ function getWrapper() { stubs: { FontAwesomeIcon: true, }, + pinia: createTestingPinia(), }); } describe("Default", () => { - let UnpatchedIntersectionObserver; + let UnpatchedIntersectionObserver: IntersectionObserverType; beforeEach(() => { UnpatchedIntersectionObserver = global.IntersectionObserver; @@ -36,15 +43,14 @@ describe("Default", () => { // [Vue warn]: Error in directive b-visible unbind hook: "TypeError: this.observer.disconnect is not a function" // I don't think there is a problem with the usage so I think this a bug in bootstrap vue, it can be worked around // with the following code - but just suppressing the warning is probably better? - const observerMock = jest.fn(function IntersectionObserver(callback) { + const observerMock = jest.fn(function IntersectionObserver(callback: IntersectionObserverCallback) { this.observe = jest.fn(); this.disconnect = jest.fn(); - // Optionally add a trigger() method to manually trigger a change - this.trigger = (mockedMutationsList) => { + this.trigger = (mockedMutationsList: IntersectionObserverEntry[]) => { callback(mockedMutationsList, this); }; }); - global.IntersectionObserver = observerMock; + global.IntersectionObserver = observerMock as unknown as IntersectionObserverType; }); afterEach(() => { @@ -53,9 +59,9 @@ describe("Default", () => { it("rendering", async () => { const wrapper = getWrapper(); - expect(wrapper.vm.counterAnnounce).toBe(0); - expect(wrapper.vm.showHelper).toBe(true); - expect(wrapper.vm.listExtensions[0].id).toBe("ab1"); + expect((wrapper.vm as any).counterAnnounce).toBe(0); + expect((wrapper.vm as any).showHelper).toBe(true); + expect((wrapper.vm as any).listExtensions[0].id).toBe("ab1"); expect(wrapper.find("#btn-reset").classes()).toEqual(expect.arrayContaining(["disabled"])); expect(wrapper.find("#btn-start").classes()).toEqual(expect.arrayContaining(["disabled"])); expect(wrapper.find("#btn-stop").classes()).toEqual(expect.arrayContaining(["disabled"])); @@ -64,12 +70,12 @@ describe("Default", () => { it("resets properly", async () => { const wrapper = getWrapper(); - expect(wrapper.vm.showHelper).toBe(true); + expect((wrapper.vm as any).showHelper).toBe(true); await wrapper.find("#btn-new").trigger("click"); - expect(wrapper.vm.showHelper).toBe(false); - expect(wrapper.vm.counterAnnounce).toBe(1); + expect((wrapper.vm as any).showHelper).toBe(false); + expect((wrapper.vm as any).counterAnnounce).toBe(1); await wrapper.find("#btn-reset").trigger("click"); - expect(wrapper.vm.showHelper).toBe(true); + expect((wrapper.vm as any).showHelper).toBe(true); await flushPromises(); }); diff --git a/client/src/components/Upload/DefaultBox.vue b/client/src/components/Upload/DefaultBox.vue index 3023a3feeb9..d484250485b 100644 --- a/client/src/components/Upload/DefaultBox.vue +++ b/client/src/components/Upload/DefaultBox.vue @@ -1,88 +1,60 @@ - @@ -134,7 +98,7 @@ function autoSelectFileInput() { {{ bytesToString(fileSize) }} - + {{ title }} @@ -101,7 +101,7 @@
- + props.id === props.activeNodeId); +const isPopulatedInput = computed( + () => + props.populatedInputs && + isWorkflowInput(props.step.type) && + "nodeText" in props.step && + props.step.nodeText !== undefined +); + const classes = computed(() => { return { "node-on-scroll-to": scrolledTo.value, @@ -263,8 +273,8 @@ const errors = computed(() => props.step.errors || stateStore.getStepLoadingStat const headerClass = computed(() => { return { ...invocationStep.value.headerClass, - "cursor-pointer": props.isInvocation, - "node-header": !props.isInvocation || invocationStep.value.headerClass === undefined, + "cursor-pointer": props.isInvocation || isPopulatedInput.value, + "node-header": invocationStep.value.headerClass === undefined, "cursor-move": !props.readonly && !props.isInvocation, }; }); diff --git a/client/src/components/Workflow/Editor/NodeInvocationText.vue b/client/src/components/Workflow/Editor/NodeInvocationText.vue index 0b37b9be771..4a54dc6fe8d 100644 --- a/client/src/components/Workflow/Editor/NodeInvocationText.vue +++ b/client/src/components/Workflow/Editor/NodeInvocationText.vue @@ -1,6 +1,8 @@ diff --git a/client/src/composables/fileDrop.ts b/client/src/composables/fileDrop.ts index 9ffdde5aba9..34216deb057 100644 --- a/client/src/composables/fileDrop.ts +++ b/client/src/composables/fileDrop.ts @@ -18,9 +18,12 @@ export function useFileDrop( solo: MaybeRefOrGetter, idleTime = 800 ) { - /** returns if any bootstrap modal is open */ + /** returns if any bootstrap modal or workflow run form is open */ function isAnyModalOpen() { - return document.querySelectorAll(".modal.show").length > 0; + return ( + document.querySelectorAll(".modal.show").length > 0 || + document.querySelectorAll(".workflow-run-form-simple").length > 0 + ); } type State = "idle" | "blocked" | "fileDragging"; diff --git a/client/src/composables/fileSources.ts b/client/src/composables/fileSources.ts index 9965fe7c6f5..f2e5c3631ab 100644 --- a/client/src/composables/fileSources.ts +++ b/client/src/composables/fileSources.ts @@ -2,7 +2,7 @@ import { onMounted, readonly, ref } from "vue"; import { type BrowsableFilesSourcePlugin, type FilterFileSourcesOptions } from "@/api/remoteFiles"; import { useFileSourcesStore } from "@/stores/fileSourcesStore"; -import { USER_FILE_PREFIX } from "@/utils/upload-payload"; +import { USER_FILE_PREFIX } from "@/utils/url"; /** * Composable for accessing and working with file sources. diff --git a/client/src/composables/monitorUploadedHistoryItems.ts b/client/src/composables/monitorUploadedHistoryItems.ts new file mode 100644 index 00000000000..0cabc95cc31 --- /dev/null +++ b/client/src/composables/monitorUploadedHistoryItems.ts @@ -0,0 +1,103 @@ +import { faExclamation, faSpinner, type IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { computed, type Ref } from "vue"; + +import { type HDASummary, type HistoryItemSummary, isHDA } from "@/api"; +import type { UploadItem } from "@/components/Upload/model"; +import { useHistoryItemsStore } from "@/stores/historyItemsStore"; +import { stateIsTerminal } from "@/utils/utils"; + +const REFER_TO_HISTORY_MSG = "Refer to the history panel to view dataset state."; + +/** + * For given uploaded items, monitor the states of those items from the history. + * + * @param uploadValues - The uploaded values from the upload `DefaultBox` + * @param historyId - The ID of the history being uploaded to + * @param enableStart - If an upload is enabled to start + * @param creatingPairedType - If the user has selected to create a `paired` + * or `list:paired` collection JUST from these uploads + */ +export function monitorUploadedHistoryItems( + uploadValues: Ref, + historyId: Ref, + enableStart: Ref, + creatingPairedType: Ref +) { + const historyItemsStore = useHistoryItemsStore(); + /** Uploaded items from the history */ + const uploadedHistoryItems = computed(() => { + // Get all successfully uploaded datasets + const uploadedDatasets: HistoryItemSummary[] = []; + uploadValues.value.forEach((model) => { + const outputs = model.outputs; + if (outputs) { + Object.entries(outputs).forEach((output) => { + const outputDetails = output[1] as HistoryItemSummary; + uploadedDatasets.push(outputDetails); + }); + } + }); + + // Get uploaded datasets from history + const historyItems = historyItemsStore.getHistoryItems(historyId.value, ""); + return uploadedDatasets + .map((item) => historyItems.find((historyItem) => historyItem.id === item.id)) + .filter((item) => isHDA(item)) as HDASummary[]; + }); + + const uploadedHistoryItemsReady = computed(() => + uploadedHistoryItems.value.every((item) => item && stateIsTerminal(item)) + ); + + const uploadedHistoryItemsOk = computed(() => + uploadedHistoryItems.value.filter((item) => item && item.state === "ok") + ); + + const historyItemsStateInfo = computed<{ + variant: string; + message: string; + icon?: IconDefinition; + spin?: boolean; + } | null>(() => { + if (uploadedHistoryItems.value?.length && !enableStart.value) { + if (!uploadedHistoryItemsReady.value) { + return { + variant: "info", + message: `Your upload(s) are not ready to be used yet. ${REFER_TO_HISTORY_MSG}`, + icon: faSpinner, + spin: true, + }; + } else if (uploadedHistoryItems.value.length > uploadedHistoryItemsOk.value.length) { + return { + variant: "warning", + message: `Only ${uploadedHistoryItemsOk.value.length} / ${uploadedHistoryItems.value.length} uploaded items are usable. ${REFER_TO_HISTORY_MSG}`, + icon: faExclamation, + }; + } else if (creatingPairedType.value && uploadedHistoryItemsOk.value.length % 2 !== 0) { + return { + variant: "danger", + message: + "Please upload an even number of datasets to create a dataset pair or a list of dataset pairs.", + icon: faExclamation, + }; + } else if (uploadedHistoryItemsOk.value.length) { + return { + variant: "success", + message: "Upload(s) ready to be used.", + }; + } else { + return null; + } + } else { + return null; + } + }); + + return { + /** Uploaded history items with the `ok` state */ + uploadedHistoryItemsOk, + /** If all uploaded history items have achieved a terminal state */ + uploadedHistoryItemsReady, + historyItemsStateInfo, + }; +} diff --git a/client/src/composables/uploadConfigurations.ts b/client/src/composables/uploadConfigurations.ts new file mode 100644 index 00000000000..8f5354626be --- /dev/null +++ b/client/src/composables/uploadConfigurations.ts @@ -0,0 +1,108 @@ +import { storeToRefs } from "pinia"; +import { computed, ref, watch } from "vue"; + +import type { CompositeFileInfo } from "@/api/datatypes"; +import { AUTO_EXTENSION, DEFAULT_EXTENSION, getUploadDatatypes, getUploadDbKeys } from "@/components/Upload/utils"; +import { useDatatypesMapperStore } from "@/stores/datatypesMapperStore"; + +import { useConfig } from "./config"; + +export type ExtensionDetails = { + id: string; + text: string; + description: string | null; + description_url: string | null; + composite_files?: CompositeFileInfo[] | null; + upload_warning?: string | null; +}; + +export type DbKey = { + id: string; + text: string; +}; + +export type UploadConfigurations = { + chunkUploadSize: number; + fileSourcesConfigured: boolean; + ftpUploadSite?: string; + defaultDbKey: string; + defaultExtension: string; +}; + +export function useUploadConfigurations(extensions: string[] | undefined) { + const { config, isConfigLoaded } = useConfig(); + + extensions = extensions?.filter((ext) => ext !== "data"); + + const configOptions = computed(() => + isConfigLoaded.value + ? { + chunkUploadSize: config.value.chunk_upload_size as number, + fileSourcesConfigured: config.value.file_sources_configured as boolean, + ftpUploadSite: (config.value.ftp_upload_site as string) || undefined, + defaultDbKey: (config.value.default_genome as string) || "", + defaultExtension: extensions?.length + ? extensions[0]! + : (config.value.default_extension as string) || DEFAULT_EXTENSION, + } + : null + ); + + // Load the list of extensions + // TODO: Maybe a store would be better for this + const listExtensions = ref([]); + const extensionsSet = ref(false); + async function loadExtensions() { + listExtensions.value = await getUploadDatatypes(false, AUTO_EXTENSION); + extensionsSet.value = true; + } + loadExtensions(); + + const datatypesMapperStore = useDatatypesMapperStore(); + const { datatypesMapper, loading: datatypesMapperLoading } = storeToRefs(datatypesMapperStore); + datatypesMapperStore.createMapper(); + + const effectiveExtensions = computed(() => { + if (extensions?.length && datatypesMapper.value && !datatypesMapperLoading.value) { + const result: ExtensionDetails[] = []; + listExtensions.value.forEach((extension) => { + if (extension && extension.id == DEFAULT_EXTENSION) { + result.push(extension); + } else if (datatypesMapper.value?.isSubTypeOfAny(extension.id, extensions!)) { + result.push(extension); + } + }); + return result; + } else { + return listExtensions.value; + } + }); + + const listDbKeys = ref([]); + const dbKeysSet = ref(false); + async function loadDbKeys() { + listDbKeys.value = await getUploadDbKeys(config.value?.default_genome || ""); + dbKeysSet.value = true; + } + + watch( + () => config.value, + async (c) => { + if (c) { + await loadDbKeys(); + } + }, + { immediate: true } + ); + + const ready = computed( + () => dbKeysSet.value && extensionsSet.value && !!datatypesMapper.value && !datatypesMapperLoading.value + ); + + return { + configOptions, + effectiveExtensions, + listDbKeys, + ready, + }; +} diff --git a/client/src/composables/useInvocationGraph.ts b/client/src/composables/useInvocationGraph.ts index 7bce026c3d1..9e03e7b4d15 100644 --- a/client/src/composables/useInvocationGraph.ts +++ b/client/src/composables/useInvocationGraph.ts @@ -15,13 +15,13 @@ import { GalaxyApi } from "@/api"; import { fetchCollectionDetails } from "@/api/datasetCollections"; import { fetchDatasetDetails } from "@/api/datasets"; import { type InvocationStep, type StepJobSummary, type WorkflowInvocationElementView } from "@/api/invocations"; +import type { StoredWorkflowDetailed } from "@/api/workflows"; import { getContentItemState } from "@/components/History/Content/model/states"; import { isWorkflowInput } from "@/components/Workflow/constants"; import { fromSimple } from "@/components/Workflow/Editor/modules/model"; import { getWorkflowFull } from "@/components/Workflow/workflows.services"; import { useInvocationStore } from "@/stores/invocationStore"; import { type Step } from "@/stores/workflowStepStore"; -import { type Workflow } from "@/stores/workflowStore"; import { rethrowSimple } from "@/utils/simple-error"; import { provideScopedWorkflowStores } from "./workflowStores"; @@ -46,7 +46,7 @@ export interface GraphStep extends Step { headerIconSpin?: boolean; nodeText?: string | boolean; } -interface InvocationGraph extends Workflow { +interface InvocationGraph extends Omit { steps: { [index: number]: GraphStep }; } diff --git a/client/src/composables/useWorkflowRunGraph.ts b/client/src/composables/useWorkflowRunGraph.ts new file mode 100644 index 00000000000..75357042060 --- /dev/null +++ b/client/src/composables/useWorkflowRunGraph.ts @@ -0,0 +1,212 @@ +import { faCheckCircle, faExclamationCircle, faSpinner, type IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { computed, type Ref, ref } from "vue"; + +import { isWorkflowInput } from "@/components/Workflow/constants"; +import { fromSimple } from "@/components/Workflow/Editor/modules/model"; +import { getWorkflowFull } from "@/components/Workflow/workflows.services"; +import { type Step } from "@/stores/workflowStepStore"; +import { rethrowSimple } from "@/utils/simple-error"; + +import { getHeaderClass } from "./useInvocationGraph"; + +const STEP_DESCRIPTIONS = { + TextToolParameter: "Provide text input", + IntegerToolParameter: "Provide an integer", + FloatToolParameter: "Provide a float", + ColorToolParameter: "Provide a color", + DirectoryUriToolParameter: "Provide a directory", + DataToolParameter: "Provide a dataset", + DataCollectionToolParameter: "Provide a collection", + SelectToolParameter: "Select an option", + BooleanToolParameter: "", +}; + +interface BaseDataToolParameterInput { + batch: boolean; + product: boolean; + values: { id: string; src: "dce" | "hda" | "hdca" | "ldda"; map_over_type: string }[]; +} +interface DataToolParameterInput extends BaseDataToolParameterInput {} +interface DataCollectionToolParameterInput extends BaseDataToolParameterInput {} +export type DataInput = DataToolParameterInput | DataCollectionToolParameterInput | boolean | string | null; + +interface WorkflowRunStepInfo { + headerClass?: Record; + headerIcon?: IconDefinition; + headerIconSpin?: boolean; + nodeText?: string | boolean; +} + +interface WorkflowRunStep extends Readonly, WorkflowRunStepInfo {} + +/** Composable that creates a readonly workflow run graph and loads it onto a workflow editor canvas for display. + * This graph updates as the user changes the inputs of the workflow. + * @param workflowId - The id of the workflow + * @param workflowVersion - The version of the workflow + * @param inputs - The current inputs of the workflow + * @param formInputs - The form inputs of the workflow + * @param stepValidation - The current error if any at the stepId: [stepId, message] + */ +export function useWorkflowRunGraph( + workflowId: string | undefined, + workflowVersion: number | undefined, + inputs: Ref>, + formInputs: Ref, + stepValidation: Ref<[string, string] | undefined> +) { + /** The workflow that is to be run */ + const loadedWorkflow = ref(null); + + const loading = ref(true); + + async function loadWorkflowOntoGraph() { + loading.value = true; + + try { + if (!workflowId) { + throw new Error("Workflow Id is not defined"); + } + if (workflowVersion === undefined) { + throw new Error("Workflow Version is not defined"); + } + + // initialize the original full workflow ref + if (!loadedWorkflow.value) { + loadedWorkflow.value = await getWorkflowFull(workflowId, workflowVersion); + } + + await fromSimple(workflowId, loadedWorkflow.value); + } catch (e) { + rethrowSimple(e); + } finally { + loading.value = false; + } + } + + /** The steps of the original workflow */ + const workflowSteps = computed>>(() => loadedWorkflow.value?.steps); + + const steps = computed>(() => { + if (!workflowSteps.value || !formInputs.value || !inputs.value) { + return {}; + } + + const result: Record = {}; + for (const stepId in workflowSteps.value) { + const step = workflowSteps.value[stepId]; + if (step) { + let stepInfo: WorkflowRunStepInfo | null = null; + const validation = getWorkflowRunStepValidation(step); + if (validation) { + stepInfo = validation; + } else if (isWorkflowInput(step.type)) { + const dataInput = inputs.value[step.id.toString()]; + const formInput = formInputs.value.find((input) => parseInt(input.name) === step.id); + stepInfo = getWorkflowRunStepInfo(formInput, dataInput); + } + if (stepInfo) { + result[stepId] = { ...step, ...stepInfo }; + } + } + } + return result; + }); + + /** Return step desciptions for the workflow graph given the current input field and user value + * @param formInput The form input field + * @param dataInput The user input value + */ + function getWorkflowRunStepInfo(formInput: any, dataInput?: DataInput): WorkflowRunStepInfo { + const optional = formInput?.optional as boolean; + const modelClass = formInput?.model_class as keyof typeof STEP_DESCRIPTIONS; + + if (modelClass === "BooleanToolParameter") { + return getStepDescription(dataInput as boolean, true); + } else if (modelClass === "DataToolParameter" || modelClass === "DataCollectionToolParameter") { + dataInput = dataInput as DataToolParameterInput | DataCollectionToolParameterInput; + const inputVals = dataInput?.values; + const options = formInput?.options; + + if (inputVals?.length === 1 && inputVals[0]) { + const { id, src } = inputVals[0]; + const item = options[src].find((option: any) => option.id === id); + + if (item && item.hid && item.name) { + return getStepDescription(`${item.hid}: ${item.name}`, true); + } else { + return getStepDescription("Input value processing", true, optional, true); + } + } else if (inputVals?.length) { + return getStepDescription(`${inputVals.length} inputs provided`, true); + } else { + return getStepDescription(STEP_DESCRIPTIONS[modelClass], false, optional); + } + } else if (Object.keys(STEP_DESCRIPTIONS).includes(modelClass)) { + if (!dataInput || dataInput.toString().trim() === "") { + return getStepDescription(STEP_DESCRIPTIONS[modelClass], false, optional); + } else { + let text: string; + switch (modelClass) { + case "ColorToolParameter": + text = dataInput as string; + break; + case "DirectoryUriToolParameter": + text = `Directory: ${dataInput}`; + break; + default: + text = `${dataInput}`; + } + return getStepDescription(text, true); + } + } else { + return getStepDescription("This is an input", true); + } + } + + /** Return step desciptions for the workflow graph given the current input value or prompt + * @param text The text to display + * @param populated Whether the input is populated, undefined for optional inputs + * @param optional Whether the input is optional + * @param spin Whether the icon should spin + */ + function getStepDescription( + text: string | boolean, + populated: boolean, + optional?: boolean, + spin?: boolean + ): WorkflowRunStepInfo { + // color variant for `paused` state works best for unpopulated inputs, + // "" for optional inputs and `ok` for populated inputs + const headerClass = optional ? "" : !spin && populated ? "ok" : "paused"; + const headerIcon = spin ? faSpinner : populated ? faCheckCircle : faExclamationCircle; + + text = typeof text === "boolean" ? text : !optional ? text : `${text} (optional)`; + + return { + nodeText: text, + headerClass: getHeaderClass(headerClass), + headerIcon, + headerIconSpin: spin, + }; + } + + function getWorkflowRunStepValidation(step: Step): WorkflowRunStepInfo | null { + if (stepValidation.value && stepValidation.value.length == 2) { + const [stepId, message] = stepValidation.value; + + if (stepId === step.id.toString()) { + const text = message.length < 20 ? message : "Fix error(s) for this step"; + return getStepDescription(text, false); + } + } + return null; + } + + return { + /** The steps of the workflow run graph */ + steps, + /** Fetches the original workflow structure and loads it onto the graph */ + loadWorkflowOntoGraph, + loading, + }; +} diff --git a/client/src/stores/workflowStore.ts b/client/src/stores/workflowStore.ts index 56c046b301d..d4c68478411 100644 --- a/client/src/stores/workflowStore.ts +++ b/client/src/stores/workflowStore.ts @@ -2,25 +2,13 @@ import { defineStore } from "pinia"; import { computed, ref, set } from "vue"; import { GalaxyApi } from "@/api"; -// import type { StoredWorkflowDetailed } from "@/api/workflows"; // TODO: use this instead of locally defined type -import { type Steps } from "@/stores/workflowStepStore"; +import type { StoredWorkflowDetailed } from "@/api/workflows"; -export interface Workflow { - name: string; - id: string; - steps: Steps; - step_count?: number; - latest_id?: string; - version: number; - deleted?: boolean; - owner?: string; - annotation?: string; - tags?: string[]; - update_time?: string; -} +// TODO: Define a less specific `Workflow` type here, and unify with the one in `workflows.services.ts`, +// instead of using `StoredWorkflowDetailed` directly. export const useWorkflowStore = defineStore("workflowStore", () => { - const workflowsByInstanceId = ref<{ [index: string]: Workflow }>({}); + const workflowsByInstanceId = ref<{ [index: string]: StoredWorkflowDetailed }>({}); const getStoredWorkflowByInstanceId = computed(() => (workflowId: string) => { return workflowsByInstanceId.value[workflowId]; diff --git a/client/src/utils/upload-payload.js b/client/src/utils/upload-payload.js index d3d434ec5f2..2b47cf5a108 100644 --- a/client/src/utils/upload-payload.js +++ b/client/src/utils/upload-payload.js @@ -1,25 +1,6 @@ -export const DEFAULT_FILE_NAME = "New File"; -export const USER_FILE_PREFIX = "gxuserfiles://"; -export const URI_PREFIXES = [ - "http://", - "https://", - "ftp://", - "file://", - "gxfiles://", - "gximport://", - "gxuserimport://", - USER_FILE_PREFIX, - "gxftp://", - "drs://", - "invenio://", - "zenodo://", - "dataverse://", - "elabftw://", -]; +import { isUrl } from "./url"; -export function isUrl(content) { - return URI_PREFIXES.some((prefix) => content.startsWith(prefix)); -} +export const DEFAULT_FILE_NAME = "New File"; export function uploadPayload(items, historyId, composite = false) { const files = []; diff --git a/client/src/utils/upload-payload.test.js b/client/src/utils/upload-payload.test.js index 9a3bc946391..490455f2e7b 100644 --- a/client/src/utils/upload-payload.test.js +++ b/client/src/utils/upload-payload.test.js @@ -1,4 +1,4 @@ -import { isUrl, uploadPayload } from "./upload-payload.js"; +import { uploadPayload } from "./upload-payload.js"; describe("uploadPayload", () => { test("basic validation", () => { @@ -26,12 +26,6 @@ describe("uploadPayload", () => { ).toThrow("Invalid url: xyz://test.me.1"); }); - test("url detection", () => { - expect(isUrl("xyz://")).toBeFalsy(); - expect(isUrl("ftp://")).toBeTruthy(); - expect(isUrl("http://")).toBeTruthy(); - }); - test("regular payload", () => { const p = uploadPayload( [ diff --git a/client/src/utils/url.test.js b/client/src/utils/url.test.js index 6fcaf8bab99..f154515e571 100644 --- a/client/src/utils/url.test.js +++ b/client/src/utils/url.test.js @@ -1,4 +1,4 @@ -import { addSearchParams } from "./url"; +import { addSearchParams, isUrl } from "./url"; describe("test url utilities", () => { it("adding parameters to url", async () => { @@ -6,4 +6,10 @@ describe("test url utilities", () => { expect(addSearchParams("/test", { name: "value", and: "this" })).toBe("/test?name=value&and=this"); expect(addSearchParams("/test?exists=value", { name: "value" })).toBe("/test?exists=value&name=value"); }); + + test("url detection", () => { + expect(isUrl("xyz://")).toBeFalsy(); + expect(isUrl("ftp://")).toBeTruthy(); + expect(isUrl("http://")).toBeTruthy(); + }); }); diff --git a/client/src/utils/url.ts b/client/src/utils/url.ts index bc537c30382..83e44a738a7 100644 --- a/client/src/utils/url.ts +++ b/client/src/utils/url.ts @@ -10,6 +10,28 @@ export interface UrlDataOptions { errorSimplify?: boolean; } +export const USER_FILE_PREFIX = "gxuserfiles://"; +export const URI_PREFIXES = [ + "http://", + "https://", + "ftp://", + "file://", + "gxfiles://", + "gximport://", + "gxuserimport://", + USER_FILE_PREFIX, + "gxftp://", + "drs://", + "invenio://", + "zenodo://", + "dataverse://", + "elabftw://", +]; + +export function isUrl(content: string): boolean { + return URI_PREFIXES.some((prefix) => content.startsWith(prefix)); +} + export async function urlData({ url, headers, params, errorSimplify = true }: UrlDataOptions): Promise { try { headers = headers || {}; diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 15f12312385..abffb257984 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1653,6 +1653,7 @@ class NavigatesGalaxy(HasDriver): def workflow_run_ensure_expanded(self): workflow_run = self.components.workflow_run if workflow_run.expanded_form.is_absent: + workflow_run.runtime_setting_button.wait_for_and_click() workflow_run.expand_form_link.wait_for_and_click() workflow_run.expanded_form.wait_for_visible()