diff --git a/.github/workflows/pr-title-update.yml b/.github/workflows/pr-title-update.yml index 6a4c5021f63d..8ffd54a45da4 100644 --- a/.github/workflows/pr-title-update.yml +++ b/.github/workflows/pr-title-update.yml @@ -2,26 +2,28 @@ name: Update PR title on: pull_request_target: - types: [opened, edited] - branches: - - "release_**" + types: [opened, edited, reopened] jobs: update-title: + if: github.event.action != 'edited' || github.event.changes.base.ref.from != '' runs-on: ubuntu-latest permissions: pull-requests: write steps: - - uses: actions/checkout@v4 - name: Update PR title env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} TARGET_BRANCH: "${{ github.base_ref }}" PR_TITLE: "${{ github.event.pull_request.title }}" + REPO: "${{ github.repository }}" run: | - VERSION=$(echo $TARGET_BRANCH | grep -oP '\d+\.\d+') - if [[ -n "$VERSION" && ! "$PR_TITLE" =~ ^\[$VERSION\] ]]; then - NEW_TITLE="[$VERSION] $PR_TITLE" - gh pr edit $PR_NUMBER --title "$NEW_TITLE" + VERSION=$(echo $TARGET_BRANCH | grep -oP '^release_\K\d+.\d+$' || true) + NEW_TITLE=$(echo "$PR_TITLE" | sed -E "s/\[[0-9]+\.[0-9]+\] //") + if [[ -n "$VERSION" ]]; then + NEW_TITLE="[$VERSION] $NEW_TITLE" + fi + if [[ "$NEW_TITLE" != "$PR_TITLE" ]]; then + gh pr edit $PR_NUMBER --repo "$REPO" --title "$NEW_TITLE" fi diff --git a/client/package.json b/client/package.json index b2a751ff3d94..de12b9f71852 100644 --- a/client/package.json +++ b/client/package.json @@ -66,6 +66,7 @@ "elkjs": "^0.8.2", "file-saver": "^2.0.5", "flush-promises": "^1.0.2", + "font-awesome-6": "npm:@fortawesome/free-solid-svg-icons@6", "glob": "^10.3.10", "handsontable": "^4.0.0", "hsluv": "^1.0.1", diff --git a/client/src/api/datatypes.ts b/client/src/api/datatypes.ts new file mode 100644 index 000000000000..0b6d30a71c60 --- /dev/null +++ b/client/src/api/datatypes.ts @@ -0,0 +1,3 @@ +import { type components } from "@/api"; + +export type CompositeFileInfo = components["schemas"]["CompositeFileInfo"]; 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/api/schema/schema.ts b/client/src/api/schema/schema.ts index 1e9ab944ad2e..654d8e77594e 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -18464,6 +18464,8 @@ export interface components { * } */ WorkflowJobMetric: { + /** Job Id */ + job_id: string; /** * Name * @description The name of the metric variable. diff --git a/client/src/components/ActivityBar/ActivityBar.vue b/client/src/components/ActivityBar/ActivityBar.vue index 5371d14f84fe..e5bdd51663f5 100644 --- a/client/src/components/ActivityBar/ActivityBar.vue +++ b/client/src/components/ActivityBar/ActivityBar.vue @@ -98,6 +98,10 @@ const dragItem: Ref = ref(null); // drag state const isDragging = ref(false); +// computed values +const canDrag = computed(() => isActiveSideBar("settings")); +const isSideBarOpen = computed(() => activityStore.toggledSideBar !== ""); + /** * Checks if the route of an activity is currently being visited and panels are collapsed */ @@ -112,8 +116,6 @@ function isActiveSideBar(menuKey: string) { return activityStore.toggledSideBar === menuKey; } -const isSideBarOpen = computed(() => activityStore.toggledSideBar !== ""); - /** * Checks if an activity that has a panel should have the `is-active` prop */ @@ -209,6 +211,7 @@ defineExpose({ -
+
+import { faCheckCircle, faUndo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; +import { BAlert, BLink, BModal } from "bootstrap-vue"; +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 { useHistoryStore } from "@/stores/historyStore"; +import localize from "@/utils/localization"; +import { orList } from "@/utils/strings"; + +import type { CollectionType, DatasetPair } from "../History/adapters/buildCollectionModal"; + +import ListCollectionCreator from "./ListCollectionCreator.vue"; +import PairCollectionCreator from "./PairCollectionCreator.vue"; +import PairedListCollectionCreator from "./PairedListCollectionCreator.vue"; +import Heading from "@/components/Common/Heading.vue"; +import GenericItem from "@/components/History/Content/GenericItem.vue"; +import LoadingSpan from "@/components/LoadingSpan.vue"; + +interface Props { + historyId: string; + showModal: boolean; + collectionType: CollectionType; + selectedItems?: HistoryItemSummary[]; + defaultHideSourceItems?: boolean; + extensions?: string[]; + fromRulesInput?: boolean; + hideModalOnCreate?: boolean; + filterText?: string; +} +const props = defineProps(); + +const emit = defineEmits<{ + (e: "created-collection", collection: any): void; + (e: "update:show-modal", showModal: boolean): void; +}>(); + +/** Computed toggle that handles opening and closing the modal */ +const localShowToggle = computed({ + get: () => props.showModal, + set: (value: boolean) => { + emit("update:show-modal", value); + }, +}); + +// Create Collection refs +const creatingCollection = ref(false); +const createCollectionError = ref(null); +const createdCollection = ref(null); + +// History items variables +const historyItemsError = ref(null); +const collectionItemsStore = useCollectionBuilderItemsStore(); +const historyStore = useHistoryStore(); +const history = computed(() => historyStore.getHistoryById(props.historyId)); +const historyId = computed(() => props.historyId); +const localFilterText = computed(() => props.filterText || ""); +const historyUpdateTime = computed(() => history.value?.update_time); +const isFetchingItems = computed(() => collectionItemsStore.isFetching[localFilterText.value]); +const historyDatasets = computed(() => { + if (collectionItemsStore.cachedDatasetsForFilterText) { + return collectionItemsStore.cachedDatasetsForFilterText[localFilterText.value] || []; + } else { + return []; + } +}); + +/** Flag for the initial fetch of history items */ +const initialFetch = ref(false); + +/** Whether a list of items was selected to create a collection from */ +const fromSelection = computed(() => !!props.selectedItems?.length); + +/** Items to create the collection from */ +const creatorItems = computed(() => (fromSelection.value ? props.selectedItems : historyDatasets.value)); + +watch( + () => localShowToggle.value, + async (show) => { + if (show) { + await fetchHistoryDatasets(); + if (!initialFetch.value) { + initialFetch.value = true; + } + } + }, + { immediate: true } +); + +// Fetch items when history ID or update time changes, only if localShowToggle is true +watch([historyId, historyUpdateTime, localFilterText], async () => { + if (localShowToggle.value) { + await fetchHistoryDatasets(); + } +}); + +// If there is a change in `historyDatasets`, but we have selected items, we should update the selected items +watch( + () => historyDatasets.value, + (newDatasets) => { + if (fromSelection.value) { + // find each selected item in the new datasets, and update it + props.selectedItems?.forEach((selectedItem) => { + const newDataset = newDatasets.find((dataset) => dataset.id === selectedItem.id); + if (newDataset) { + Object.assign(selectedItem, newDataset); + } + }); + } + } +); + +const extensionInTitle = computed(() => { + const extensions = props.extensions; + if (!extensions || extensions.length == 0 || extensions.indexOf("data") >= 0) { + return ""; + } else { + return orList(extensions); + } +}); + +const modalTitle = computed(() => { + if (props.collectionType === "list") { + return localize(`Create a list of ${fromSelection.value ? "selected" : ""} ${extensionInTitle.value} datasets`); + } else if (props.collectionType === "list:paired") { + return localize( + `Create a list of ${fromSelection.value ? "selected" : ""} ${extensionInTitle.value} paired datasets` + ); + } else if (props.collectionType === "paired") { + return localize( + `Create a ${extensionInTitle.value} paired dataset collection ${ + fromSelection.value ? "from selected items" : "" + }` + ); + } else { + return localize("Create a collection"); + } +}); + +// Methods +function createListCollection(elements: HDASummary[], name: string, hideSourceItems: boolean) { + const returnedElems = elements.map((element) => ({ + id: element.id, + name: element.name, + //TODO: this allows for list:list even if the implementation does not - reconcile + src: "src" in element ? element.src : element.history_content_type == "dataset" ? "hda" : "hdca", + })); + return createHDCA(returnedElems, "list", name, hideSourceItems); +} + +function createListPairedCollection(elements: DatasetPair[], name: string, hideSourceItems: boolean) { + const returnedElems = elements.map((pair) => ({ + collection_type: "paired", + src: "new_collection", + name: pair.name, + element_identifiers: [ + { + name: "forward", + id: pair.forward.id, + src: "src" in pair.forward ? pair.forward.src : "hda", + }, + { + name: "reverse", + id: pair.reverse.id, + src: "src" in pair.reverse ? pair.reverse.src : "hda", + }, + ], + })); + return createHDCA(returnedElems, "list:paired", name, hideSourceItems); +} + +function createPairedCollection(elements: DatasetPair, name: string, hideSourceItems: boolean) { + const { forward, reverse } = elements; + const returnedElems = [ + { name: "forward", src: "src" in forward ? forward.src : "hda", id: forward.id }, + { name: "reverse", src: "src" in reverse ? reverse.src : "hda", id: reverse.id }, + ]; + return createHDCA(returnedElems, "paired", name, hideSourceItems); +} + +async function createHDCA( + element_identifiers: any[], + collection_type: CollectionType, + name: string, + hide_source_items: boolean, + options = {} +) { + try { + creatingCollection.value = true; + const collection = await createDatasetCollection(history.value as HistorySummary, { + collection_type, + name, + hide_source_items, + element_identifiers, + options, + }); + + emit("created-collection", collection); + createdCollection.value = collection; + + if (props.hideModalOnCreate) { + hideModal(); + } + } catch (error) { + createCollectionError.value = error as string; + } finally { + creatingCollection.value = false; + } +} + +async function fetchHistoryDatasets() { + const { error } = await collectionItemsStore.fetchDatasetsForFiltertext( + historyId.value, + historyUpdateTime.value, + localFilterText.value + ); + if (error) { + historyItemsError.value = error; + console.error("Error fetching history items:", historyItemsError.value); + } else { + historyItemsError.value = null; + } +} + +function hideModal() { + localShowToggle.value = false; +} + +function resetModal() { + createCollectionError.value = null; + createdCollection.value = null; +} + + + + + diff --git a/client/src/components/Collections/ListCollectionCreator.vue b/client/src/components/Collections/ListCollectionCreator.vue index 5798187943f2..ca9e25aec416 100644 --- a/client/src/components/Collections/ListCollectionCreator.vue +++ b/client/src/components/Collections/ListCollectionCreator.vue @@ -1,43 +1,48 @@ diff --git a/client/src/components/Collections/PairCollectionCreator.vue b/client/src/components/Collections/PairCollectionCreator.vue index 2520a3a022f8..1d3e5922f315 100644 --- a/client/src/components/Collections/PairCollectionCreator.vue +++ b/client/src/components/Collections/PairCollectionCreator.vue @@ -1,32 +1,50 @@ @@ -382,10 +529,6 @@ onMounted(() => { margin-top: 8px; } - .main-help { - cursor: pointer; - } - .collection-elements-controls { margin-bottom: 8px; } @@ -433,6 +576,15 @@ onMounted(() => { text-decoration: none; } } + + &.selected { + border-color: black; + background: rgb(118, 119, 131); + color: white; + a { + color: white; + } + } } .empty-message { diff --git a/client/src/components/Collections/PairCollectionCreatorModal.js b/client/src/components/Collections/PairCollectionCreatorModal.js deleted file mode 100644 index 1cfa2a64dc71..000000000000 --- a/client/src/components/Collections/PairCollectionCreatorModal.js +++ /dev/null @@ -1,43 +0,0 @@ -import _l from "utils/localization"; -import Vue from "vue"; - -import { collectionCreatorModalSetup } from "./common/modal"; - -function pairCollectionCreatorModal(elements, options) { - options = options || {}; - options.title = _l("Create a collection from a pair of datasets"); - const { promise, showEl } = collectionCreatorModalSetup(options); - return import(/* webpackChunkName: "PairCollectionCreator" */ "./PairCollectionCreator.vue").then((module) => { - var pairCollectionCreatorInstance = Vue.extend(module.default); - var vm = document.createElement("div"); - showEl(vm); - new pairCollectionCreatorInstance({ - propsData: { - initialElements: elements, - creationFn: options.creationFn, - oncancel: options.oncancel, - oncreate: options.oncreate, - defaultHideSourceItems: options.defaultHideSourceItems, - }, - }).$mount(vm); - return promise; - }); -} -function createPairCollection(contents, defaultHideSourceItems = true) { - var elements = contents.toJSON(); - var promise = pairCollectionCreatorModal(elements, { - defaultHideSourceItems: defaultHideSourceItems, - creationFn: function (elements, name, hideSourceItems) { - elements = [ - { name: "forward", src: elements[0].src || "hda", id: elements[0].id }, - { name: "reverse", src: elements[1].src || "hda", id: elements[1].id }, - ]; - return contents.createHDCA(elements, "paired", name, hideSourceItems); - }, - }); - return promise; -} -export default { - pairCollectionCreatorModal: pairCollectionCreatorModal, - createPairCollection: createPairCollection, -}; diff --git a/client/src/components/Collections/PairedElementView.vue b/client/src/components/Collections/PairedElementView.vue index 364832a22db2..42cd1d9e8af0 100644 --- a/client/src/components/Collections/PairedElementView.vue +++ b/client/src/components/Collections/PairedElementView.vue @@ -1,69 +1,58 @@ diff --git a/client/src/components/Collections/PairedListCollectionCreator.test.js b/client/src/components/Collections/PairedListCollectionCreator.test.js index a798ba2f8f17..d62270b04335 100644 --- a/client/src/components/Collections/PairedListCollectionCreator.test.js +++ b/client/src/components/Collections/PairedListCollectionCreator.test.js @@ -1,48 +1,69 @@ +import { createTestingPinia } from "@pinia/testing"; import DATA from "@tests/test-data/paired-collection-creator.data.js"; import { mount, shallowMount } from "@vue/test-utils"; import PairedListCollectionCreator from "components/Collections/PairedListCollectionCreator"; +import flushPromises from "flush-promises"; +import Vue from "vue"; + +import { useServerMock } from "@/api/client/__mocks__"; + +// Mock the localize directive +// (otherwise we get: [Vue warn]: Failed to resolve directive: localize) +Vue.directive("localize", { + bind(el, binding) { + el.textContent = binding.value; + }, +}); + +const { server, http } = useServerMock(); describe("PairedListCollectionCreator", () => { let wrapper; + const pinia = createTestingPinia(); + + beforeEach(() => { + server.use( + http.get("/api/configuration", ({ response }) => { + return response(200).json({ + chunk_upload_size: 100, + file_sources_configured: true, + }); + }) + ); + }); + + it("performs an autopair on startup if we have a selection", async () => { + // Kind of deprecated because we are never using `props.fromSelection: true` anywhere - it("autopairs the dataset", async () => { wrapper = shallowMount(PairedListCollectionCreator, { propsData: { + historyId: "history_id", initialElements: DATA._1, - creationFn: () => { - return; - }, - oncreate: () => { - return; - }, - oncancel: () => { - return; - }, - hideSourceItems: false, + fromSelection: true, }, + pinia, }); - await wrapper.vm.$nextTick(); + + await flushPromises(); // Autopair is called on startup - expect(wrapper.findAll("li.dataset unpaired").length == 0).toBeTruthy(); + const pairsCountDisplay = wrapper.find('[data-description="number of pairs"]'); + expect(pairsCountDisplay.text()).toContain(`${DATA._1.length / 2} pairs`); }); - it("selects the correct name for an auotpair", async () => { + it("selects the correct name for an autopair", async () => { wrapper = mount(PairedListCollectionCreator, { propsData: { + historyId: "history_id", initialElements: DATA._2, - creationFn: () => { - return; - }, - oncreate: () => { - return; - }, - oncancel: () => { - return; - }, - hideSourceItems: false, + }, + pinia, + stubs: { + FontAwesomeIcon: true, + BPopover: true, }, }); - await wrapper.vm.$nextTick(); + + await flushPromises(); //change filter to .1.fastq/.2.fastq await wrapper.find("div.forward-unpaired-filter > div.input-group-append > button").trigger("click"); await wrapper @@ -56,7 +77,7 @@ describe("PairedListCollectionCreator", () => { const reverseFilter = wrapper.find("div.reverse-unpaired-filter > input").element.value; expect(reverseFilter).toBe(".2.fastq"); // click Autopair - await wrapper.find("a.autopair-link").trigger("click"); + await wrapper.find(".autopair-link").trigger("click"); //assert pair-name longer name const pairname = wrapper.find("span.pair-name"); expect(pairname.text()).toBe("DP134_1_FS_PSII_FSB_42C_A10"); @@ -65,20 +86,17 @@ describe("PairedListCollectionCreator", () => { it("removes the period from autopair name", async () => { wrapper = mount(PairedListCollectionCreator, { propsData: { + historyId: "history_id", initialElements: DATA._3, - creationFn: () => { - return; - }, - oncreate: () => { - return; - }, - oncancel: () => { - return; - }, - hideSourceItems: false, + }, + pinia, + stubs: { + FontAwesomeIcon: true, + BPopover: true, }, }); - await wrapper.vm.$nextTick(); + + await flushPromises(); //change filter to .1.fastq/.2.fastq await wrapper.find("div.forward-unpaired-filter > div.input-group-append > button").trigger("click"); await wrapper @@ -92,7 +110,7 @@ describe("PairedListCollectionCreator", () => { const reverseFilter = wrapper.find("div.reverse-unpaired-filter > input").element.value; expect(reverseFilter).toBe(".2.fastq"); // click Autopair - await wrapper.find("a.autopair-link").trigger("click"); + await wrapper.find(".autopair-link").trigger("click"); //assert pair-name longer name const pairname = wrapper.find("span.pair-name"); expect(pairname.text()).toBe("UII_moo_1"); @@ -101,20 +119,17 @@ describe("PairedListCollectionCreator", () => { it("autopairs correctly when filters are typed in", async () => { wrapper = mount(PairedListCollectionCreator, { propsData: { + historyId: "history_id", initialElements: DATA._4, - creationFn: () => { - return; - }, - oncreate: () => { - return; - }, - oncancel: () => { - return; - }, - hideSourceItems: false, + }, + pinia, + stubs: { + FontAwesomeIcon: true, + BPopover: true, }, }); - await wrapper.vm.$nextTick(); + + await flushPromises(); //change filter to _R1/_R2 await wrapper.find("div.forward-unpaired-filter > input").setValue("_R1"); await wrapper.find("div.reverse-unpaired-filter > input").setValue("_R2"); @@ -125,7 +140,7 @@ describe("PairedListCollectionCreator", () => { const reverseFilter = wrapper.find("div.reverse-unpaired-filter > input").element.value; expect(reverseFilter).toBe("_R2"); // click Autopair - await wrapper.find("a.autopair-link").trigger("click"); + await wrapper.find(".autopair-link").trigger("click"); //assert all pairs matched expect(wrapper.findAll("li.dataset unpaired").length == 0).toBeTruthy(); }); diff --git a/client/src/components/Collections/PairedListCollectionCreator.vue b/client/src/components/Collections/PairedListCollectionCreator.vue index c699c0de7077..620252f7c281 100644 --- a/client/src/components/Collections/PairedListCollectionCreator.vue +++ b/client/src/components/Collections/PairedListCollectionCreator.vue @@ -1,1044 +1,1335 @@ + + - diff --git a/client/src/components/Collections/common/ClickToEdit.vue b/client/src/components/Collections/common/ClickToEdit.vue index da60f01880d9..b77925d65a1d 100644 --- a/client/src/components/Collections/common/ClickToEdit.vue +++ b/client/src/components/Collections/common/ClickToEdit.vue @@ -1,5 +1,8 @@ diff --git a/client/src/components/Collections/common/CollectionCreator.vue b/client/src/components/Collections/common/CollectionCreator.vue index 8ef14660d999..c60e80099fd2 100644 --- a/client/src/components/Collections/common/CollectionCreator.vue +++ b/client/src/components/Collections/common/CollectionCreator.vue @@ -1,38 +1,127 @@ diff --git a/client/src/components/Help/terms.yml b/client/src/components/Help/terms.yml index 536d08f3dee1..c361ddf91e60 100644 --- a/client/src/components/Help/terms.yml +++ b/client/src/components/Help/terms.yml @@ -59,7 +59,36 @@ 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). + filteredExtensions: | + The history is filtered for the extensions that are required for this collection. You might see some + items with other extensions since those can still be valid inputs via implicit conversion. + requiredUploadExtensions: | + The extensions that are required for this collection. The files you upload will be assumed to have + these extensions. In the case of more than one extension, you can select a specific extension for + each individual file above. If there is only one extension, Galaxy will attempt to set that as the + extension for each file. + jobs: + metrics: + cores: | + This is how many [cores](https://en.wikipedia.org/wiki/Central_processing_unit) (or distinct central processing units (CPUs)) are + allocated to run the job for the tool. This value is generally configured for the tool by the Galaxy administrator. This value + does not guarantee how many cores the job actually used - the job may have used more and less based on how Galaxy is configured + and how the tool is programmed. + + walltime: | + This is estimate of the length of time the job ran created by recording the start and stop of the job in the job script + created for the tool execution and subtracting these values. + + allocated_core_time: | + This is the number of cores Galaxy believes is allocated for the job times the estimated walltime for the job. This can be thought + of as scaling the runtime/walltime metric by the number of cores allocated - when purchasing compute time per core hour or consuming + compute time from a compute center's allocation - this is likely to be a more interesting and useful number than the walltime. + states: # upload, waiting, failed, paused, deleting, deleted, stop, stopped, skipped. ok: | diff --git a/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue b/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue index 313846e57546..5463baab1b59 100644 --- a/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue +++ b/client/src/components/History/CurrentHistory/HistoryOperations/SelectionOperations.vue @@ -138,11 +138,20 @@

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

+ + + + + diff --git a/client/src/components/Panels/ToolBox.vue b/client/src/components/Panels/ToolBox.vue index cca2127c4741..d34f8ee6f501 100644 --- a/client/src/components/Panels/ToolBox.vue +++ b/client/src/components/Panels/ToolBox.vue @@ -33,7 +33,6 @@ const props = defineProps({ panelView: { type: String, required: true }, showAdvanced: { type: Boolean, default: false, required: true }, panelQuery: { type: String, required: true }, - editorWorkflows: { type: Array, default: null }, dataManagers: { type: Array, default: null }, moduleSections: { type: Array as PropType>, default: null }, useSearchWorker: { type: Boolean, default: true }, diff --git a/client/src/components/Panels/ToolPanel.vue b/client/src/components/Panels/ToolPanel.vue index fb75fb39295d..80e196135ba4 100644 --- a/client/src/components/Panels/ToolPanel.vue +++ b/client/src/components/Panels/ToolPanel.vue @@ -17,7 +17,6 @@ import Heading from "@/components/Common/Heading.vue"; const props = defineProps({ workflow: { type: Boolean, default: false }, - editorWorkflows: { type: Array, default: null }, dataManagers: { type: Array, default: null }, moduleSections: { type: Array, default: null }, useSearchWorker: { type: Boolean, default: true }, @@ -199,7 +198,6 @@ watch( :panel-query.sync="query" :panel-view="currentPanelView" :show-advanced.sync="showAdvanced" - :editor-workflows="editorWorkflows" :data-managers="dataManagers" :module-sections="moduleSections" :use-search-worker="useSearchWorker" diff --git a/client/src/components/ProgressBar.vue b/client/src/components/ProgressBar.vue index 29290ead9387..eb10d0bbe240 100644 --- a/client/src/components/ProgressBar.vue +++ b/client/src/components/ProgressBar.vue @@ -4,9 +4,9 @@ import { BProgress, BProgressBar } from "bootstrap-vue"; interface Props { - total: number; + total?: number; note: string; - loading: boolean; + loading?: boolean; okCount?: number; runningCount?: number; newCount?: number; diff --git a/client/src/components/Tool/ToolCard.vue b/client/src/components/Tool/ToolCard.vue index f09527328008..f76955803d2b 100644 --- a/client/src/components/Tool/ToolCard.vue +++ b/client/src/components/Tool/ToolCard.vue @@ -118,7 +118,7 @@ const showHelpForum = computed(() => isConfigLoaded.value && config.value.enable diff --git a/client/src/components/Workflow/Editor/modules/activities.ts b/client/src/components/Workflow/Editor/modules/activities.ts index aed37909dea2..5a5534a4af0e 100644 --- a/client/src/components/Workflow/Editor/modules/activities.ts +++ b/client/src/components/Workflow/Editor/modules/activities.ts @@ -13,6 +13,7 @@ import { faWrench, } from "@fortawesome/free-solid-svg-icons"; import { watchImmediate } from "@vueuse/core"; +import { faDiagramNext } from "font-awesome-6"; import { computed, type Ref } from "vue"; import { type Activity, useActivityStore } from "@/stores/activityStore"; @@ -27,6 +28,15 @@ export const workflowEditorActivities = [ icon: faPencilAlt, visible: true, }, + { + title: "Inputs", + id: "workflow-editor-inputs", + tooltip: "Add input steps to your workflow", + description: "Add input steps to your workflow.", + icon: faDiagramNext, + panel: true, + visible: true, + }, { title: "Tools", id: "workflow-editor-tools", diff --git a/client/src/components/Workflow/Editor/modules/inputs.ts b/client/src/components/Workflow/Editor/modules/inputs.ts new file mode 100644 index 000000000000..c152058d4360 --- /dev/null +++ b/client/src/components/Workflow/Editor/modules/inputs.ts @@ -0,0 +1,90 @@ +import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons"; +import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { type IconDefinition } from "font-awesome-6"; + +export interface WorkflowInput { + id?: string; // unique ID. defaults to module ID + moduleId: string; + title: string; + description: string; + stateOverwrites?: { + parameter_type?: "text" | "integer" | "boolean" | "color" | "float" | "directory_uri"; + }; + icon: IconDefinition; +} + +export function getWorkflowInputs(): WorkflowInput[] { + return [ + { + moduleId: "data_input", + title: "Input Dataset", + description: "Single dataset input", + icon: faFile, + }, + { + moduleId: "data_collection_input", + title: "Input Dataset Collection", + description: "Input for a collection of datasets", + icon: faFolder, + }, + { + moduleId: "parameter_input", + title: "Text Input", + description: "Text parameter used for workflow logic", + icon: faPencilAlt, + stateOverwrites: { + parameter_type: "text", + }, + }, + { + id: "parameter_input_integer", + moduleId: "parameter_input", + title: "Integer Input", + description: "Whole number parameter used for workflow logic", + icon: faPencilAlt, + stateOverwrites: { + parameter_type: "integer", + }, + }, + { + id: "parameter_input_float", + moduleId: "parameter_input", + title: "Float Input", + description: "Imprecise decimal number parameter used for workflow logic", + icon: faPencilAlt, + stateOverwrites: { + parameter_type: "float", + }, + }, + { + id: "parameter_input_boolean", + moduleId: "parameter_input", + title: "Boolean Input", + description: "True / False parameter used for workflow logic", + icon: faPencilAlt, + stateOverwrites: { + parameter_type: "boolean", + }, + }, + { + id: "parameter_input_color", + moduleId: "parameter_input", + title: "Color Input", + description: "Color parameter used for workflow logic", + icon: faPencilAlt, + stateOverwrites: { + parameter_type: "color", + }, + }, + { + id: "parameter_input_directory_uri", + moduleId: "parameter_input", + title: "Directory Input", + description: "Directory parameter used for workflow logic", + icon: faPencilAlt, + stateOverwrites: { + parameter_type: "directory_uri", + }, + }, + ]; +} diff --git a/client/src/components/Workflow/Editor/modules/linting.ts b/client/src/components/Workflow/Editor/modules/linting.ts index 21d02cafff88..75a7c142ab43 100644 --- a/client/src/components/Workflow/Editor/modules/linting.ts +++ b/client/src/components/Workflow/Editor/modules/linting.ts @@ -15,6 +15,13 @@ interface LintState { autofix?: boolean; } +export const bestPracticeWarningAnnotation = + "This workflow is not annotated. Providing an annotation helps workflow executors understand the purpose and usage of the workflow."; +export const bestPracticeWarningCreator = + "This workflow does not specify creator(s). This is important metadata for workflows that will be published and/or shared to help workflow executors know how to cite the workflow authors."; +export const bestPracticeWarningLicense = + "This workflow does not specify a license. This is important metadata for workflows that will be published and/or shared to help workflow executors understand how it may be used."; + export function getDisconnectedInputs( steps: Steps = {}, datatypesMapper: DatatypesMapperModel, diff --git a/client/src/components/Workflow/Invocation/Export/InvocationExportWizard.vue b/client/src/components/Workflow/Invocation/Export/InvocationExportWizard.vue index dcfa04e4f770..23c6d1d0c8dd 100644 --- a/client/src/components/Workflow/Invocation/Export/InvocationExportWizard.vue +++ b/client/src/components/Workflow/Invocation/Export/InvocationExportWizard.vue @@ -327,6 +327,7 @@ Examples of RDM repositories include [Zenodo](https://zenodo.org/), [Invenio RDM @@ -350,6 +351,7 @@ Examples of RDM repositories include [Zenodo](https://zenodo.org/), [Invenio RDM before running this workflow.
- + Workflow submission failed: {{ submissionError }}
-
+
+
The workflow cannot run because the current history is immutable. Please select a different history diff --git a/client/src/components/Workflow/WorkflowNavigationTitle.test.ts b/client/src/components/Workflow/WorkflowNavigationTitle.test.ts index 75dfa51431a9..c1c8d7ac6ebc 100644 --- a/client/src/components/Workflow/WorkflowNavigationTitle.test.ts +++ b/client/src/components/Workflow/WorkflowNavigationTitle.test.ts @@ -66,17 +66,13 @@ const localVue = getLocalVue(); * @param version The version of the component to mount (`run_form` or `invocation` view) * @param ownsWorkflow Whether the user owns the workflow associated with the invocation * @param unimportableWorkflow Whether the workflow import should fail - * @returns The wrapper object, and the mockRouter object + * @returns The wrapper object */ async function mountWorkflowNavigationTitle( version: "run_form" | "invocation", ownsWorkflow = true, unimportableWorkflow = false ) { - const mockRouter = { - push: jest.fn(), - }; - let workflowId: string; let invocation; if (version === "invocation") { @@ -96,9 +92,6 @@ async function mountWorkflowNavigationTitle( workflowId, }, localVue, - mocks: { - $router: mockRouter, - }, pinia: createTestingPinia(), }); @@ -107,7 +100,7 @@ async function mountWorkflowNavigationTitle( username: ownsWorkflow ? WORKFLOW_OWNER : OTHER_USER, }); - return { wrapper, mockRouter }; + return { wrapper }; } describe("WorkflowNavigationTitle renders", () => { @@ -116,10 +109,9 @@ describe("WorkflowNavigationTitle renders", () => { const heading = wrapper.find(SELECTORS.WORKFLOW_HEADING); expect(heading.text()).toContain(`Invoked Workflow: ${SAMPLE_WORKFLOW.name}`); - expect(heading.text()).toContain(`(version: ${SAMPLE_WORKFLOW.version + 1})`); + expect(heading.text()).toContain(`(Version: ${SAMPLE_WORKFLOW.version + 1})`); - const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP); - const runButton = actionsGroup.find(SELECTORS.ROUTE_TO_RUN_BUTTON); + const runButton = wrapper.find(SELECTORS.ROUTE_TO_RUN_BUTTON); expect(runButton.attributes("title")).toContain("Rerun"); expect(runButton.attributes("title")).toContain(SAMPLE_WORKFLOW.name); }); @@ -129,24 +121,19 @@ describe("WorkflowNavigationTitle renders", () => { const heading = wrapper.find(SELECTORS.WORKFLOW_HEADING); expect(heading.text()).toContain(`Workflow: ${SAMPLE_WORKFLOW.name}`); - expect(heading.text()).toContain(`(version: ${SAMPLE_WORKFLOW.version + 1})`); + expect(heading.text()).toContain(`(Version: ${SAMPLE_WORKFLOW.version + 1})`); - const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP); - const runButton = actionsGroup.find(SELECTORS.EXECUTE_WORKFLOW_BUTTON); + const runButton = wrapper.find(SELECTORS.EXECUTE_WORKFLOW_BUTTON); expect(runButton.attributes("title")).toContain("Run"); }); it("edit button if user owns the workflow", async () => { async function findAndClickEditButton(version: "invocation" | "run_form") { - const { wrapper, mockRouter } = await mountWorkflowNavigationTitle(version); + const { wrapper } = await mountWorkflowNavigationTitle(version); const actionsGroup = wrapper.find(SELECTORS.ACTIONS_BUTTON_GROUP); const editButton = actionsGroup.find(SELECTORS.EDIT_WORKFLOW_BUTTON); - await editButton.trigger("click"); - await flushPromises(); - - expect(mockRouter.push).toHaveBeenCalledTimes(1); - expect(mockRouter.push).toHaveBeenCalledWith( + expect(editButton.attributes("to")).toBe( `/workflows/edit?id=${SAMPLE_WORKFLOW.id}&version=${SAMPLE_WORKFLOW.version}` ); } diff --git a/client/src/components/Workflow/WorkflowNavigationTitle.vue b/client/src/components/Workflow/WorkflowNavigationTitle.vue index 5237d61e7e12..5f467c90fe53 100644 --- a/client/src/components/Workflow/WorkflowNavigationTitle.vue +++ b/client/src/components/Workflow/WorkflowNavigationTitle.vue @@ -5,7 +5,6 @@ import { BAlert, BButton, BButtonGroup } from "bootstrap-vue"; import { storeToRefs } from "pinia"; import { computed, ref } from "vue"; import { RouterLink } from "vue-router"; -import { useRouter } from "vue-router/composables"; import { isRegisteredUser } from "@/api"; import type { WorkflowInvocationElementView } from "@/api/invocations"; @@ -21,8 +20,6 @@ import AsyncButton from "../Common/AsyncButton.vue"; import ButtonSpinner from "../Common/ButtonSpinner.vue"; import WorkflowRunButton from "./WorkflowRunButton.vue"; -const router = useRouter(); - interface Props { invocation?: WorkflowInvocationElementView; workflowId: string; @@ -98,16 +95,14 @@ const workflowImportTitle = computed(() => { {{ error }} -
-
-
+
+
+
-
- - - {{ props.invocation ? "Invoked " : "" }}Workflow: {{ getWorkflowName() }} - - (version: {{ workflow.version + 1 }}) +
+ + {{ props.invocation ? "Invoked " : "" }}Workflow: {{ getWorkflowName() }} + (Version: {{ workflow.version + 1 }})
@@ -122,7 +117,7 @@ const workflowImportTitle = computed(() => { " variant="link" :disabled="workflow.deleted" - @click="router.push(`/workflows/edit?id=${workflow.id}&version=${workflow.version}`)"> + :to="`/workflows/edit?id=${workflow.id}&version=${workflow.version}`"> { - - - + +
diff --git a/client/src/components/Workflow/WorkflowRunButton.test.ts b/client/src/components/Workflow/WorkflowRunButton.test.ts index 0aec407666a4..431e8861a964 100644 --- a/client/src/components/Workflow/WorkflowRunButton.test.ts +++ b/client/src/components/Workflow/WorkflowRunButton.test.ts @@ -1,4 +1,5 @@ -import { mount } from "@vue/test-utils"; +import { mount, type Wrapper } from "@vue/test-utils"; +import flushPromises from "flush-promises"; import { getLocalVue } from "tests/jest/helpers"; import { generateRandomString } from "./testUtils"; @@ -11,22 +12,69 @@ const WORKFLOW_ID = generateRandomString(); const WORKFLOW_VERSION = 1; const WORKFLOW_RUN_BUTTON_SELECTOR = `[data-workflow-run="${WORKFLOW_ID}"]`; -async function mountWorkflowRunButton(props?: { id: string; version: number; full?: boolean }) { +function getPath() { + return `/workflows/run?id=${WORKFLOW_ID}&version=${WORKFLOW_VERSION}`; +} + +async function mountWorkflowRunButton( + props?: { id: string; version: number; full?: boolean; force?: boolean }, + currentPath?: string +) { + const mockRouter = { + push: jest.fn(), + afterEach: jest.fn(), + currentRoute: { + fullPath: currentPath || getPath(), + }, + }; + const wrapper = mount(WorkflowRunButton as object, { propsData: { ...props }, localVue, + mocks: { + $router: mockRouter, + }, }); - return { wrapper }; + return { wrapper, mockRouter }; +} + +async function clickButton(button: Wrapper) { + // Remove the href and target attributes to prevent navigation error + // This is done because the `BButton` has a `:to` prop as well as an `@click` event + button.element.setAttribute("href", "javascript:void(0)"); + button.element.setAttribute("target", ""); + + await button.trigger("click"); + await flushPromises(); } describe("WorkflowRunButton.vue", () => { - it("should render button with icon and route", async () => { - const { wrapper } = await mountWorkflowRunButton({ id: WORKFLOW_ID, version: WORKFLOW_VERSION }); + it("should render button with icon and route to it", async () => { + const { wrapper, mockRouter } = await mountWorkflowRunButton({ id: WORKFLOW_ID, version: WORKFLOW_VERSION }); const button = wrapper.find(WORKFLOW_RUN_BUTTON_SELECTOR); expect(button.attributes("title")).toBe("Run workflow"); expect(button.text()).toBe(""); - expect(button.attributes("href")).toBe(`/workflows/run?id=${WORKFLOW_ID}&version=${WORKFLOW_VERSION}`); + expect(button.attributes("href")).toBe(getPath()); + + await clickButton(button); + + // Check that router.push was called with the correct arguments + expect(mockRouter.push).toHaveBeenCalledWith(getPath()); + }); + + it("should force route if on the same path", async () => { + const { wrapper, mockRouter } = await mountWorkflowRunButton( + { id: WORKFLOW_ID, version: WORKFLOW_VERSION, force: true }, + getPath() + ); + + const button = wrapper.find(WORKFLOW_RUN_BUTTON_SELECTOR); + + await clickButton(button); + + // Check that router.push was called with the correct arguments + expect(mockRouter.push).toHaveBeenCalledWith(getPath(), { force: true }); }); }); diff --git a/client/src/components/Workflow/WorkflowRunButton.vue b/client/src/components/Workflow/WorkflowRunButton.vue index f5e26c981da9..7a3f94b79486 100644 --- a/client/src/components/Workflow/WorkflowRunButton.vue +++ b/client/src/components/Workflow/WorkflowRunButton.vue @@ -3,6 +3,7 @@ import { faPlay } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BButton } from "bootstrap-vue"; import { computed } from "vue"; +import { useRoute, useRouter } from "vue-router/composables"; interface Props { id: string; @@ -20,13 +21,22 @@ const props = withDefaults(defineProps(), { variant: "primary", }); +const route = useRoute(); +const router = useRouter(); + const runPath = computed( () => `/workflows/run?id=${props.id}${props.version !== undefined ? `&version=${props.version}` : ""}` ); -function forceRunPath() { - if (props.force) { - window.open(runPath.value); +function routeToPath() { + if (props.force && route.fullPath === runPath.value) { + // vue-router 4 supports a native force push with clean URLs, + // but we're using a __vkey__ bit as a workaround + // Only conditionally force to keep urls clean most of the time. + // @ts-ignore - monkeypatched router, drop with migration. + router.push(runPath.value, { force: true }); + } else { + router.push(runPath.value); } } @@ -42,7 +52,7 @@ function forceRunPath() { class="text-decoration-none" :disabled="disabled" :to="runPath" - @click="forceRunPath"> + @click="routeToPath"> Run diff --git a/client/src/components/WorkflowInvocationState/VegaWrapper.vue b/client/src/components/WorkflowInvocationState/VegaWrapper.vue index e5f4a8f49ba8..8a46fec2fd0a 100644 --- a/client/src/components/WorkflowInvocationState/VegaWrapper.vue +++ b/client/src/components/WorkflowInvocationState/VegaWrapper.vue @@ -1,17 +1,27 @@ - - diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue index 91782651b709..ff06ca1adf7d 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationMetrics.vue @@ -1,21 +1,26 @@ diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue index d076e639daf0..fb79d5bb3d9e 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationOverview.vue @@ -15,7 +15,6 @@ import InvocationMessage from "@/components/WorkflowInvocationState/InvocationMe interface Props { invocation: WorkflowInvocationElementView; invocationAndJobTerminal: boolean; - invocationSchedulingTerminal: boolean; isFullPage?: boolean; isSubworkflow?: boolean; } @@ -62,7 +61,6 @@ const uniqueMessages = computed(() => { :invocation="invocation" :workflow="workflow" :is-terminal="invocationAndJobTerminal" - :is-scheduled="invocationSchedulingTerminal" :is-full-page="isFullPage" :show-minimap="isFullPage" />
diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationState.test.ts b/client/src/components/WorkflowInvocationState/WorkflowInvocationState.test.ts index 84abb4dd163e..16eeb0808b6b 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationState.test.ts +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationState.test.ts @@ -16,6 +16,7 @@ const selectors = { bAlertStub: "balert-stub", spanElement: "span", invocationReportTab: '[titleitemclass="invocation-report-tab"]', + invocationExportTab: '[titleitemclass="invocation-export-tab"]', fullPageHeading: "anonymous-stub[h1='true']", }; @@ -177,16 +178,20 @@ describe("WorkflowInvocationState check invocation and job terminal states", () }); }); -describe("WorkflowInvocationState check 'Report' tab disabled state and header", () => { - it("determines that 'Report' tab is disabled for non-terminal invocation", async () => { +describe("WorkflowInvocationState check 'Report' and 'Export' tab disabled state and header", () => { + it("for non-terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState("non-terminal-id"); const reportTab = wrapper.find(selectors.invocationReportTab); expect(reportTab.attributes("disabled")).toBe("true"); + const exportTab = wrapper.find(selectors.invocationExportTab); + expect(exportTab.attributes("disabled")).toBe("true"); }); - it("determines that 'Report' tab is not disabled for terminal invocation", async () => { + it("for terminal invocation", async () => { const wrapper = await mountWorkflowInvocationState(invocationData.id); const reportTab = wrapper.find(selectors.invocationReportTab); expect(reportTab.attributes("disabled")).toBeUndefined(); + const exportTab = wrapper.find(selectors.invocationExportTab); + expect(exportTab.attributes("disabled")).toBeUndefined(); }); }); diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue b/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue index 839dda0cf7bb..741702b1cdc2 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationState.vue @@ -10,7 +10,6 @@ import { useInvocationStore } from "@/stores/invocationStore"; import { useWorkflowStore } from "@/stores/workflowStore"; import { errorMessageAsString } from "@/utils/simple-error"; -import { cancelWorkflowScheduling } from "./services"; import { errorCount as jobStatesSummaryErrorCount, isTerminal, @@ -53,6 +52,7 @@ const stepStatesInterval = ref(undefined); const jobStatesInterval = ref(undefined); const invocationLoaded = ref(false); const errorMessage = ref(null); +const cancellingInvocation = ref(false); // after the report tab is first activated, no longer lazy-render it from then on const reportActive = ref(false); @@ -67,22 +67,22 @@ watch( ); const workflowStore = useWorkflowStore(); -const reportTabDisabled = computed( +const tabsDisabled = computed( () => !invocationStateSuccess.value || !invocation.value || !workflowStore.getStoredWorkflowByInstanceId(invocation.value.workflow_id) ); -/** Tooltip message for the report tab when it is disabled */ -const disabledReportTooltip = computed(() => { +/** Tooltip message for the a tab when it is disabled */ +const disabledTabTooltip = computed(() => { const state = invocationState.value; if (state != "scheduled") { - return `This workflow is not currently scheduled. The current state is ${state}. Once the workflow is fully scheduled and jobs have complete this option will become available.`; + return `This workflow is not currently scheduled. The current state is ${state}. Once the workflow is fully scheduled and jobs have complete any disabled tabs will become available.`; } else if (runningCount.value != 0) { - return `The workflow invocation still contains ${runningCount.value} running job(s). Once these jobs have completed this option will become available.`; + return `The workflow invocation still contains ${runningCount.value} running job(s). Once these jobs have completed any disabled tabs will become available.`; } else { - return "Steps for this workflow are still running. A report will be available once complete."; + return "Steps for this workflow are still running. Any disabled tabs will be available once complete."; } }); @@ -199,6 +199,16 @@ watch( const storeId = computed(() => (invocation.value ? `invocation-${invocation.value.id}` : undefined)); +watch( + () => invocationSchedulingTerminal.value, + async (newVal, oldVal) => { + if (oldVal && !newVal) { + // If the invocation was terminal and now is not, start polling again + await pollStepStatesUntilTerminal(); + } + } +); + onUnmounted(() => { clearTimeout(stepStatesInterval.value); clearTimeout(jobStatesInterval.value); @@ -219,11 +229,16 @@ async function pollJobStatesUntilTerminal() { function onError(e: any) { console.error(e); } -function onCancel() { - emit("invocation-cancelled"); -} -function cancelWorkflowSchedulingLocal() { - cancelWorkflowScheduling(props.invocationId).then(onCancel).catch(onError); +async function onCancel() { + try { + cancellingInvocation.value = true; + await invocationStore.cancelWorkflowScheduling(props.invocationId); + } catch (e) { + onError(e); + } finally { + emit("invocation-cancelled"); + cancellingInvocation.value = false; + } } @@ -243,6 +258,7 @@ function cancelWorkflowSchedulingLocal() { size="sm" class="text-decoration-none" variant="link" + :disabled="cancellingInvocation || invocationState == 'cancelling'" @click="onCancel"> Cancel @@ -301,9 +317,7 @@ function cancelWorkflowSchedulingLocal() { :invocation="invocation" :is-full-page="props.isFullPage" :invocation-and-job-terminal="invocationAndJobTerminal" - :invocation-scheduling-terminal="invocationSchedulingTerminal" - :is-subworkflow="isSubworkflow" - @invocation-cancelled="cancelWorkflowSchedulingLocal" /> + :is-subworkflow="isSubworkflow" /> --> - - +
- - -
- +
@@ -373,9 +385,10 @@ function cancelWorkflowSchedulingLocal() { diff --git a/client/src/components/WorkflowInvocationState/services.js b/client/src/components/WorkflowInvocationState/services.js deleted file mode 100644 index 759354aa513d..000000000000 --- a/client/src/components/WorkflowInvocationState/services.js +++ /dev/null @@ -1,14 +0,0 @@ -import axios from "axios"; -import { getRootFromIndexLink } from "onload"; - -const getUrl = (path) => getRootFromIndexLink() + path; - -export function getInvocationJobsSummary(invocationId) { - const url = getUrl(`api/invocations/${invocationId}/jobs_summary`); - return axios.get(url); -} - -export function cancelWorkflowScheduling(invocationId) { - const url = getUrl(`api/invocations/${invocationId}`); - return axios.delete(url); -} diff --git a/client/src/composables/shortTermStorageMonitor.test.ts b/client/src/composables/shortTermStorageMonitor.test.ts index 8cdf1fb8b0ba..201992813c76 100644 --- a/client/src/composables/shortTermStorageMonitor.test.ts +++ b/client/src/composables/shortTermStorageMonitor.test.ts @@ -54,7 +54,6 @@ describe("useShortTermStorageMonitor", () => { it("should indicate the task status request failed when the request failed", async () => { suppressDebugConsole(); // expected API failure - const { waitForTask, requestHasFailed, isRunning, isCompleted, taskStatus } = useShortTermStorageMonitor(); expect(requestHasFailed.value).toBe(false); @@ -94,7 +93,6 @@ describe("useShortTermStorageMonitor", () => { it("should indicate is final state when the task has failed", async () => { suppressDebugConsole(); // expected API failure - const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, taskStatus } = useShortTermStorageMonitor(); diff --git a/client/src/composables/useInvocationGraph.ts b/client/src/composables/useInvocationGraph.ts index 89e3570ce459..211db6cd7979 100644 --- a/client/src/composables/useInvocationGraph.ts +++ b/client/src/composables/useInvocationGraph.ts @@ -69,7 +69,7 @@ export const statePlaceholders: Record = { }; /** Only one job needs to be in one of these states for the graph step to be in that state */ -const SINGLE_INSTANCE_STATES = ["error", "running", "paused"]; +const SINGLE_INSTANCE_STATES = ["error", "running", "paused", "deleting"]; /** All jobs need to be in one of these states for the graph step to be in that state */ const ALL_INSTANCES_STATES = ["deleted", "skipped", "new", "queued"]; @@ -293,6 +293,9 @@ export function useInvocationGraph( function getStepStateFromJobStates(jobStates: string[]): GraphStep["state"] | undefined { for (const state of SINGLE_INSTANCE_STATES) { if (jobStates.includes(state)) { + if (state === "deleting") { + return "deleted"; + } return state as GraphStep["state"]; } } diff --git a/client/src/entry/analysis/modules/WorkflowEditor.vue b/client/src/entry/analysis/modules/WorkflowEditor.vue index fb8ed6eea9d5..67634f4a3842 100644 --- a/client/src/entry/analysis/modules/WorkflowEditor.vue +++ b/client/src/entry/analysis/modules/WorkflowEditor.vue @@ -7,7 +7,6 @@ :initial-version="editorConfig.initialVersion" :module-sections="editorConfig.moduleSections" :workflow-tags="editorConfig.tags" - :workflows="editorConfig.workflows" @update:confirmation="$emit('update:confirmation', $event)" />