mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #19294 from ahmedhamidawan/simplified_wf_run
Workflow Run Form Enhancements
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
+65
-27
@@ -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<Props>();
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BModal
|
||||
<component
|
||||
:is="props.notModal ? 'div' : BModal"
|
||||
id="collection-creator-modal"
|
||||
v-model="localShowToggle"
|
||||
:busy="(fromSelection && isFetchingItems) || creatingCollection"
|
||||
@@ -245,7 +271,7 @@ function resetModal() {
|
||||
ok-only
|
||||
:ok-title="localize('Exit')"
|
||||
ok-variant="secondary"
|
||||
@hidden="resetModal">
|
||||
@hidden="resetCreator">
|
||||
<template v-slot:modal-header>
|
||||
<Heading class="w-100" size="sm">
|
||||
<div class="d-flex justify-content-between unselectable w-100">
|
||||
@@ -267,23 +293,32 @@ function resetModal() {
|
||||
</BAlert>
|
||||
<BAlert v-else-if="createCollectionError" variant="danger" show>
|
||||
{{ createCollectionError }}
|
||||
<BLink class="text-decoration-none" @click.stop.prevent="resetModal">
|
||||
<BLink class="text-decoration-none" @click.stop.prevent="resetCreator">
|
||||
<FontAwesomeIcon :icon="faUndo" fixed-width />
|
||||
{{ localize("Try again") }}
|
||||
</BLink>
|
||||
</BAlert>
|
||||
<div v-else-if="createdCollection">
|
||||
<BAlert variant="success" show>
|
||||
<FontAwesomeIcon :icon="faCheckCircle" class="text-success" fixed-width />
|
||||
{{ localize("Collection created successfully.") }}
|
||||
<BLink v-if="!fromSelection" class="text-decoration-none" @click.stop.prevent="resetModal">
|
||||
<FontAwesomeIcon :icon="faUndo" fixed-width />
|
||||
{{ localize("Create another collection") }}
|
||||
</BLink>
|
||||
<BAlert v-if="!createdCollectionInReadyState" variant="info" show>
|
||||
<LoadingSpan :message="localize('Waiting for collection to be ready')" />
|
||||
</BAlert>
|
||||
<template v-else>
|
||||
<BAlert variant="success" show>
|
||||
<FontAwesomeIcon :icon="faCheckCircle" class="text-success" fixed-width />
|
||||
{{ localize("Collection created successfully.") }}
|
||||
{{ localize("It might still not be a valid input based on individual element properties.") }}
|
||||
{{ localize("Expand the collection to see its individual elements.") }}
|
||||
<BLink v-if="!fromSelection" class="text-decoration-none" @click.stop.prevent="resetCreator">
|
||||
<FontAwesomeIcon :icon="faUndo" fixed-width />
|
||||
{{ localize("Create another collection") }}
|
||||
</BLink>
|
||||
</BAlert>
|
||||
|
||||
<!-- TODO: This is a bit shady, better if we confirm it is a collection type -->
|
||||
<GenericItem :item-id="createdCollection.id" item-src="hdca" />
|
||||
<GenericItem
|
||||
v-if="createdCollection.history_content_type === 'dataset_collection'"
|
||||
:item-id="createdCollection.id"
|
||||
item-src="hdca" />
|
||||
</template>
|
||||
</div>
|
||||
<ListCollectionCreator
|
||||
v-else-if="props.collectionType === 'list'"
|
||||
@@ -292,8 +327,9 @@ function resetModal() {
|
||||
:default-hide-source-items="props.defaultHideSourceItems"
|
||||
:from-selection="fromSelection"
|
||||
:extensions="props.extensions"
|
||||
:suggested-name="props.suggestedName"
|
||||
@clicked-create="createListCollection"
|
||||
@on-cancel="hideModal" />
|
||||
@on-cancel="hideCreator" />
|
||||
<PairedListCollectionCreator
|
||||
v-else-if="props.collectionType === 'list:paired'"
|
||||
:history-id="props.historyId"
|
||||
@@ -301,8 +337,9 @@ function resetModal() {
|
||||
:default-hide-source-items="props.defaultHideSourceItems"
|
||||
:from-selection="fromSelection"
|
||||
:extensions="props.extensions"
|
||||
:suggested-name="props.suggestedName"
|
||||
@clicked-create="createListPairedCollection"
|
||||
@on-cancel="hideModal" />
|
||||
@on-cancel="hideCreator" />
|
||||
<PairCollectionCreator
|
||||
v-else-if="props.collectionType === 'paired'"
|
||||
:history-id="props.historyId"
|
||||
@@ -310,9 +347,10 @@ function resetModal() {
|
||||
:default-hide-source-items="props.defaultHideSourceItems"
|
||||
:from-selection="fromSelection"
|
||||
:extensions="props.extensions"
|
||||
:suggested-name="props.suggestedName"
|
||||
@clicked-create="createPairedCollection"
|
||||
@on-cancel="hideModal" />
|
||||
</BModal>
|
||||
@on-cancel="hideCreator" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { HDASummary } from "@/api";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import DatasetCollectionElementView from "./ListDatasetCollectionElementView.vue";
|
||||
|
||||
interface Props {
|
||||
element?: HDASummary;
|
||||
hasActions?: boolean;
|
||||
|
||||
@@ -26,6 +26,7 @@ interface Props {
|
||||
historyId: string;
|
||||
initialElements: HistoryItemSummary[];
|
||||
defaultHideSourceItems?: boolean;
|
||||
suggestedName?: string;
|
||||
fromSelection?: boolean;
|
||||
extensions?: string[];
|
||||
}
|
||||
@@ -441,6 +442,7 @@ function selectionAsHdaSummary(value: any): HDASummary {
|
||||
collection-type="list"
|
||||
:no-items="props.initialElements.length == 0 && !props.fromSelection"
|
||||
:show-upload="!fromSelection"
|
||||
:suggested-name="props.suggestedName"
|
||||
@add-uploaded-files="addUploadedFiles"
|
||||
@on-update-datatype-toggle="changeDatatypeFilter"
|
||||
@onUpdateHideSourceItems="onUpdateHideSourceItems"
|
||||
|
||||
@@ -31,6 +31,7 @@ interface Props {
|
||||
historyId: string;
|
||||
initialElements: HistoryItemSummary[];
|
||||
defaultHideSourceItems?: boolean;
|
||||
suggestedName?: string;
|
||||
fromSelection?: boolean;
|
||||
extensions?: string[];
|
||||
}
|
||||
@@ -44,7 +45,7 @@ const emit = defineEmits<{
|
||||
|
||||
const state = ref("build");
|
||||
const removeExtensions = ref(true);
|
||||
const initialSuggestedName = ref("");
|
||||
const initialSuggestedName = ref(props.suggestedName);
|
||||
const invalidElements = ref<string[]>([]);
|
||||
const workingElements = ref<HDASummary[]>([]);
|
||||
const filterText = ref("");
|
||||
@@ -259,15 +260,34 @@ function addUploadedFiles(files: HDASummary[]) {
|
||||
// Any added files are added to workingElements in _elementsSetUp
|
||||
// The user will have to manually select the files to add them to the pair
|
||||
|
||||
// Check for validity of uploads
|
||||
let alreadyPopulated = false;
|
||||
|
||||
// Check for validity of uploads, and add them to the pair if space is available
|
||||
files.forEach((file) => {
|
||||
const problem = _isElementInvalid(file);
|
||||
if (problem) {
|
||||
const invalidMsg = `${file.hid}: ${file.name} ${problem} and ${NOT_VALID_ELEMENT_MSG}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Uploaded item invalid for pair"));
|
||||
const element = workingElements.value.find((e) => e.id === file.id);
|
||||
if (element) {
|
||||
const problem = _isElementInvalid(element);
|
||||
if (problem) {
|
||||
const invalidMsg = `${element.hid}: ${element.name} ${problem} and ${NOT_VALID_ELEMENT_MSG}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Uploaded item invalid for pair"));
|
||||
} else if (!props.fromSelection) {
|
||||
if (inListElements.value.forward === undefined) {
|
||||
inListElements.value.forward = element;
|
||||
} else if (inListElements.value.reverse === undefined) {
|
||||
inListElements.value.reverse = element;
|
||||
} else if (!alreadyPopulated) {
|
||||
alreadyPopulated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (alreadyPopulated && files.length > 0) {
|
||||
Toast.info(
|
||||
localize("Forward and reverse datasets already selected. Uploaded files are available for replacement."),
|
||||
localize("Uploads Available for Replacement")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clickedCreate(collectionName: string) {
|
||||
|
||||
@@ -50,6 +50,7 @@ interface Props {
|
||||
historyId: string;
|
||||
initialElements: HistoryItemSummary[];
|
||||
defaultHideSourceItems?: boolean;
|
||||
suggestedName?: string;
|
||||
fromSelection?: boolean;
|
||||
extensions?: string[];
|
||||
}
|
||||
@@ -882,6 +883,7 @@ function _naiveStartingAndEndingLCS(s1: string, s2: string) {
|
||||
collection-type="list:paired"
|
||||
:no-items="props.initialElements.length == 0 && !props.fromSelection"
|
||||
:show-upload="!fromSelection"
|
||||
:suggested-name="props.suggestedName"
|
||||
@add-uploaded-files="addUploadedFiles"
|
||||
@onUpdateHideSourceItems="hideSourceItems = $event"
|
||||
@clicked-create="clickedCreate"
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
import { faPlus, faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BTab, BTabs } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { HDASummary } from "@/api";
|
||||
import type { CompositeFileInfo } from "@/api/datatypes";
|
||||
import { AUTO_EXTENSION, getUploadDatatypes } from "@/components/Upload/utils";
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import { COLLECTION_TYPE_TO_LABEL } from "@/components/History/adapters/buildCollectionModal";
|
||||
import { useUploadConfigurations } from "@/composables/uploadConfigurations";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import CollectionCreatorFooterButtons from "./CollectionCreatorFooterButtons.vue";
|
||||
@@ -25,15 +22,6 @@ const Tabs = {
|
||||
upload: 1,
|
||||
};
|
||||
|
||||
type ExtensionDetails = {
|
||||
id: string;
|
||||
text: string;
|
||||
description: string | null;
|
||||
description_url: string | null;
|
||||
composite_files?: CompositeFileInfo[] | null;
|
||||
upload_warning?: string | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
oncancel: () => void;
|
||||
historyId: string;
|
||||
@@ -66,60 +54,22 @@ const emit = defineEmits<{
|
||||
const currentTab = ref(Tabs.create);
|
||||
const collectionName = ref(props.suggestedName);
|
||||
const localHideSourceItems = ref(props.hideSourceItems);
|
||||
const listExtensions = ref<ExtensionDetails[]>([]);
|
||||
const extensionsSet = ref(false);
|
||||
|
||||
// Upload properties
|
||||
const {
|
||||
configOptions,
|
||||
effectiveExtensions,
|
||||
listDbKeys,
|
||||
ready: uploadReady,
|
||||
} = useUploadConfigurations(props.extensions);
|
||||
|
||||
const validInput = computed(() => {
|
||||
return collectionName.value.length > 0;
|
||||
});
|
||||
|
||||
// If there are props.extensions, filter the list of extensions to only include those
|
||||
const validExtensions = computed(() => {
|
||||
return listExtensions.value.filter((ext) => props.extensions?.includes(ext.id));
|
||||
});
|
||||
|
||||
// Upload properties
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
|
||||
const configOptions = computed(() =>
|
||||
isConfigLoaded.value
|
||||
? {
|
||||
chunkUploadSize: config.value.chunk_upload_size,
|
||||
fileSourcesConfigured: config.value.file_sources_configured,
|
||||
ftpUploadSite: config.value.ftp_upload_site,
|
||||
defaultDbKey: config.value.default_genome || "",
|
||||
defaultExtension: config.value.default_extension || "",
|
||||
}
|
||||
: {}
|
||||
);
|
||||
|
||||
const ftpUploadSite = computed(() =>
|
||||
currentUser.value && "id" in currentUser.value ? configOptions.value.ftpUploadSite : null
|
||||
);
|
||||
|
||||
const defaultExtension = computed(() => {
|
||||
if (!configOptions.value || !extensionsSet.value) {
|
||||
return "auto";
|
||||
} else if (!props.extensions?.length) {
|
||||
return configOptions.value.defaultExtension || "auto";
|
||||
} else {
|
||||
return props.extensions[0];
|
||||
}
|
||||
});
|
||||
|
||||
/** Plain language for what is being created */
|
||||
const shortWhatIsBeingCreated = computed<string>(() => {
|
||||
// plain language for what is being created
|
||||
if (props.collectionType === "list") {
|
||||
return "list";
|
||||
} else if (props.collectionType === "list:paired") {
|
||||
return "list of pairs";
|
||||
} else if (props.collectionType == "paired") {
|
||||
return "dataset pair";
|
||||
} else {
|
||||
return "collection";
|
||||
}
|
||||
return COLLECTION_TYPE_TO_LABEL[props.collectionType] || "collection";
|
||||
});
|
||||
|
||||
function addUploadedFiles(value: HDASummary[]) {
|
||||
@@ -136,13 +86,6 @@ function removeExtensionsToggle() {
|
||||
emit("remove-extensions-toggle");
|
||||
}
|
||||
|
||||
async function loadExtensions() {
|
||||
listExtensions.value = await getUploadDatatypes(false, AUTO_EXTENSION);
|
||||
extensionsSet.value = true;
|
||||
}
|
||||
|
||||
loadExtensions();
|
||||
|
||||
watch(
|
||||
() => localHideSourceItems.value,
|
||||
() => {
|
||||
@@ -233,18 +176,15 @@ watch(
|
||||
<span>{{ localize("Upload Files to Add to Collection") }}</span>
|
||||
</template>
|
||||
<DefaultBox
|
||||
v-if="configOptions && extensionsSet"
|
||||
:chunk-upload-size="configOptions.chunkUploadSize"
|
||||
:default-db-key="configOptions.defaultDbKey"
|
||||
:default-extension="defaultExtension"
|
||||
:effective-extensions="props.extensions?.length ? validExtensions : listExtensions"
|
||||
:file-sources-configured="configOptions.fileSourcesConfigured"
|
||||
:ftp-upload-site="ftpUploadSite"
|
||||
v-if="uploadReady && configOptions"
|
||||
:effective-extensions="effectiveExtensions"
|
||||
v-bind="configOptions"
|
||||
:has-callback="false"
|
||||
:history-id="historyId"
|
||||
:list-db-keys="[]"
|
||||
:list-db-keys="listDbKeys"
|
||||
disable-footer
|
||||
emit-uploaded
|
||||
size="sm"
|
||||
@uploaded="addUploadedFiles"
|
||||
@dismiss="currentTab = Tabs.create">
|
||||
<template v-slot:footer>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { useFileSources } from "@/composables/fileSources";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
import { USER_FILE_PREFIX } from "@/utils/upload-payload";
|
||||
import { USER_FILE_PREFIX } from "@/utils/url";
|
||||
|
||||
import { Model } from "./model";
|
||||
|
||||
|
||||
@@ -1,40 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faExclamation,
|
||||
faLink,
|
||||
faPlus,
|
||||
faSpinner,
|
||||
faUnlink,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faExclamation, faLink, faUnlink } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton, BButtonGroup, BCollapse, BFormCheckbox, BTooltip } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { BAlert, BFormCheckbox } from "bootstrap-vue";
|
||||
import { computed, onMounted, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { isDatasetElement, isDCE } from "@/api";
|
||||
import { getGalaxyInstance } from "@/app";
|
||||
import type { CollectionType } from "@/components/History/adapters/buildCollectionModal";
|
||||
import { useDatatypesMapper } from "@/composables/datatypesMapper";
|
||||
import { useUid } from "@/composables/utils/uid";
|
||||
import { type EventData, useEventStore } from "@/stores/eventStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { orList } from "@/utils/strings";
|
||||
|
||||
import type { DataOption } from "./types";
|
||||
import { BATCH, SOURCE, VARIANTS } from "./variants";
|
||||
|
||||
import FormSelection from "../FormSelection.vue";
|
||||
import CollectionCreatorModal from "@/components/Collections/CollectionCreatorModal.vue";
|
||||
import FormDataContextButtons from "./FormDataContextButtons.vue";
|
||||
import FormDataExtensions from "./FormDataExtensions.vue";
|
||||
import FormDataWorkflowRunTabs from "./FormDataWorkflowRunTabs.vue";
|
||||
import FormSelect from "@/components/Form/Elements/FormSelect.vue";
|
||||
import HelpText from "@/components/Help/HelpText.vue";
|
||||
|
||||
const COLLECTION_TYPE_TO_LABEL: Record<string, string> = {
|
||||
list: "list",
|
||||
"list:paired": "list of dataset pairs",
|
||||
paired: "dataset pair",
|
||||
};
|
||||
|
||||
type SelectOption = {
|
||||
label: string;
|
||||
value: DataOption | null;
|
||||
@@ -51,9 +38,11 @@ const props = withDefaults(
|
||||
};
|
||||
extensions?: Array<string>;
|
||||
type?: "data" | "data_collection";
|
||||
collectionTypes?: Array<string>;
|
||||
collectionTypes?: Array<CollectionType>;
|
||||
flavor?: string;
|
||||
tag?: string;
|
||||
userDefinedTitle?: string;
|
||||
workflowRun?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
@@ -65,13 +54,14 @@ const props = withDefaults(
|
||||
collectionTypes: undefined,
|
||||
flavor: undefined,
|
||||
tag: undefined,
|
||||
userDefinedTitle: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const eventStore = useEventStore();
|
||||
const { datatypesMapper } = useDatatypesMapper();
|
||||
|
||||
const $emit = defineEmits(["input", "alert"]);
|
||||
const $emit = defineEmits(["input", "alert", "focus"]);
|
||||
|
||||
// Determines wether values should be processed as linked or unlinked
|
||||
const currentLinked = ref(true);
|
||||
@@ -86,10 +76,8 @@ const currentHighlighting: Ref<string | null> = ref(null);
|
||||
const dragData: Ref<EventData | null> = ref(null);
|
||||
const dragTarget: Ref<EventTarget | null> = ref(null);
|
||||
|
||||
// Collection creator modal settings
|
||||
const collectionModalShow = ref(false);
|
||||
const collectionModalType = ref<"list" | "list:paired" | "paired">("list");
|
||||
const { currentHistoryId } = storeToRefs(useHistoryStore());
|
||||
const workflowTab = ref("");
|
||||
|
||||
const restrictsExtensions = computed(() => {
|
||||
const extensions = props.extensions;
|
||||
if (!extensions || extensions.length == 0 || extensions.indexOf("data") >= 0) {
|
||||
@@ -212,7 +200,13 @@ const formattedOptions = computed(() => {
|
||||
// Populate keep-options from cache
|
||||
Object.entries(keepOptions).forEach(([key, option]) => {
|
||||
if (option.value && getSourceType(option.value) === currentSource.value) {
|
||||
result.unshift(option);
|
||||
// check if option (with same id) is already in result, if yes replace it with keepOption
|
||||
const existingOptionIndex = result.findIndex((v) => v.value?.id === option.value?.id);
|
||||
if (existingOptionIndex >= 0) {
|
||||
result[existingOptionIndex] = option;
|
||||
} else {
|
||||
result.unshift(option);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Sort entries by hid
|
||||
@@ -253,6 +247,16 @@ const variant = computed(() => {
|
||||
return VARIANTS[variantKey];
|
||||
});
|
||||
|
||||
const formSelectionRef = ref<InstanceType<typeof FormSelection> | null>(null);
|
||||
/**
|
||||
* Determines if the `FormSelection` field is a simple select or a column select field
|
||||
*/
|
||||
const usingSimpleSelect = computed(
|
||||
() =>
|
||||
!formSelectionRef.value ||
|
||||
("displayMany" in formSelectionRef.value && formSelectionRef.value.displayMany === false)
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears highlighting with delay
|
||||
*/
|
||||
@@ -267,7 +271,11 @@ function clearHighlighting(timeout = 1000) {
|
||||
*/
|
||||
function createValue(val?: Array<DataOption> | DataOption | null) {
|
||||
if (val) {
|
||||
const values = Array.isArray(val) ? val : [val];
|
||||
let values = Array.isArray(val) ? val : [val];
|
||||
|
||||
// Remove duplicates based on item.id
|
||||
values = values.filter((value, index, self) => index === self.findIndex((v) => v.id === value.id));
|
||||
|
||||
if (variant.value && values.length > 0 && values[0]) {
|
||||
const hasMapOverType = values.find((v) => !!v.map_over_type);
|
||||
const isMultiple = values.length > 1;
|
||||
@@ -455,7 +463,7 @@ function canAcceptDatatype(itemDatatypes: string | Array<string>) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function canAcceptSrc(historyContentType: "dataset" | "dataset_collection", collectionType?: string) {
|
||||
function canAcceptSrc(historyContentType: "dataset" | "dataset_collection", collectionType?: CollectionType) {
|
||||
if (historyContentType === "dataset") {
|
||||
// HDA can only be fed into data parameters, not collection parameters
|
||||
if (props.type === "data") {
|
||||
@@ -501,40 +509,47 @@ function canAcceptSrc(historyContentType: "dataset" | "dataset_collection", coll
|
||||
const effectiveCollectionTypes = props.collectionTypes?.filter((collectionType) =>
|
||||
["list", "list:paired", "paired"].includes(collectionType)
|
||||
);
|
||||
|
||||
function buildNewCollection(collectionType: string) {
|
||||
if (!["list", "list:paired", "paired"].includes(collectionType)) {
|
||||
throw Error(`Unknown collection type: ${collectionType}`);
|
||||
}
|
||||
collectionModalType.value = collectionType as "list" | "list:paired" | "paired";
|
||||
collectionModalShow.value = true;
|
||||
}
|
||||
|
||||
function createdCollection(collection: any) {
|
||||
handleIncoming(collection);
|
||||
}
|
||||
const currentCollectionTypeTab = ref(effectiveCollectionTypes?.[0]);
|
||||
|
||||
// Drag/Drop event handlers
|
||||
function onDragEnter(evt: MouseEvent) {
|
||||
function onDragEnter(evt: DragEvent) {
|
||||
const eventData = eventStore.getDragData();
|
||||
if (eventData) {
|
||||
const extensions = (eventData.extension as string) || (eventData.elements_datatypes as Array<string>);
|
||||
let eventFiles: any[];
|
||||
if (eventStore.multipleDragData) {
|
||||
eventFiles = Object.values(eventData);
|
||||
} else {
|
||||
eventFiles = [eventData];
|
||||
}
|
||||
|
||||
let highlightingState = "success";
|
||||
if (!canAcceptDatatype(extensions)) {
|
||||
highlightingState = "warning";
|
||||
$emit("alert", `${extensions} is not an acceptable format for this parameter.`);
|
||||
} else if (
|
||||
!canAcceptSrc(
|
||||
eventData.history_content_type as "dataset" | "dataset_collection",
|
||||
eventData.collection_type as string
|
||||
)
|
||||
) {
|
||||
highlightingState = "warning";
|
||||
$emit("alert", `${eventData.history_content_type} is not an acceptable input type for this parameter.`);
|
||||
for (const eventFile of eventFiles) {
|
||||
const extensions = (eventFile.extension as string) || (eventFile.elements_datatypes as Array<string>);
|
||||
if (!canAcceptDatatype(extensions)) {
|
||||
highlightingState = "warning";
|
||||
$emit("alert", `${extensions} is not an acceptable format for this parameter.`);
|
||||
} else if (
|
||||
!canAcceptSrc(
|
||||
eventFile.history_content_type as "dataset" | "dataset_collection",
|
||||
eventFile.collection_type as CollectionType
|
||||
)
|
||||
) {
|
||||
highlightingState = "warning";
|
||||
$emit("alert", `${eventFile.history_content_type} is not an acceptable input type for this parameter.`);
|
||||
}
|
||||
}
|
||||
currentHighlighting.value = highlightingState;
|
||||
dragTarget.value = evt.target;
|
||||
dragData.value = eventData;
|
||||
} else if (props.workflowRun && canBrowse.value && evt.dataTransfer?.items) {
|
||||
// if any item in DataTransfer is a file
|
||||
const hasFiles = Array.from(evt.dataTransfer.items).some((item) => item.kind === "file");
|
||||
if (hasFiles) {
|
||||
currentHighlighting.value = "success";
|
||||
$emit("alert", "Drop files in the upload area below to create datasets.");
|
||||
workflowTab.value = "create";
|
||||
dragTarget.value = evt.target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +560,7 @@ function onDragLeave(evt: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop() {
|
||||
function onDrop(e: DragEvent) {
|
||||
if (dragData.value) {
|
||||
let accept = false;
|
||||
if (eventStore.multipleDragData) {
|
||||
@@ -555,12 +570,19 @@ function onDrop() {
|
||||
}
|
||||
if (accept) {
|
||||
currentHighlighting.value = "success";
|
||||
if (props.workflowRun) {
|
||||
workflowTab.value = "view";
|
||||
}
|
||||
} else {
|
||||
currentHighlighting.value = "warning";
|
||||
}
|
||||
$emit("alert", undefined);
|
||||
dragData.value = null;
|
||||
clearHighlighting();
|
||||
} else if (props.workflowRun && e.dataTransfer?.files?.length) {
|
||||
$emit("alert", undefined);
|
||||
dragData.value = null;
|
||||
clearHighlighting();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,118 +662,117 @@ const noOptionsWarningMessage = computed(() => {
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onDrop">
|
||||
<div class="d-flex flex-column">
|
||||
<BButtonGroup v-if="variant && variant.length > 1" buttons class="align-self-start">
|
||||
<BButton
|
||||
v-for="(v, index) in variant"
|
||||
:key="index"
|
||||
v-b-tooltip.hover.bottom
|
||||
:pressed="currentField === index"
|
||||
:title="v.tooltip"
|
||||
@click="currentField = index">
|
||||
<FontAwesomeIcon :icon="['far', v.icon]" />
|
||||
</BButton>
|
||||
<BButton v-if="canBrowse" v-b-tooltip.hover.bottom title="Browse or Upload Datasets" @click="onBrowse">
|
||||
<FontAwesomeIcon v-if="loading" :icon="faSpinner" spin />
|
||||
<span v-else class="font-weight-bold">...</span>
|
||||
</BButton>
|
||||
<BButtonGroup v-if="effectiveCollectionTypes?.length > 0" size="sm" buttons>
|
||||
<BButton
|
||||
v-for="collectionType in effectiveCollectionTypes"
|
||||
:key="collectionType"
|
||||
v-b-tooltip.hover.bottom
|
||||
:title="`Create a new ${COLLECTION_TYPE_TO_LABEL[collectionType]}`"
|
||||
:variant="formattedOptions.length === 0 ? 'warning' : 'secondary'"
|
||||
@click="buildNewCollection(collectionType)">
|
||||
<FontAwesomeIcon :icon="faPlus" fixed-width />
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</BButtonGroup>
|
||||
<div v-if="restrictsExtensions">
|
||||
<BButton :id="formatsButtonId" class="ui-link" @click="formatsVisible = !formatsVisible">
|
||||
accepted formats
|
||||
<FontAwesomeIcon v-if="formatsVisible" :icon="faCaretUp" />
|
||||
<FontAwesomeIcon v-else :icon="faCaretDown" />
|
||||
</BButton>
|
||||
<BCollapse v-model="formatsVisible">
|
||||
<ul class="pl-3 m-0">
|
||||
<li v-for="extension in extensions" :key="extension">{{ extension }}</li>
|
||||
</ul>
|
||||
</BCollapse>
|
||||
<BTooltip :target="formatsButtonId" noninteractive placement="bottom" triggers="hover">
|
||||
<div class="form-data-extensions-tooltip">
|
||||
<span v-for="extension in extensions" :key="extension">{{ extension }}</span>
|
||||
</div>
|
||||
</BTooltip>
|
||||
<div class="d-flex flex-gapx-1">
|
||||
<div class="d-flex flex-column">
|
||||
<FormDataContextButtons
|
||||
:class="{ 'field-options': props.workflowRun && !currentVariant?.multiple }"
|
||||
:variant="variant"
|
||||
:current-field="currentField"
|
||||
:can-browse="canBrowse"
|
||||
:loading="props.loading"
|
||||
:workflow-run="props.workflowRun"
|
||||
:collection-type="props.collectionTypes?.length ? props.collectionTypes[0] : undefined"
|
||||
:current-source="currentSource || undefined"
|
||||
:is-populated="currentValue && currentValue.length > 0"
|
||||
show-field-options
|
||||
:show-view-create-options="props.workflowRun && !usingSimpleSelect"
|
||||
:workflow-tab.sync="workflowTab"
|
||||
@create-collection-type="(value) => (currentCollectionTypeTab = value)"
|
||||
@on-browse="onBrowse"
|
||||
@set-current-field="(value) => (currentField = value)" />
|
||||
|
||||
<FormDataExtensions
|
||||
v-if="restrictsExtensions && !props.workflowRun"
|
||||
:extensions="props.extensions"
|
||||
:formats-button-id="formatsButtonId"
|
||||
:formats-visible.sync="formatsVisible" />
|
||||
</div>
|
||||
|
||||
<div class="w-100 d-flex flex-gapx-1">
|
||||
<FormSelect
|
||||
v-if="currentVariant && !currentVariant.multiple"
|
||||
v-model="currentValue"
|
||||
class="align-self-start w-100"
|
||||
:class="{ 'form-select': props.workflowRun }"
|
||||
:multiple="currentVariant.multiple"
|
||||
:optional="currentVariant.multiple || optional"
|
||||
:options="formattedOptions"
|
||||
:placeholder="`Select a ${placeholder}`">
|
||||
<template v-slot:no-options>
|
||||
<BAlert class="mb-0" variant="warning" show>
|
||||
{{ noOptionsWarningMessage }}
|
||||
</BAlert>
|
||||
</template>
|
||||
</FormSelect>
|
||||
<FormSelection
|
||||
v-else-if="currentVariant?.multiple"
|
||||
ref="formSelectionRef"
|
||||
v-model="currentValue"
|
||||
class="w-100"
|
||||
:data="formattedOptions"
|
||||
optional
|
||||
multiple />
|
||||
<FormDataContextButtons
|
||||
v-if="props.workflowRun && usingSimpleSelect"
|
||||
:class="{ 'h-100': !currentVariant?.multiple }"
|
||||
compact
|
||||
:collection-type="props.collectionTypes?.length ? props.collectionTypes[0] : undefined"
|
||||
:current-source="currentSource || undefined"
|
||||
:is-populated="currentValue && currentValue.length > 0"
|
||||
show-view-create-options
|
||||
:workflow-tab.sync="workflowTab"
|
||||
@create-collection-type="(value) => (currentCollectionTypeTab = value)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSelect
|
||||
v-if="currentVariant && !currentVariant.multiple"
|
||||
v-model="currentValue"
|
||||
class="align-self-start"
|
||||
:multiple="currentVariant.multiple"
|
||||
:optional="currentVariant.multiple || optional"
|
||||
:options="formattedOptions"
|
||||
:placeholder="`Select a ${placeholder}`">
|
||||
<template v-slot:no-options>
|
||||
<BAlert class="w-100 align-items-center" variant="warning" show>
|
||||
{{ noOptionsWarningMessage }}
|
||||
</BAlert>
|
||||
</template>
|
||||
</FormSelect>
|
||||
<FormSelection
|
||||
v-else-if="currentVariant?.multiple"
|
||||
v-model="currentValue"
|
||||
:data="formattedOptions"
|
||||
optional
|
||||
multiple />
|
||||
|
||||
<CollectionCreatorModal
|
||||
v-if="currentHistoryId && effectiveCollectionTypes?.length > 0"
|
||||
:history-id="currentHistoryId"
|
||||
:collection-type="collectionModalType"
|
||||
:extensions="props.extensions.filter((ext) => ext !== 'data')"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
@created-collection="createdCollection" />
|
||||
|
||||
<template v-if="currentVariant && currentVariant.batch !== BATCH.DISABLED">
|
||||
<BFormCheckbox
|
||||
v-if="currentVariant.batch === BATCH.ENABLED"
|
||||
v-model="currentLinked"
|
||||
class="checkbox no-highlight"
|
||||
switch>
|
||||
<span v-if="currentLinked">
|
||||
<FontAwesomeIcon :icon="faLink" />
|
||||
<b v-localize class="mr-1">Linked:</b>
|
||||
<span v-localize>Datasets will be run in matched order with other datasets.</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<FontAwesomeIcon :icon="faUnlink" />
|
||||
<b v-localize class="mr-1">Unlinked:</b>
|
||||
<span v-localize>Dataset will be run against *all* other datasets.</span>
|
||||
</span>
|
||||
</BFormCheckbox>
|
||||
<div class="info text-info">
|
||||
<FontAwesomeIcon :icon="faExclamation" />
|
||||
<span v-if="props.type == 'data' && currentVariant.src == SOURCE.COLLECTION" class="ml-1">
|
||||
The supplied input will be <HelpText text="mapped over" uri="galaxy.collections.mapOver" /> this
|
||||
tool.
|
||||
</span>
|
||||
<span v-else v-localize class="ml-1">
|
||||
This is a batch mode input field. Individual jobs will be triggered for each dataset.
|
||||
</span>
|
||||
<div :class="{ 'd-flex justify-content-between': props.workflowRun }">
|
||||
<div v-if="currentVariant && currentVariant.batch !== BATCH.DISABLED">
|
||||
<BFormCheckbox
|
||||
v-if="currentVariant.batch === BATCH.ENABLED"
|
||||
v-model="currentLinked"
|
||||
class="checkbox no-highlight"
|
||||
switch>
|
||||
<span v-if="currentLinked">
|
||||
<FontAwesomeIcon :icon="faLink" />
|
||||
<b v-localize class="mr-1">Linked:</b>
|
||||
<span v-localize>Datasets will be run in matched order with other datasets.</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<FontAwesomeIcon :icon="faUnlink" />
|
||||
<b v-localize class="mr-1">Unlinked:</b>
|
||||
<span v-localize>Dataset will be run against *all* other datasets.</span>
|
||||
</span>
|
||||
</BFormCheckbox>
|
||||
<div class="info text-info">
|
||||
<FontAwesomeIcon :icon="faExclamation" />
|
||||
<span v-if="props.type == 'data' && currentVariant.src == SOURCE.COLLECTION" class="ml-1">
|
||||
The supplied input will be <HelpText text="mapped over" uri="galaxy.collections.mapOver" /> this
|
||||
tool.
|
||||
</span>
|
||||
<span v-else v-localize class="ml-1">
|
||||
This is a batch mode input field. Individual jobs will be triggered for each dataset.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<FormDataWorkflowRunTabs
|
||||
v-if="props.workflowRun"
|
||||
class="mt-3"
|
||||
:current-value="currentValue"
|
||||
:current-variant="currentVariant"
|
||||
:can-browse="canBrowse"
|
||||
:extensions="props.extensions"
|
||||
:collection-type="currentCollectionTypeTab"
|
||||
:step-title="props.userDefinedTitle"
|
||||
:workflow-tab.sync="workflowTab"
|
||||
@focus="$emit('focus')"
|
||||
@uploaded-data="($event) => handleIncoming($event, !$event?.length || $event.length <= 1)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-data {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
|
||||
.checkbox {
|
||||
grid-column: span 2;
|
||||
}
|
||||
@@ -763,6 +784,23 @@ const noOptionsWarningMessage = computed(() => {
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// To ensure the field options, select field and the workflow run options are all the same height
|
||||
.form-data {
|
||||
.form-select {
|
||||
height: 100%;
|
||||
.multiselect,
|
||||
.multiselect__tags {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.field-options {
|
||||
height: 100%;
|
||||
.btn-group {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-data-extensions-tooltip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { faCaretDown, faEye, faPlus, faSpinner, faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BButton, BButtonGroup, BDropdown, BDropdownItem } from "bootstrap-vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
import { COLLECTION_TYPE_TO_LABEL, type CollectionType } from "@/components/History/adapters/buildCollectionModal";
|
||||
import { capitalizeFirstLetter } from "@/utils/strings";
|
||||
|
||||
import type { VariantInterface } from "./variants";
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: VariantInterface[];
|
||||
currentField?: number;
|
||||
canBrowse?: boolean;
|
||||
loading?: boolean;
|
||||
workflowRun?: boolean;
|
||||
workflowTab: string;
|
||||
compact?: boolean;
|
||||
collectionType?: string;
|
||||
currentSource?: string;
|
||||
isPopulated?: boolean;
|
||||
showFieldOptions?: boolean;
|
||||
showViewCreateOptions?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "on-browse"): void;
|
||||
(e: "set-current-field", value: number): void;
|
||||
(e: "update:workflow-tab", value: string): void;
|
||||
(e: "create-collection-type", value: CollectionType): void;
|
||||
}>();
|
||||
|
||||
const createTitle = computed(() => {
|
||||
return props.collectionType
|
||||
? `Create a new ${COLLECTION_TYPE_TO_LABEL[props.collectionType]}`
|
||||
: "Upload dataset(s)";
|
||||
});
|
||||
|
||||
function clickedTab(tab: string) {
|
||||
emit("update:workflow-tab", props.workflowTab === tab ? "" : tab);
|
||||
}
|
||||
|
||||
function createCollectionType(colType: string) {
|
||||
emit("create-collection-type", colType as CollectionType);
|
||||
emit("update:workflow-tab", "create");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BButtonGroup :vertical="!props.compact" buttons class="align-self-start">
|
||||
<BButtonGroup
|
||||
v-if="props.showFieldOptions && props.variant && props.variant.length > 1"
|
||||
buttons
|
||||
class="align-self-start">
|
||||
<BButton
|
||||
v-for="(v, index) in props.variant"
|
||||
:key="index"
|
||||
v-b-tooltip.hover.bottom
|
||||
:pressed="props.currentField === index"
|
||||
:title="v.tooltip"
|
||||
@click="emit('set-current-field', index)">
|
||||
<FontAwesomeIcon :icon="['far', v.icon]" />
|
||||
</BButton>
|
||||
<BButton
|
||||
v-if="props.canBrowse && !props.workflowRun"
|
||||
v-b-tooltip.hover.bottom
|
||||
title="Browse or Upload Datasets"
|
||||
@click="emit('on-browse')">
|
||||
<FontAwesomeIcon v-if="props.loading" :icon="faSpinner" spin />
|
||||
<span v-else class="font-weight-bold">...</span>
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
<BButton
|
||||
v-if="props.showViewCreateOptions && props.isPopulated"
|
||||
v-b-tooltip.bottom.hover.noninteractive
|
||||
class="d-flex flex-gapx-1 align-items-center"
|
||||
title="View currently selected"
|
||||
:pressed="props.workflowTab === 'view'"
|
||||
@click="clickedTab('view')">
|
||||
<FontAwesomeIcon :icon="faEye" />
|
||||
<span v-if="!props.compact" v-localize>View</span>
|
||||
</BButton>
|
||||
<BDropdown
|
||||
v-if="props.showViewCreateOptions && props.currentSource === 'hdca' && !props.collectionType"
|
||||
v-b-tooltip.bottom.hover.noninteractive
|
||||
class="d-flex flex-gapx-1 align-items-center"
|
||||
title="Create a new collection"
|
||||
no-caret
|
||||
toggle-class="d-flex flex-gapx-1 align-items-center">
|
||||
<template v-slot:button-content>
|
||||
<FontAwesomeIcon :icon="faCaretDown" />
|
||||
<span v-localize>Create</span>
|
||||
</template>
|
||||
<BDropdownItem
|
||||
v-for="colType in Object.keys(COLLECTION_TYPE_TO_LABEL)"
|
||||
:key="colType"
|
||||
@click="createCollectionType(colType)">
|
||||
{{ capitalizeFirstLetter(COLLECTION_TYPE_TO_LABEL[colType] || "collection") }}
|
||||
</BDropdownItem>
|
||||
</BDropdown>
|
||||
<BButton
|
||||
v-else-if="props.showViewCreateOptions"
|
||||
v-b-tooltip.bottom.hover.noninteractive
|
||||
class="d-flex flex-gapx-1 align-items-center"
|
||||
:title="createTitle"
|
||||
:pressed="props.workflowTab === 'create'"
|
||||
@click="clickedTab('create')">
|
||||
<FontAwesomeIcon :icon="!props.collectionType ? faUpload : faPlus" />
|
||||
<span v-localize>{{ !props.collectionType ? "Upload" : "Create" }}</span>
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BButton, BCollapse, BPopover, BTooltip } from "bootstrap-vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
import { orList } from "@/utils/strings";
|
||||
|
||||
const props = defineProps<{
|
||||
extensions: string[];
|
||||
formatsButtonId: string;
|
||||
formatsVisible: boolean;
|
||||
popover?: boolean;
|
||||
minimal?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:formats-visible", formatsVisible: boolean): void;
|
||||
}>();
|
||||
|
||||
/** Computed toggle that handles opening and closing the modal */
|
||||
const localFormatsVisible = computed({
|
||||
get: () => props.formatsVisible,
|
||||
set: (value: boolean) => {
|
||||
// emit("update:show-modal", value);
|
||||
emit("update:formats-visible", value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="props.minimal && props.extensions.length <= 2" class="font-italic font-weight-normal">
|
||||
<span v-localize>accepted formats:</span>
|
||||
<span>{{ orList([...props.extensions]) }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<BButton
|
||||
:id="props.formatsButtonId"
|
||||
class="ui-link d-flex align-items-center text-nowrap flex-gapx-1"
|
||||
@click="localFormatsVisible = !localFormatsVisible">
|
||||
<span v-localize>accepted formats</span>
|
||||
<FontAwesomeIcon v-if="formatsVisible" :icon="faCaretUp" />
|
||||
<FontAwesomeIcon v-else :icon="faCaretDown" />
|
||||
</BButton>
|
||||
<component
|
||||
:is="props.popover ? BPopover : BCollapse"
|
||||
v-model="localFormatsVisible"
|
||||
:target="props.formatsButtonId"
|
||||
:show.sync="localFormatsVisible"
|
||||
placement="bottom">
|
||||
<ul class="pl-3 m-0">
|
||||
<li v-for="extension in props.extensions" :key="extension">{{ extension }}</li>
|
||||
</ul>
|
||||
</component>
|
||||
<BTooltip
|
||||
v-if="!formatsVisible"
|
||||
:target="props.formatsButtonId"
|
||||
noninteractive
|
||||
placement="bottom"
|
||||
triggers="hover">
|
||||
<div class="form-data-props.extensions-tooltip">
|
||||
<span>{{ orList([...props.extensions]) }}</span>
|
||||
</div>
|
||||
</BTooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { faEye, faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
|
||||
import type { CollectionType } from "@/components/History/adapters/buildCollectionModal";
|
||||
import { useUploadConfigurations } from "@/composables/uploadConfigurations";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
|
||||
import type { DataOption } from "./types";
|
||||
import type { VariantInterface } from "./variants";
|
||||
|
||||
import CollectionCreatorIndex from "@/components/Collections/CollectionCreatorIndex.vue";
|
||||
import CollectionCreatorShowExtensions from "@/components/Collections/common/CollectionCreatorShowExtensions.vue";
|
||||
import GenericItem from "@/components/History/Content/GenericItem.vue";
|
||||
import DefaultBox from "@/components/Upload/DefaultBox.vue";
|
||||
|
||||
const WorkflowRunTabs: Record<string, number> = {
|
||||
view: 0,
|
||||
create: 1,
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
currentVariant?: VariantInterface | null;
|
||||
currentValue?: DataOption[];
|
||||
canBrowse?: boolean;
|
||||
extensions?: string[];
|
||||
collectionType?: CollectionType;
|
||||
stepTitle?: string;
|
||||
workflowTab: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "focus"): void;
|
||||
(e: "uploaded-data", data: any): void;
|
||||
(e: "update:workflow-tab", value: string): void;
|
||||
}>();
|
||||
|
||||
const currentWorkflowTab = computed({
|
||||
get: () => WorkflowRunTabs[props.workflowTab],
|
||||
set: (value) => {
|
||||
emit("update:workflow-tab", Object.keys(WorkflowRunTabs).find((key) => WorkflowRunTabs[key] === value) || "");
|
||||
},
|
||||
});
|
||||
|
||||
const { currentHistoryId } = storeToRefs(useHistoryStore());
|
||||
|
||||
// Upload properties
|
||||
const {
|
||||
configOptions,
|
||||
effectiveExtensions,
|
||||
listDbKeys,
|
||||
ready: uploadReady,
|
||||
} = useUploadConfigurations(props.extensions);
|
||||
|
||||
function addUploadedFiles(value: any[], viewUploads = true) {
|
||||
emit("uploaded-data", value);
|
||||
if (viewUploads) {
|
||||
goToFirstWorkflowTab();
|
||||
}
|
||||
}
|
||||
|
||||
function collectionCreated(collection: any) {
|
||||
addUploadedFiles([collection], false);
|
||||
emit("focus");
|
||||
}
|
||||
|
||||
function goToFirstWorkflowTab() {
|
||||
emit("focus");
|
||||
currentWorkflowTab.value = WorkflowRunTabs.view;
|
||||
}
|
||||
|
||||
// TODO:
|
||||
// - Add support for the browse files option we have in FormData
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-show="currentWorkflowTab === WorkflowRunTabs.view && currentValue" class="bordered-container">
|
||||
<div class="heading-container d-flex align-items-center flex-gapx-1">
|
||||
<FontAwesomeIcon :icon="faEye" fixed-width />
|
||||
<h4 class="m-0">View selected {{ props.currentVariant?.tooltip.toLocaleLowerCase() || "value(s)" }}</h4>
|
||||
</div>
|
||||
<div v-for="item in currentValue" :key="item.id">
|
||||
<GenericItem class="mr-2 w-100" :item-id="item.id" :item-src="item.src" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="
|
||||
props.canBrowse && props.currentVariant?.src !== 'hdca' && currentWorkflowTab === WorkflowRunTabs.create
|
||||
"
|
||||
class="bordered-container">
|
||||
<div class="heading-container d-flex align-items-center flex-gapx-1">
|
||||
<FontAwesomeIcon :icon="faUpload" fixed-width />
|
||||
<h4 class="m-0">Upload {{ props.currentVariant?.tooltip.toLocaleLowerCase() || "value(s)" }}</h4>
|
||||
</div>
|
||||
<DefaultBox
|
||||
v-if="currentHistoryId && uploadReady && configOptions"
|
||||
:effective-extensions="effectiveExtensions"
|
||||
v-bind="configOptions"
|
||||
:has-callback="false"
|
||||
:history-id="currentHistoryId"
|
||||
:list-db-keys="listDbKeys"
|
||||
disable-footer
|
||||
emit-uploaded
|
||||
size="sm"
|
||||
@uploaded="addUploadedFiles"
|
||||
@dismiss="goToFirstWorkflowTab">
|
||||
<template v-slot:footer>
|
||||
<CollectionCreatorShowExtensions
|
||||
:extensions="props.extensions && props.extensions.filter((ext) => ext !== 'data')"
|
||||
upload />
|
||||
</template>
|
||||
</DefaultBox>
|
||||
</div>
|
||||
<div v-show="currentWorkflowTab === WorkflowRunTabs.create && props.currentVariant?.src === 'hdca'">
|
||||
<CollectionCreatorIndex
|
||||
v-if="currentHistoryId && props.collectionType"
|
||||
:history-id="currentHistoryId"
|
||||
:collection-type="props.collectionType"
|
||||
show
|
||||
not-modal
|
||||
:extensions="props.extensions && props.extensions.filter((ext) => ext !== 'data')"
|
||||
:suggested-name="props.stepTitle"
|
||||
@created-collection="collectionCreated"
|
||||
@on-hide="goToFirstWorkflowTab" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "theme/blue.scss";
|
||||
|
||||
.bordered-container {
|
||||
border: 2px solid $portlet-bg-color;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heading-container {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 16px;
|
||||
background: $body-bg;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Data input variations interface */
|
||||
interface VariantInterface {
|
||||
export interface VariantInterface {
|
||||
batch: string;
|
||||
icon: string;
|
||||
library?: boolean;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { faExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert } from "bootstrap-vue";
|
||||
|
||||
const props = defineProps<{
|
||||
alerts: string[];
|
||||
hasAlertClass?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="props.hasAlertClass ? BAlert : 'span'" show variant="info">
|
||||
<div v-for="(alert, index) in props.alerts" :key="index" class="ui-form-error">
|
||||
<FontAwesomeIcon class="mr-1" :icon="faExclamation" />
|
||||
<span class="ui-form-error-text">{{ alert }}</span>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-alert v-if="errorMessage" class="mt-2" :show="dismissCountDown" variant="info" @dismissed="resetAlert">
|
||||
<b-alert
|
||||
v-if="errorMessage"
|
||||
class="mt-2"
|
||||
:show="dismissCountDown"
|
||||
variant="info"
|
||||
dismissible
|
||||
@dismissed="resetAlert"
|
||||
@dismiss-count-down="($event) => (dismissCountDown = $event)">
|
||||
{{ errorMessage }}
|
||||
<b-progress :max="dismissSecs" :value="dismissCountDown" height="4px" class="mt-1">
|
||||
<b-progress-bar :value="dismissCountDown" variant="info" />
|
||||
</b-progress>
|
||||
</b-alert>
|
||||
<b-row align-v="center">
|
||||
<b-col :sm="isRangeValid ? defaultInputSizeWithSlider : false">
|
||||
@@ -12,7 +22,10 @@
|
||||
:no-wheel="true"
|
||||
:step="step"
|
||||
:type="fieldType"
|
||||
:placeholder="placeholder"
|
||||
:state="showState ? (!currentValue && currentValue !== 0 ? (optional ? null : false) : true) : null"
|
||||
@change="onInputChange"
|
||||
@keypress="isNumberOrDecimal"
|
||||
@keydown.190.capture="onFloatInput"
|
||||
@keydown.110.capture="onFloatInput" />
|
||||
</b-col>
|
||||
@@ -50,11 +63,23 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
optional: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
defaultInputSizeWithSlider: 4,
|
||||
dismissSecs: 5,
|
||||
dismissSecs: 4,
|
||||
dismissCountDown: 0,
|
||||
errorMessage: "",
|
||||
fractionWarning: "This output doesn't allow fractions!",
|
||||
@@ -125,6 +150,15 @@ export default {
|
||||
this.dismissCountDown = this.dismissSecs;
|
||||
}
|
||||
},
|
||||
/** To only allow numbers and decimal points as input for this number field */
|
||||
isNumberOrDecimal(event) {
|
||||
const key = event.key;
|
||||
if ((key >= "0" && key <= "9") || key === ".") {
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
return false;
|
||||
},
|
||||
isOutOfRange(value) {
|
||||
/* If value=null, then value is within range. */
|
||||
return (
|
||||
|
||||
@@ -106,10 +106,14 @@ const showSelectPreference = computed(
|
||||
const displayMany = computed(() => showSelectPreference.value && useMany.value);
|
||||
const showManyButton = computed(() => showSelectPreference.value && !useMany.value);
|
||||
const showMultiButton = computed(() => displayMany.value);
|
||||
|
||||
defineExpose({
|
||||
displayMany,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="form-selection">
|
||||
<FormCheck v-if="display === 'checkboxes'" v-model="currentValue" :options="currentOptions" />
|
||||
<FormRadio v-else-if="display === 'radio'" v-model="currentValue" :options="currentOptions" />
|
||||
<FormSelectMany v-else-if="displayMany" v-model="currentValue" :options="currentOptions" />
|
||||
@@ -145,3 +149,11 @@ const showMultiButton = computed(() => displayMany.value);
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-selection {
|
||||
&:deep(.alert) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
:class="['ui-input', cls]"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
:state="showState ? (!currentValue ? (optional ? null : false) : true) : null"
|
||||
:style="style"
|
||||
:type="acceptedTypes"
|
||||
:list="`${id}-datalist`" />
|
||||
@@ -59,6 +60,14 @@ export default {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
optional: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
:collapsed-disable-icon="collapsedDisableIcon"
|
||||
:on-change="onChange"
|
||||
:on-change-form="onChangeForm"
|
||||
:workflow-building-mode="workflowBuildingMode" />
|
||||
:workflow-building-mode="workflowBuildingMode"
|
||||
:workflow-run="workflowRun"
|
||||
:active-node-id="activeNodeId"
|
||||
:sync-with-graph="syncWithGraph"
|
||||
@update:active-node-id="updateActiveNode" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -86,10 +90,22 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflowRun: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowEmptyValueOnRequiredInput: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activeNodeId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
syncWithGraph: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -104,6 +120,9 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeNodeId() {
|
||||
this.scrollToElement(this.activeNodeId);
|
||||
},
|
||||
id() {
|
||||
this.onCloneInputs();
|
||||
},
|
||||
@@ -223,12 +242,18 @@ export default {
|
||||
const message = validation[1];
|
||||
this.setError(inputId, message);
|
||||
if (!silent && inputId) {
|
||||
const element = this.$el.querySelector(`[id='form-element-${inputId}']`);
|
||||
if (element) {
|
||||
const centerPanel = document.querySelector("#center");
|
||||
if (centerPanel) {
|
||||
centerPanel.scrollTo(0, this.getOffsetTop(element));
|
||||
}
|
||||
this.scrollToElement(inputId);
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollToElement(elementId) {
|
||||
const element = this.$el.querySelector(`[id='form-element-${elementId}']`);
|
||||
if (element) {
|
||||
const centerPanel = document.querySelector("#center");
|
||||
if (centerPanel) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (this.syncWithGraph && this.activeNodeId !== elementId) {
|
||||
this.updateActiveNode(elementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +275,9 @@ export default {
|
||||
input.error = null;
|
||||
});
|
||||
},
|
||||
updateActiveNode(activeNodeId) {
|
||||
this.$emit("update:active-node-id", activeNodeId);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -17,6 +17,7 @@ import FormData from "./Elements/FormData/FormData.vue";
|
||||
import FormDataUri from "./Elements/FormData/FormDataUri.vue";
|
||||
import FormDirectory from "./Elements/FormDirectory.vue";
|
||||
import FormDrilldown from "./Elements/FormDrilldown/FormDrilldown.vue";
|
||||
import FormError from "./Elements/FormError.vue";
|
||||
import FormHidden from "./Elements/FormHidden.vue";
|
||||
import FormInput from "./Elements/FormInput.vue";
|
||||
import FormNumber from "./Elements/FormNumber.vue";
|
||||
@@ -26,8 +27,15 @@ import FormSelection from "./Elements/FormSelection.vue";
|
||||
import FormTags from "./Elements/FormTags.vue";
|
||||
import FormText from "./Elements/FormText.vue";
|
||||
import FormUpload from "./Elements/FormUpload.vue";
|
||||
import FormElementHeader from "./FormElementHeader.vue";
|
||||
import FormElementHelpMarkdown from "./FormElementHelpMarkdown.vue";
|
||||
|
||||
const TYPE_TO_PLACEHOLDER: Record<string, string> = {
|
||||
text: "text input",
|
||||
integer: "an integer",
|
||||
float: "a floating point number",
|
||||
};
|
||||
|
||||
interface FormElementProps {
|
||||
id?: string;
|
||||
type?: FormParameterTypes;
|
||||
@@ -50,6 +58,8 @@ interface FormElementProps {
|
||||
connectedEnableIcon?: string;
|
||||
connectedDisableIcon?: string;
|
||||
workflowBuildingMode?: boolean;
|
||||
/** If true, this element is part of a workflow run form. */
|
||||
workflowRun?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FormElementProps>(), {
|
||||
@@ -66,6 +76,7 @@ const props = withDefaults(defineProps<FormElementProps>(), {
|
||||
connectedDisableIcon: "fa fa-arrows-alt-h",
|
||||
helpFormat: "html",
|
||||
workflowBuildingMode: false,
|
||||
workflowRun: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -85,6 +96,26 @@ const collapsibleValue: ComputedRef<FormParameterValue> = computed(() => attrs.v
|
||||
const defaultValue: ComputedRef<FormParameterValue> = computed(() => attrs.value["default_value"]);
|
||||
const connectedValue: FormParameterValue = { __class__: "ConnectedValue" };
|
||||
|
||||
const computedPlaceholder = computed(() => {
|
||||
if (!props.workflowRun) {
|
||||
return "";
|
||||
}
|
||||
if (props.attributes?.placeholder || !props.type) {
|
||||
return props.attributes?.placeholder;
|
||||
}
|
||||
return `please provide ${props.type in TYPE_TO_PLACEHOLDER ? TYPE_TO_PLACEHOLDER[props.type] : "a value"}${
|
||||
isOptional.value ? " (optional)" : ""
|
||||
}`;
|
||||
});
|
||||
|
||||
/** In the case this is an element in a workflow run form, this is true
|
||||
* when the element is unpopulated and the only alert is the unpopulated error.
|
||||
*/
|
||||
const unPopulatedError = computed(
|
||||
() =>
|
||||
props.workflowRun && alerts.value?.length === 1 && alerts.value[0] === "Please provide a value for this option."
|
||||
);
|
||||
|
||||
const connected = ref(false);
|
||||
const collapsed = ref(false);
|
||||
|
||||
@@ -132,7 +163,12 @@ const isHidden = computed(() => attrs.value["hidden"]);
|
||||
const elementId = computed(() => `form-element-${props.id}`);
|
||||
const hasAlert = computed(() => alerts.value.length > 0);
|
||||
const showPreview = computed(() => (collapsed.value && attrs.value["collapsible_preview"]) || props.disabled);
|
||||
const showField = computed(() => !collapsed.value && !props.disabled);
|
||||
const showField = computed(
|
||||
() => !collapsed.value && !props.disabled && (!props.workflowRun || props.type !== "boolean")
|
||||
);
|
||||
const formDataField = computed(() =>
|
||||
props.type && ["data", "data_collection"].includes(props.type) ? (props.type as "data" | "data_collection") : null
|
||||
);
|
||||
const isUriDataField = computed(() => {
|
||||
const dataField = props.type == "data";
|
||||
if (dataField && props.value && "src" in props.value) {
|
||||
@@ -151,6 +187,11 @@ const helpText = computed(() => {
|
||||
return props.help;
|
||||
}
|
||||
});
|
||||
const nonMdHelp = computed(() =>
|
||||
Boolean(helpText.value) && props.helpFormat != "markdown" && (!props.workflowRun || helpText.value !== props.title)
|
||||
? sanitize(helpText.value!)
|
||||
: ""
|
||||
);
|
||||
|
||||
const currentValue = computed({
|
||||
get() {
|
||||
@@ -161,12 +202,37 @@ const currentValue = computed({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Instead of just using `props.title`, we check `attrs.label` and `attrs.name`:
|
||||
*
|
||||
* If `attrs.label` is an integer, and `attrs.name = attrs.label - 1`, then we
|
||||
* can infer the user didn't provide a title, and we had just set the title/label
|
||||
* to the step index + 1.
|
||||
*/
|
||||
const userDefinedTitle = computed(() => {
|
||||
const label = parseInt(attrs.value.label);
|
||||
const name = parseInt(attrs.value.name);
|
||||
if (isNaN(label) || isNaN(name) || name !== label - 1) {
|
||||
return props.title;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const isHiddenType = computed(
|
||||
() =>
|
||||
["hidden", "hidden_data", "baseurl"].includes(props.type ?? "") ||
|
||||
(props.attributes && props.attributes.titleonly)
|
||||
);
|
||||
|
||||
/** Determines if the element renders content below the title. */
|
||||
const rendersContent = computed(
|
||||
() =>
|
||||
(props.workflowRun && hasAlert.value && !unPopulatedError.value) ||
|
||||
showField.value ||
|
||||
showPreview.value ||
|
||||
helpText.value
|
||||
);
|
||||
|
||||
const collapseText = computed(() => (collapsed.value ? props.collapsedEnableText : props.collapsedDisableText));
|
||||
const connectText = computed(() => (connected.value ? props.connectedEnableText : props.connectedDisableText));
|
||||
|
||||
@@ -192,6 +258,18 @@ const alerts = computed(() => {
|
||||
.map((v) => linkify(sanitize(v!, { USE_PROFILES: { html: true } })));
|
||||
});
|
||||
|
||||
/** Adds a temporary 2 sec focus to the element. */
|
||||
function addTempFocus() {
|
||||
const element = document.getElementById(elementId.value);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element.classList.add("temp-focus");
|
||||
setTimeout(() => {
|
||||
element.classList.remove("temp-focus");
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function onAlert(value: string | undefined) {
|
||||
formAlert.value = value;
|
||||
}
|
||||
@@ -202,145 +280,176 @@ function onAlert(value: string | undefined) {
|
||||
v-show="!isHidden"
|
||||
:id="elementId"
|
||||
class="ui-form-element section-row"
|
||||
:class="{ alert: hasAlert, 'alert-info': hasAlert }">
|
||||
<div v-for="(alert, index) in alerts" :key="index" class="ui-form-error">
|
||||
<FontAwesomeIcon class="mr-1" icon="fa-exclamation" />
|
||||
<span class="ui-form-error-text" v-html="alert" />
|
||||
</div>
|
||||
:class="{
|
||||
alert: hasAlert || props.workflowRun,
|
||||
'alert-info': hasAlert && !props.workflowRun,
|
||||
'workflow-run-element p-0': props.workflowRun,
|
||||
}">
|
||||
<FormError v-if="hasAlert && !props.workflowRun" :alerts="alerts" />
|
||||
|
||||
<div class="ui-form-title">
|
||||
<span v-if="collapsible || connectable">
|
||||
<b-button
|
||||
v-if="collapsible && !connected"
|
||||
class="ui-form-collapsible-icon"
|
||||
:title="collapseText"
|
||||
@click="onCollapse">
|
||||
<FontAwesomeIcon v-if="collapsed" :icon="props.collapsedEnableIcon" />
|
||||
<FontAwesomeIcon v-else :icon="props.collapsedDisableIcon" />
|
||||
</b-button>
|
||||
<div class="ui-form-title" :class="{ 'card-header m-0 px-3 py-2': props.workflowRun }">
|
||||
<div>
|
||||
<span v-if="collapsible || connectable">
|
||||
<b-button
|
||||
v-if="collapsible && !connected"
|
||||
class="ui-form-collapsible-icon"
|
||||
:title="collapseText"
|
||||
@click="onCollapse">
|
||||
<FontAwesomeIcon v-if="collapsed" :icon="props.collapsedEnableIcon" />
|
||||
<FontAwesomeIcon v-else :icon="props.collapsedDisableIcon" />
|
||||
</b-button>
|
||||
|
||||
<b-button v-if="connectable" class="ui-form-connected-icon" :title="connectText" @click="onConnect">
|
||||
<FontAwesomeIcon v-if="connected" :icon="props.connectedEnableIcon" />
|
||||
<FontAwesomeIcon v-else :icon="props.connectedDisableIcon" />
|
||||
</b-button>
|
||||
<b-button v-if="connectable" class="ui-form-connected-icon" :title="connectText" @click="onConnect">
|
||||
<FontAwesomeIcon v-if="connected" :icon="props.connectedEnableIcon" />
|
||||
<FontAwesomeIcon v-else :icon="props.connectedDisableIcon" />
|
||||
</b-button>
|
||||
|
||||
<span v-if="props.title" class="ui-form-title-text ml-1">
|
||||
<span v-if="props.title" class="ui-form-title-text ml-1">
|
||||
<label :for="props.id">{{ props.title }}</label>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else-if="props.title" class="ui-form-title-text">
|
||||
<label :for="props.id">{{ props.title }}</label>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else-if="props.title" class="ui-form-title-text">
|
||||
<label :for="props.id">{{ props.title }}</label>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="isRequired && isRequiredType && props.title"
|
||||
v-b-tooltip.hover
|
||||
class="ui-form-title-star"
|
||||
title="required"
|
||||
:class="{ warning: isEmpty }">
|
||||
*
|
||||
<span v-if="isEmpty" class="ui-form-title-message warning"> required </span>
|
||||
</span>
|
||||
<span v-else-if="isOptional && isRequiredType && props.title" class="ui-form-title-message">
|
||||
- optional
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showField" class="ui-form-field" :data-label="props.title">
|
||||
<FormBoolean v-if="props.type === 'boolean'" :id="props.id" v-model="currentValue" />
|
||||
<FormHidden v-else-if="isHiddenType" :id="props.id" v-model="currentValue" :info="attrs['info']" />
|
||||
<FormNumber
|
||||
v-else-if="props.type === 'integer' || props.type === 'float'"
|
||||
<span
|
||||
v-if="isRequired && isRequiredType && props.title"
|
||||
v-b-tooltip.hover
|
||||
class="ui-form-title-star"
|
||||
title="required"
|
||||
:class="{ warning: isEmpty }">
|
||||
*
|
||||
<span v-if="isEmpty" class="ui-form-title-message warning"> required </span>
|
||||
</span>
|
||||
<span v-else-if="isOptional && isRequiredType && props.title" class="ui-form-title-message">
|
||||
- optional
|
||||
</span>
|
||||
</div>
|
||||
<FormElementHeader
|
||||
v-if="props.workflowRun"
|
||||
:id="props.id"
|
||||
v-model="currentValue"
|
||||
:max="attrs.max"
|
||||
:min="attrs.min"
|
||||
:type="props.type ?? 'float'"
|
||||
:workflow-building-mode="workflowBuildingMode" />
|
||||
<FormOptionalText
|
||||
v-else-if="props.type === 'select' && attrs.is_workflow && attrs.optional"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:readonly="attrs.readonly"
|
||||
:value="attrs.value"
|
||||
:area="attrs.area"
|
||||
:placeholder="attrs.placeholder"
|
||||
:multiple="attrs.multiple"
|
||||
:datalist="attrs.datalist"
|
||||
:type="props.type" />
|
||||
<FormText
|
||||
v-else-if="
|
||||
['text', 'password'].includes(props.type ?? '') ||
|
||||
(attrs.is_workflow &&
|
||||
['data_column', 'drill_down', 'genomebuild', 'group_tag', 'select'].includes(props.type ?? ''))
|
||||
"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:readonly="attrs.readonly"
|
||||
:value="attrs.value"
|
||||
:area="attrs.area"
|
||||
:placeholder="attrs.placeholder"
|
||||
:color="attrs.color"
|
||||
:multiple="attrs.multiple"
|
||||
:cls="attrs.cls"
|
||||
:datalist="attrs.datalist"
|
||||
:type="props.type" />
|
||||
<FormSelection
|
||||
v-else-if="
|
||||
(props.type === undefined && attrs.options) ||
|
||||
['data_column', 'genomebuild', 'group_tag', 'select'].includes(props.type ?? '')
|
||||
"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:data="attrs.data"
|
||||
:display="attrs.display"
|
||||
:options="attrs.options"
|
||||
:optional="attrs.optional"
|
||||
:multiple="attrs.multiple" />
|
||||
<FormDataUri
|
||||
v-else-if="isUriDataField"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:value="attrs.value"
|
||||
:multiple="attrs.multiple" />
|
||||
<FormData
|
||||
v-else-if="['data', 'data_collection'].includes(props.type ?? '')"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:loading="loading"
|
||||
:extensions="attrs.extensions"
|
||||
:flavor="attrs.flavor"
|
||||
:multiple="attrs.multiple"
|
||||
:optional="attrs.optional"
|
||||
:options="attrs.options"
|
||||
:tag="attrs.tag"
|
||||
:type="props.type"
|
||||
:collection-types="attrs.collection_types"
|
||||
@alert="onAlert" />
|
||||
<FormDrilldown
|
||||
v-else-if="props.type === 'drill_down'"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:options="attrs.options"
|
||||
:multiple="attrs.multiple" />
|
||||
<FormColor v-else-if="props.type === 'color'" :id="props.id" v-model="currentValue" />
|
||||
<FormDirectory v-else-if="props.type === 'directory_uri'" v-model="currentValue" />
|
||||
<FormUpload v-else-if="props.type === 'upload'" v-model="currentValue" />
|
||||
<FormRulesEdit v-else-if="props.type == 'rules'" v-model="currentValue" :target="attrs.target" />
|
||||
<FormTags
|
||||
v-else-if="props.type === 'tags'"
|
||||
v-model="currentValue"
|
||||
:placeholder="props.attributes?.placeholder" />
|
||||
<FormInput v-else :id="props.id" v-model="currentValue" :area="attrs['area']" />
|
||||
:has-alert="hasAlert"
|
||||
:is-empty="isEmpty"
|
||||
:is-optional="isOptional"
|
||||
:extensions="attrs.extensions">
|
||||
<template v-slot:form-element>
|
||||
<FormBoolean v-if="props.type === 'boolean'" :id="props.id" v-model="currentValue" />
|
||||
</template>
|
||||
<template v-slot:action-items>
|
||||
<slot name="workflow-run-form-title-items" />
|
||||
</template>
|
||||
</FormElementHeader>
|
||||
</div>
|
||||
|
||||
<div v-if="showPreview" class="ui-form-preview pt-1 pl-2 mt-1">{{ previewText }}</div>
|
||||
<span
|
||||
v-if="Boolean(helpText) && helpFormat != 'markdown'"
|
||||
class="ui-form-info form-text text-muted"
|
||||
v-html="helpText" />
|
||||
<span v-else-if="Boolean(helpText)" class="ui-form-info form-text text-muted"
|
||||
><FormElementHelpMarkdown :content="helpText ?? ''"
|
||||
/></span>
|
||||
<div v-if="rendersContent" :class="{ 'form-element-content px-3 py-1 mb-2': props.workflowRun }">
|
||||
<FormError v-if="props.workflowRun && hasAlert && !unPopulatedError" :alerts="alerts" has-alert-class />
|
||||
|
||||
<div v-if="showField" class="ui-form-field" :data-label="props.title">
|
||||
<FormBoolean v-if="props.type === 'boolean'" :id="props.id" v-model="currentValue" />
|
||||
<FormHidden v-else-if="isHiddenType" :id="props.id" v-model="currentValue" :info="attrs['info']" />
|
||||
<FormNumber
|
||||
v-else-if="props.type === 'integer' || props.type === 'float'"
|
||||
:id="props.id"
|
||||
v-model="currentValue"
|
||||
:max="attrs.max"
|
||||
:min="attrs.min"
|
||||
:placeholder="computedPlaceholder"
|
||||
:optional="isOptional"
|
||||
:show-state="props.workflowRun"
|
||||
:type="props.type ?? 'float'"
|
||||
:workflow-building-mode="workflowBuildingMode" />
|
||||
<FormOptionalText
|
||||
v-else-if="props.type === 'select' && attrs.is_workflow && attrs.optional"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:readonly="attrs.readonly"
|
||||
:value="attrs.value"
|
||||
:area="attrs.area"
|
||||
:placeholder="computedPlaceholder"
|
||||
:multiple="attrs.multiple"
|
||||
:datalist="attrs.datalist"
|
||||
:type="props.type" />
|
||||
<FormText
|
||||
v-else-if="
|
||||
['text', 'password'].includes(props.type ?? '') ||
|
||||
(attrs.is_workflow &&
|
||||
['data_column', 'drill_down', 'genomebuild', 'group_tag', 'select'].includes(
|
||||
props.type ?? ''
|
||||
))
|
||||
"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:readonly="attrs.readonly"
|
||||
:value="attrs.value"
|
||||
:area="attrs.area"
|
||||
:placeholder="computedPlaceholder"
|
||||
:optional="isOptional"
|
||||
:show-state="props.workflowRun"
|
||||
:color="attrs.color"
|
||||
:multiple="attrs.multiple"
|
||||
:cls="attrs.cls"
|
||||
:datalist="attrs.datalist"
|
||||
:type="props.type" />
|
||||
<FormSelection
|
||||
v-else-if="
|
||||
(props.type === undefined && attrs.options) ||
|
||||
['data_column', 'genomebuild', 'group_tag', 'select'].includes(props.type ?? '')
|
||||
"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:data="attrs.data"
|
||||
:display="attrs.display"
|
||||
:options="attrs.options"
|
||||
:optional="attrs.optional"
|
||||
:multiple="attrs.multiple" />
|
||||
<FormDataUri
|
||||
v-else-if="isUriDataField"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:value="attrs.value"
|
||||
:multiple="attrs.multiple" />
|
||||
<FormData
|
||||
v-else-if="formDataField"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:loading="loading"
|
||||
:extensions="attrs.extensions"
|
||||
:flavor="attrs.flavor"
|
||||
:multiple="attrs.multiple"
|
||||
:optional="attrs.optional"
|
||||
:options="attrs.options"
|
||||
:tag="attrs.tag"
|
||||
:user-defined-title="userDefinedTitle"
|
||||
:type="formDataField"
|
||||
:collection-types="attrs.collection_types"
|
||||
:workflow-run="props.workflowRun"
|
||||
@alert="onAlert"
|
||||
@focus="addTempFocus" />
|
||||
<FormDrilldown
|
||||
v-else-if="props.type === 'drill_down'"
|
||||
:id="id"
|
||||
v-model="currentValue"
|
||||
:options="attrs.options"
|
||||
:multiple="attrs.multiple" />
|
||||
<FormColor v-else-if="props.type === 'color'" :id="props.id" v-model="currentValue" />
|
||||
<FormDirectory v-else-if="props.type === 'directory_uri'" v-model="currentValue" />
|
||||
<FormUpload v-else-if="props.type === 'upload'" v-model="currentValue" />
|
||||
<FormRulesEdit v-else-if="props.type == 'rules'" v-model="currentValue" :target="attrs.target" />
|
||||
<FormTags
|
||||
v-else-if="props.type === 'tags'"
|
||||
v-model="currentValue"
|
||||
:placeholder="props.attributes?.placeholder" />
|
||||
<FormInput v-else :id="props.id" v-model="currentValue" :area="attrs['area']" />
|
||||
</div>
|
||||
|
||||
<div v-if="showPreview" class="ui-form-preview pt-1 pl-2 mt-1">{{ previewText }}</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-if="nonMdHelp" class="ui-form-info form-text text-muted" v-html="nonMdHelp" />
|
||||
<span v-else-if="Boolean(helpText) && helpFormat === 'markdown'" class="ui-form-info form-text text-muted">
|
||||
<FormElementHelpMarkdown :content="helpText ?? ''" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheck, faExclamation, faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BBadge } from "bootstrap-vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { useUid } from "@/composables/utils/uid";
|
||||
|
||||
import type { FormParameterTypes } from "./parameterTypes";
|
||||
|
||||
import FormDataExtensions from "./Elements/FormData/FormDataExtensions.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
type?: FormParameterTypes;
|
||||
hasAlert: boolean;
|
||||
id: string;
|
||||
isEmpty: boolean;
|
||||
isOptional: boolean;
|
||||
extensions?: string[];
|
||||
}>();
|
||||
|
||||
// Rendering formats
|
||||
const formatsVisible = ref(false);
|
||||
const formatsButtonId = useUid("form-element-formats-");
|
||||
const renderFormats = computed(
|
||||
() =>
|
||||
props.type &&
|
||||
["data", "data_collection"].includes(props.type) &&
|
||||
props.extensions?.length &&
|
||||
props.extensions.indexOf("data") < 0
|
||||
);
|
||||
|
||||
const populatedClass = computed<string>(() => {
|
||||
if (props.hasAlert || (props.isEmpty && !props.isOptional)) {
|
||||
return "unpopulated";
|
||||
} else if (!props.isEmpty) {
|
||||
return "populated";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const badgeData = computed(() => {
|
||||
switch (true) {
|
||||
case props.hasAlert:
|
||||
return { icon: faExclamation, class: "text-danger", message: "Fix error(s) for this step" };
|
||||
case props.isEmpty && !props.isOptional:
|
||||
return { icon: faExclamationCircle, message: "Provide a value for this step" };
|
||||
case !props.isEmpty:
|
||||
return { icon: faCheck, class: "text-success", message: "Step is populated" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex align-items-center flex-gapx-1">
|
||||
<FormDataExtensions
|
||||
v-if="renderFormats"
|
||||
class="mr-1"
|
||||
popover
|
||||
minimal
|
||||
:extensions="props.extensions || []"
|
||||
:formats-button-id="formatsButtonId"
|
||||
:formats-visible.sync="formatsVisible" />
|
||||
<BBadge
|
||||
v-if="badgeData.message && props.type !== 'boolean'"
|
||||
v-b-tooltip.hover.noninteractive
|
||||
class="flex-gapx-1 form-element-header-badge"
|
||||
:class="populatedClass"
|
||||
:title="badgeData.message">
|
||||
<FontAwesomeIcon v-if="badgeData?.icon" :icon="badgeData.icon" :class="badgeData.class" fixed-width />
|
||||
</BBadge>
|
||||
<slot name="form-element" />
|
||||
<slot name="action-items" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(input, index) in inputs" :key="index">
|
||||
<div
|
||||
v-for="(input, index) in inputs"
|
||||
:key="index"
|
||||
:class="{ 'bordered-input': syncWithGraph && activeNodeId === index }">
|
||||
<div v-if="input.type == 'conditional'" class="ui-portlet-section mt-3">
|
||||
<div class="portlet-header">
|
||||
<b>{{ input.test_param.label || input.test_param.name }}</b>
|
||||
@@ -61,7 +64,22 @@
|
||||
:collapsed-disable-icon="collapsedDisableIcon"
|
||||
:loading="loading"
|
||||
:workflow-building-mode="workflowBuildingMode"
|
||||
@change="onChange" />
|
||||
:workflow-run="workflowRun"
|
||||
@change="onChange">
|
||||
<template v-slot:workflow-run-form-title-items>
|
||||
<b-button
|
||||
v-if="syncWithGraph"
|
||||
class="text-decoration-none"
|
||||
size="sm"
|
||||
variant="link"
|
||||
:title="activeNodeId === index ? 'Active' : 'View in Graph'"
|
||||
:disabled="activeNodeId === index"
|
||||
@click="$emit('update:active-node-id', index)">
|
||||
<span class="fas fa-sitemap" />
|
||||
<span class="fas fa-arrow-right" />
|
||||
</b-button>
|
||||
</template>
|
||||
</FormElement>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bordered-input {
|
||||
border: 1px solid blue;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-5
@@ -138,14 +138,14 @@
|
||||
<p v-localize>Remove the following tags from {{ numSelected }} items:</p>
|
||||
<StatelessTags v-model="selectedTags" class="tags" />
|
||||
</b-modal>
|
||||
<CollectionCreatorModal
|
||||
<CollectionCreatorIndex
|
||||
v-if="collectionModalType"
|
||||
:history-id="history.id"
|
||||
:collection-type="collectionModalType"
|
||||
:filter-text="filterText"
|
||||
:selected-items="collectionSelection"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
hide-modal-on-create
|
||||
:show.sync="collectionModalShow"
|
||||
hide-on-create
|
||||
default-hide-source-items
|
||||
@created-collection="createdCollection" />
|
||||
</section>
|
||||
@@ -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,
|
||||
|
||||
@@ -21,6 +21,12 @@ export type DatasetPair = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const COLLECTION_TYPE_TO_LABEL: Record<string, string> = {
|
||||
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[],
|
||||
|
||||
@@ -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<string, string | boolea
|
||||
@onSelect="onAddDatasetsDirectory"
|
||||
@onClose="onAddDatasets" />
|
||||
|
||||
<CollectionCreatorModal
|
||||
<CollectionCreatorIndex
|
||||
v-if="collectionModalType && collectionHistoryId"
|
||||
:history-id="collectionHistoryId"
|
||||
:collection-type="collectionModalType"
|
||||
:selected-items="collectionSelection"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
:show.sync="collectionModalShow"
|
||||
default-hide-source-items />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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";
|
||||
|
||||
+20
-14
@@ -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();
|
||||
});
|
||||
|
||||
@@ -1,88 +1,60 @@
|
||||
<script setup>
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faCopy, faEdit, faFolderOpen, faLaptop } from "@fortawesome/free-solid-svg-icons";
|
||||
<script setup lang="ts">
|
||||
import { faCopy, faEdit, faFolderOpen, faLaptop, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BButton } from "bootstrap-vue";
|
||||
import { filesDialog } from "utils/data";
|
||||
import Vue, { computed, ref } from "vue";
|
||||
import { BAlert, BBadge, BButton } from "bootstrap-vue";
|
||||
import Vue, { computed, type Ref, ref } from "vue";
|
||||
|
||||
import type { HDASummary } from "@/api";
|
||||
import { monitorUploadedHistoryItems } from "@/composables/monitorUploadedHistoryItems";
|
||||
import type { DbKey, ExtensionDetails } from "@/composables/uploadConfigurations";
|
||||
import { filesDialog } from "@/utils/data";
|
||||
import { UploadQueue } from "@/utils/upload-queue.js";
|
||||
|
||||
import { defaultModel } from "./model.js";
|
||||
import type { CollectionType } from "../History/adapters/buildCollectionModal";
|
||||
import { defaultModel, type UploadFile, type UploadItem } from "./model";
|
||||
import { COLLECTION_TYPES, DEFAULT_FILE_NAME, hasBrowserSupport } from "./utils";
|
||||
|
||||
import CollectionCreatorModal from "../Collections/CollectionCreatorModal.vue";
|
||||
import CollectionCreatorIndex from "../Collections/CollectionCreatorIndex.vue";
|
||||
import LoadingSpan from "../LoadingSpan.vue";
|
||||
import DefaultRow from "./DefaultRow.vue";
|
||||
import UploadBox from "./UploadBox.vue";
|
||||
import UploadSelect from "./UploadSelect.vue";
|
||||
import UploadSelectExtension from "./UploadSelectExtension.vue";
|
||||
|
||||
library.add(faCopy, faEdit, faFolderOpen, faLaptop);
|
||||
interface Props {
|
||||
chunkUploadSize: number;
|
||||
defaultDbKey: string;
|
||||
defaultExtension: string;
|
||||
effectiveExtensions: ExtensionDetails[];
|
||||
fileSourcesConfigured: boolean;
|
||||
ftpUploadSite?: string;
|
||||
historyId: string;
|
||||
multiple?: boolean;
|
||||
hasCallback?: boolean;
|
||||
lazyLoad?: number;
|
||||
listDbKeys: DbKey[];
|
||||
isCollection?: boolean;
|
||||
disableFooter?: boolean;
|
||||
emitUploaded?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
chunkUploadSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
defaultDbKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultExtension: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
effectiveExtensions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
fileSourcesConfigured: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
ftpUploadSite: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
historyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasCallback: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
lazyLoad: {
|
||||
type: Number,
|
||||
default: 150,
|
||||
},
|
||||
listDbKeys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isCollection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emitUploaded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
ftpUploadSite: undefined,
|
||||
multiple: true,
|
||||
lazyLoad: 150,
|
||||
size: "md",
|
||||
isCollection: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["dismiss", "progress", "uploaded"]);
|
||||
const emit = defineEmits<{
|
||||
(e: "dismiss"): void;
|
||||
(e: "progress", value: number | null, variant?: string): void;
|
||||
(e: "uploaded", value: HDASummary[]): void;
|
||||
}>();
|
||||
|
||||
const collectionModalShow = ref(false);
|
||||
const collectionSelection = ref([]);
|
||||
const collectionType = ref("list");
|
||||
const collectionType = ref<CollectionType>("list");
|
||||
const counterAnnounce = ref(0);
|
||||
const counterError = ref(0);
|
||||
const counterRunning = ref(0);
|
||||
@@ -91,14 +63,24 @@ const extension = ref(props.defaultExtension);
|
||||
const dbKey = ref(props.defaultDbKey);
|
||||
const queueStopping = ref(false);
|
||||
const uploadCompleted = ref(0);
|
||||
const uploadFile = ref(null);
|
||||
const uploadItems = ref({});
|
||||
const uploadFile = ref<HTMLInputElement | null>(null);
|
||||
const uploadItems = ref<Record<string, UploadItem>>({});
|
||||
const uploadSize = ref(0);
|
||||
const queue = ref(createUploadQueue());
|
||||
const selectedItemsForModal = ref<HDASummary[]>([]);
|
||||
|
||||
const counterNonRunning = computed(() => counterAnnounce.value + counterSuccess.value + counterError.value);
|
||||
const creatingPairedType = computed(
|
||||
() => props.isCollection && ["list:paired", "paired"].includes(collectionType.value)
|
||||
);
|
||||
const enableBuild = computed(
|
||||
() => !isRunning.value && counterAnnounce.value == 0 && counterSuccess.value > 0 && counterError.value == 0
|
||||
() =>
|
||||
!isRunning.value &&
|
||||
counterAnnounce.value == 0 &&
|
||||
counterSuccess.value > 0 &&
|
||||
uploadedHistoryItemsReady.value &&
|
||||
uploadedHistoryItemsOk.value.length > 0 &&
|
||||
(!creatingPairedType.value || uploadedHistoryItemsOk.value.length % 2 === 0)
|
||||
);
|
||||
const enableReset = computed(() => !isRunning.value && counterNonRunning.value > 0);
|
||||
const enableStart = computed(() => !isRunning.value && counterAnnounce.value > 0);
|
||||
@@ -110,13 +92,20 @@ const listExtensions = computed(() => props.effectiveExtensions.filter((ext) =>
|
||||
const showHelper = computed(() => Object.keys(uploadItems.value).length === 0);
|
||||
const uploadValues = computed(() => Object.values(uploadItems.value));
|
||||
|
||||
const { uploadedHistoryItemsOk, uploadedHistoryItemsReady, historyItemsStateInfo } = monitorUploadedHistoryItems(
|
||||
uploadValues as Ref<UploadItem[]>,
|
||||
historyId,
|
||||
enableStart,
|
||||
creatingPairedType
|
||||
);
|
||||
|
||||
function createUploadQueue() {
|
||||
return new UploadQueue({
|
||||
announce: eventAnnounce,
|
||||
chunkSize: props.chunkUploadSize,
|
||||
complete: eventComplete,
|
||||
error: eventError,
|
||||
get: (index) => uploadItems.value[index],
|
||||
get: (index: string) => uploadItems.value[index],
|
||||
multiple: props.multiple,
|
||||
progress: eventProgress,
|
||||
success: eventSuccess,
|
||||
@@ -125,7 +114,7 @@ function createUploadQueue() {
|
||||
}
|
||||
|
||||
/** Add files to queue */
|
||||
function addFiles(files, immediate = false) {
|
||||
function addFiles(files: FileList, immediate = false) {
|
||||
if (!isRunning.value) {
|
||||
if (immediate || !props.multiple) {
|
||||
eventReset();
|
||||
@@ -138,8 +127,18 @@ function addFiles(files, immediate = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function addFileFromInput(eventTarget: EventTarget | null) {
|
||||
if (!eventTarget) {
|
||||
return;
|
||||
}
|
||||
const { files } = eventTarget as HTMLInputElement;
|
||||
if (files) {
|
||||
addFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
/** A new file has been announced to the upload queue */
|
||||
function eventAnnounce(index, file) {
|
||||
function eventAnnounce(index: string, file: UploadFile) {
|
||||
counterAnnounce.value++;
|
||||
const uploadModel = {
|
||||
...defaultModel,
|
||||
@@ -156,28 +155,13 @@ function eventAnnounce(index, file) {
|
||||
Vue.set(uploadItems.value, index, uploadModel);
|
||||
}
|
||||
|
||||
/** Populates collection builder with uploaded files */
|
||||
/** Populates and opens collection builder with uploaded files, or emits uploads */
|
||||
async function eventBuild(openModal = false) {
|
||||
try {
|
||||
collectionSelection.value = [];
|
||||
uploadValues.value.forEach((model) => {
|
||||
const outputs = model.outputs;
|
||||
if (outputs) {
|
||||
Object.entries(outputs).forEach((output) => {
|
||||
const outputDetails = output[1];
|
||||
collectionSelection.value.push(outputDetails);
|
||||
});
|
||||
} else {
|
||||
console.debug("Warning, upload response does not contain outputs.", model);
|
||||
}
|
||||
});
|
||||
if (openModal) {
|
||||
collectionModalShow.value = true;
|
||||
} else {
|
||||
emit("uploaded", collectionSelection.value);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (openModal) {
|
||||
selectedItemsForModal.value = uploadedHistoryItemsOk.value;
|
||||
collectionModalShow.value = true;
|
||||
} else {
|
||||
emit("uploaded", uploadedHistoryItemsOk.value);
|
||||
}
|
||||
counterRunning.value = 0;
|
||||
eventReset();
|
||||
@@ -201,51 +185,59 @@ function eventCreate() {
|
||||
}
|
||||
|
||||
/** Error */
|
||||
function eventError(index, message) {
|
||||
function eventError(index: string, message: string) {
|
||||
const it = uploadItems.value[index];
|
||||
it.percentage = 100;
|
||||
it.status = "error";
|
||||
it.info = message;
|
||||
uploadCompleted.value += it.fileSize * 100;
|
||||
counterAnnounce.value--;
|
||||
counterError.value++;
|
||||
emit("progress", uploadPercentage(100, it.fileSize), "danger");
|
||||
if (it) {
|
||||
it.percentage = 100;
|
||||
it.status = "error";
|
||||
it.info = message;
|
||||
uploadCompleted.value += it.fileSize * 100;
|
||||
counterAnnounce.value--;
|
||||
counterError.value++;
|
||||
emit("progress", uploadPercentage(100, it.fileSize), "danger");
|
||||
}
|
||||
}
|
||||
|
||||
/** Update model */
|
||||
function eventInput(index, newData) {
|
||||
function eventInput(index: string, newData: UploadItem) {
|
||||
const it = uploadItems.value[index];
|
||||
Object.entries(newData).forEach(([key, value]) => {
|
||||
it[key] = value;
|
||||
});
|
||||
if (it) {
|
||||
Object.entries(newData).forEach(([key, value]) => {
|
||||
(it as any)[key] = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Reflect upload progress */
|
||||
function eventProgress(index, percentage) {
|
||||
function eventProgress(index: string, percentage: number) {
|
||||
const it = uploadItems.value[index];
|
||||
it.percentage = percentage;
|
||||
emit("progress", uploadPercentage(percentage, it.fileSize));
|
||||
if (it) {
|
||||
it.percentage = percentage;
|
||||
emit("progress", uploadPercentage(percentage, it.fileSize));
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove model from upload list */
|
||||
function eventRemove(index) {
|
||||
function eventRemove(index: string) {
|
||||
const it = uploadItems.value[index];
|
||||
var status = it.status;
|
||||
if (status == "success") {
|
||||
counterSuccess.value--;
|
||||
} else if (status == "error") {
|
||||
counterError.value--;
|
||||
} else {
|
||||
counterAnnounce.value--;
|
||||
if (it) {
|
||||
var status = it.status;
|
||||
if (status == "success") {
|
||||
counterSuccess.value--;
|
||||
} else if (status == "error") {
|
||||
counterError.value--;
|
||||
} else {
|
||||
counterAnnounce.value--;
|
||||
}
|
||||
Vue.delete(uploadItems.value, index);
|
||||
queue.value.remove(index);
|
||||
}
|
||||
Vue.delete(uploadItems.value, index);
|
||||
queue.value.remove(index);
|
||||
}
|
||||
|
||||
/** Show remote files dialog or FTP files */
|
||||
function eventRemoteFiles() {
|
||||
filesDialog(
|
||||
(items) => {
|
||||
(items: UploadFile[]) => {
|
||||
queue.value.add(
|
||||
items.map((item) => {
|
||||
const rval = {
|
||||
@@ -277,15 +269,17 @@ function eventReset() {
|
||||
}
|
||||
|
||||
/** Success */
|
||||
function eventSuccess(index, incoming) {
|
||||
function eventSuccess(index: string, incoming: any) {
|
||||
var it = uploadItems.value[index];
|
||||
it.percentage = 100;
|
||||
it.status = "success";
|
||||
it.outputs = incoming.outputs || incoming.data.outputs || {};
|
||||
emit("progress", uploadPercentage(100, it.fileSize));
|
||||
uploadCompleted.value += it.fileSize * 100;
|
||||
counterAnnounce.value--;
|
||||
counterSuccess.value++;
|
||||
if (it) {
|
||||
it.percentage = 100;
|
||||
it.status = "success";
|
||||
it.outputs = incoming.outputs || incoming.data.outputs || {};
|
||||
emit("progress", uploadPercentage(100, it.fileSize));
|
||||
uploadCompleted.value += it.fileSize * 100;
|
||||
counterAnnounce.value--;
|
||||
counterSuccess.value++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start upload process */
|
||||
@@ -320,19 +314,21 @@ function eventStop() {
|
||||
}
|
||||
|
||||
/** Display warning */
|
||||
function eventWarning(index, message) {
|
||||
function eventWarning(index: string, message: string) {
|
||||
const it = uploadItems.value[index];
|
||||
it.status = "warning";
|
||||
it.info = message;
|
||||
if (it) {
|
||||
it.status = "warning";
|
||||
it.info = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update collection type */
|
||||
function updateCollectionType(newCollectionType) {
|
||||
function updateCollectionType(newCollectionType: CollectionType) {
|
||||
collectionType.value = newCollectionType;
|
||||
}
|
||||
|
||||
/* Update extension type for all entries */
|
||||
function updateExtension(newExtension) {
|
||||
function updateExtension(newExtension: string) {
|
||||
extension.value = newExtension;
|
||||
uploadValues.value.forEach((model) => {
|
||||
if (model.status === "init" && model.extension === props.defaultExtension) {
|
||||
@@ -342,7 +338,7 @@ function updateExtension(newExtension) {
|
||||
}
|
||||
|
||||
/** Update reference dataset for all entries */
|
||||
function updateDbKey(newDbKey) {
|
||||
function updateDbKey(newDbKey: string) {
|
||||
dbKey.value = newDbKey;
|
||||
uploadValues.value.forEach((model) => {
|
||||
if (model.status === "init" && model.dbKey === props.defaultDbKey) {
|
||||
@@ -352,7 +348,7 @@ function updateDbKey(newDbKey) {
|
||||
}
|
||||
|
||||
/** Calculate percentage of all queued uploads */
|
||||
function uploadPercentage(percentage, size) {
|
||||
function uploadPercentage(percentage: number, size: number) {
|
||||
return (uploadCompleted.value + percentage * size) / uploadSize.value;
|
||||
}
|
||||
|
||||
@@ -367,9 +363,15 @@ defineExpose({
|
||||
<template>
|
||||
<div class="upload-wrapper">
|
||||
<div class="upload-header">
|
||||
<div v-if="props.emitUploaded && historyItemsStateInfo">
|
||||
<BAlert show :variant="historyItemsStateInfo.variant">
|
||||
<LoadingSpan v-if="historyItemsStateInfo.spin" :message="historyItemsStateInfo.message" />
|
||||
<span v-else>{{ historyItemsStateInfo.message }}</span>
|
||||
</BAlert>
|
||||
</div>
|
||||
<div v-if="queueStopping" v-localize>Queue will pause after completing the current file...</div>
|
||||
<div v-else-if="counterAnnounce === 0">
|
||||
<div v-if="hasBrowserSupport"> </div>
|
||||
<div v-if="!!hasBrowserSupport"> </div>
|
||||
<div v-else>
|
||||
Browser does not support Drag & Drop. Try Firefox 4+, Chrome 7+, IE 10+, Opera 12+ or Safari 6+.
|
||||
</div>
|
||||
@@ -383,7 +385,7 @@ defineExpose({
|
||||
</div>
|
||||
<UploadBox @add="addFiles">
|
||||
<div v-show="showHelper" class="upload-helper">
|
||||
<FontAwesomeIcon class="mr-1" icon="fa-copy" />
|
||||
<FontAwesomeIcon class="mr-1" :icon="faCopy" />
|
||||
<span v-localize>Drop files here</span>
|
||||
</div>
|
||||
<div v-show="!showHelper">
|
||||
@@ -398,9 +400,9 @@ defineExpose({
|
||||
:file-mode="uploadItem.fileMode"
|
||||
:file-name="uploadItem.fileName"
|
||||
:file-size="uploadItem.fileSize"
|
||||
:info="uploadItem.info"
|
||||
:list-extensions="!isCollection && listExtensions.length > 1 ? listExtensions : null"
|
||||
:list-db-keys="!isCollection && listDbKeys.length > 1 ? listDbKeys : null"
|
||||
:info="uploadItem.info || undefined"
|
||||
:list-extensions="!isCollection && listExtensions.length > 1 ? listExtensions : undefined"
|
||||
:list-db-keys="!isCollection && listDbKeys.length > 1 ? listDbKeys : undefined"
|
||||
:percentage="uploadItem.percentage"
|
||||
:space-to-tab="uploadItem.spaceToTab"
|
||||
:status="uploadItem.status"
|
||||
@@ -415,7 +417,13 @@ defineExpose({
|
||||
Only showing first {{ lazyLoad }} of {{ uploadValues.length }} entries.
|
||||
</div>
|
||||
</div>
|
||||
<input ref="uploadFile" type="file" :multiple="multiple" @change="addFiles($event.target.files)" />
|
||||
<label class="sr-only" for="upload-file">Uploaded File</label>
|
||||
<input
|
||||
id="upload-file"
|
||||
ref="uploadFile"
|
||||
type="file"
|
||||
:multiple="multiple"
|
||||
@change="addFileFromInput($event.target)" />
|
||||
</UploadBox>
|
||||
<div v-if="!disableFooter" class="upload-footer text-center">
|
||||
<span v-if="isCollection" class="upload-footer-title">Collection:</span>
|
||||
@@ -447,21 +455,27 @@ defineExpose({
|
||||
@input="updateDbKey" />
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
<div class="upload-buttons d-flex justify-content-end">
|
||||
<BButton id="btn-local" :disabled="!enableSources" @click="uploadFile.click()">
|
||||
<FontAwesomeIcon icon="fa-laptop" />
|
||||
<div class="d-flex justify-content-end flex-wrap" :class="!disableFooter && 'upload-buttons'">
|
||||
<BButton id="btn-local" :size="size" :disabled="!enableSources" @click="uploadFile?.click()">
|
||||
<FontAwesomeIcon :icon="faLaptop" />
|
||||
<span v-localize>Choose local file</span>
|
||||
</BButton>
|
||||
<BButton v-if="hasRemoteFiles" id="btn-remote-files" :disabled="!enableSources" @click="eventRemoteFiles">
|
||||
<FontAwesomeIcon icon="fa-folder-open" />
|
||||
<BButton
|
||||
v-if="hasRemoteFiles"
|
||||
id="btn-remote-files"
|
||||
:size="size"
|
||||
:disabled="!enableSources"
|
||||
@click="eventRemoteFiles">
|
||||
<FontAwesomeIcon :icon="faFolderOpen" />
|
||||
<span v-localize>Choose remote files</span>
|
||||
</BButton>
|
||||
<BButton id="btn-new" title="Paste/Fetch data" :disabled="!enableSources" @click="eventCreate">
|
||||
<FontAwesomeIcon icon="fa-edit" />
|
||||
<BButton id="btn-new" :size="size" title="Paste/Fetch data" :disabled="!enableSources" @click="eventCreate">
|
||||
<FontAwesomeIcon :icon="faEdit" />
|
||||
<span v-localize>Paste/Fetch data</span>
|
||||
</BButton>
|
||||
<BButton
|
||||
id="btn-start"
|
||||
:size="size"
|
||||
:disabled="!enableStart"
|
||||
title="Start"
|
||||
:variant="enableStart ? 'primary' : null"
|
||||
@@ -471,41 +485,54 @@ defineExpose({
|
||||
<BButton
|
||||
v-if="isCollection"
|
||||
id="btn-build"
|
||||
:size="size"
|
||||
:disabled="!enableBuild"
|
||||
title="Build"
|
||||
:variant="enableBuild ? 'primary' : null"
|
||||
@click="() => eventBuild(true)">
|
||||
<FontAwesomeIcon v-if="!uploadedHistoryItemsReady" :icon="faSpinner" spin />
|
||||
<span v-localize>Build</span>
|
||||
</BButton>
|
||||
<BButton
|
||||
v-if="emitUploaded"
|
||||
id="btn-emit"
|
||||
:size="size"
|
||||
:disabled="!enableBuild"
|
||||
title="Use Uploaded Files"
|
||||
:variant="enableBuild ? 'primary' : null"
|
||||
@click="() => eventBuild(false)">
|
||||
<FontAwesomeIcon v-if="!uploadedHistoryItemsReady" :icon="faSpinner" spin />
|
||||
<slot name="emit-btn-txt">
|
||||
<span v-localize>Use Uploaded</span>
|
||||
</slot>
|
||||
({{ counterSuccess }})
|
||||
</BButton>
|
||||
<BButton id="btn-stop" title="Pause" :disabled="!isRunning" @click="eventStop">
|
||||
<BBadge
|
||||
v-if="props.isCollection && historyItemsStateInfo?.icon"
|
||||
v-b-tooltip.hover.noninteractive
|
||||
role="button"
|
||||
class="d-flex align-items-center"
|
||||
:variant="historyItemsStateInfo.variant"
|
||||
:title="historyItemsStateInfo.message">
|
||||
<FontAwesomeIcon :icon="historyItemsStateInfo.icon" :spin="historyItemsStateInfo.spin" />
|
||||
</BBadge>
|
||||
<BButton id="btn-stop" :size="size" title="Pause" :disabled="!isRunning" @click="eventStop">
|
||||
<span v-localize>Pause</span>
|
||||
</BButton>
|
||||
<BButton id="btn-reset" title="Reset" :disabled="!enableReset" @click="eventReset">
|
||||
<BButton id="btn-reset" :size="size" title="Reset" :disabled="!enableReset" @click="eventReset">
|
||||
<span v-localize>Reset</span>
|
||||
</BButton>
|
||||
<BButton id="btn-close" title="Close" @click="$emit('dismiss')">
|
||||
<BButton id="btn-close" :size="size" title="Close" @click="$emit('dismiss')">
|
||||
<span v-if="hasCallback" v-localize>Cancel</span>
|
||||
<span v-else v-localize>Close</span>
|
||||
</BButton>
|
||||
</div>
|
||||
<CollectionCreatorModal
|
||||
<CollectionCreatorIndex
|
||||
v-if="isCollection && historyId"
|
||||
:history-id="historyId"
|
||||
:collection-type="collectionType"
|
||||
:selected-items="collectionSelection"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
:selected-items="selectedItemsForModal"
|
||||
:show.sync="collectionModalShow"
|
||||
default-hide-source-items />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faEdit, faFolderOpen, faLaptop } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { bytesToString } from "utils/utils";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, type Ref, ref } from "vue";
|
||||
|
||||
import type { DbKey, ExtensionDetails } from "@/composables/uploadConfigurations";
|
||||
import { bytesToString } from "@/utils/utils";
|
||||
|
||||
import UploadExtension from "./UploadExtension.vue";
|
||||
import UploadSelect from "./UploadSelect.vue";
|
||||
@@ -11,93 +13,55 @@ import UploadSettings from "./UploadSettings.vue";
|
||||
|
||||
library.add(faEdit, faLaptop, faFolderOpen);
|
||||
|
||||
const fileField = ref(null);
|
||||
const fileField: Ref<HTMLInputElement | null> = ref(null);
|
||||
|
||||
const props = defineProps({
|
||||
deferred: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
extension: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileContent: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileMode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
dbKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
info: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
listDbKeys: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
listExtensions: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
percentage: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
spaceToTab: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
toPosixLines: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
interface Props {
|
||||
deferred?: boolean;
|
||||
extension: string;
|
||||
fileContent: string;
|
||||
fileMode: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
dbKey: string;
|
||||
index: string;
|
||||
info?: string;
|
||||
listDbKeys?: DbKey[];
|
||||
listExtensions?: ExtensionDetails[];
|
||||
percentage: number;
|
||||
spaceToTab: boolean;
|
||||
status: string;
|
||||
toPosixLines: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
deferred: false,
|
||||
info: "",
|
||||
listDbKeys: undefined,
|
||||
listExtensions: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["input", "remove"]);
|
||||
|
||||
const isDisabled = computed(() => props.status !== "init");
|
||||
function inputExtension(newExtension) {
|
||||
function inputExtension(newExtension: string) {
|
||||
emit("input", props.index, { extension: newExtension });
|
||||
}
|
||||
|
||||
function inputFileContent(newFileContent) {
|
||||
function inputFileContent(newFileContent: string) {
|
||||
emit("input", props.index, { fileContent: newFileContent, fileSize: newFileContent.length });
|
||||
}
|
||||
|
||||
function inputFileName(newFileName) {
|
||||
function inputFileName(newFileName: string) {
|
||||
emit("input", props.index, { fileName: newFileName });
|
||||
}
|
||||
|
||||
function inputDbKey(newDbKey) {
|
||||
function inputDbKey(newDbKey: string) {
|
||||
emit("input", props.index, { dbKey: newDbKey });
|
||||
}
|
||||
|
||||
function inputSettings(settingId) {
|
||||
const newSettings = {};
|
||||
newSettings[settingId] = !props[settingId];
|
||||
function inputSettings(settingId: string) {
|
||||
const newSettings: Record<string, any> = {};
|
||||
newSettings[settingId] = !(props as any)[settingId];
|
||||
emit("input", props.index, newSettings);
|
||||
}
|
||||
|
||||
@@ -112,7 +76,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
function autoSelectFileInput() {
|
||||
fileField.value.select();
|
||||
fileField.value?.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -134,7 +98,7 @@ function autoSelectFileInput() {
|
||||
{{ bytesToString(fileSize) }}
|
||||
</div>
|
||||
<UploadSelect
|
||||
v-if="listExtensions !== null"
|
||||
v-if="listExtensions"
|
||||
class="upload-extension"
|
||||
:value="extension"
|
||||
:disabled="isDisabled"
|
||||
@@ -142,9 +106,9 @@ function autoSelectFileInput() {
|
||||
placeholder="Select Type"
|
||||
what="file type"
|
||||
@input="inputExtension" />
|
||||
<UploadExtension v-if="listExtensions !== null" :extension="extension" :list-extensions="listExtensions" />
|
||||
<UploadExtension v-if="listExtensions" :extension="extension" :list-extensions="listExtensions" />
|
||||
<UploadSelect
|
||||
v-if="listDbKeys !== null"
|
||||
v-if="listDbKeys"
|
||||
class="upload-genome"
|
||||
:value="dbKey"
|
||||
:disabled="isDisabled"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export const defaultModel = {
|
||||
dbKey: "?",
|
||||
deferred: false,
|
||||
enabled: true,
|
||||
extension: "auto",
|
||||
fileContent: "",
|
||||
fileData: null,
|
||||
fileMode: "",
|
||||
fileName: "",
|
||||
filePath: "",
|
||||
fileSize: 0,
|
||||
fileUri: null,
|
||||
info: null,
|
||||
optional: false,
|
||||
outputs: null,
|
||||
percentage: 0,
|
||||
spaceToTab: false,
|
||||
status: "init",
|
||||
toPosixLines: true,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface UploadFile {
|
||||
mode: string | null;
|
||||
name: string | null;
|
||||
size: number | null;
|
||||
uri?: string | null;
|
||||
path?: string | null;
|
||||
label?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadItem {
|
||||
dbKey: string;
|
||||
deferred: boolean;
|
||||
enabled: boolean;
|
||||
extension: string;
|
||||
fileContent: string;
|
||||
fileData: object | null;
|
||||
fileMode: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
fileUri?: string | null;
|
||||
info?: string | null;
|
||||
optional: boolean;
|
||||
outputs: object | null;
|
||||
percentage: number;
|
||||
spaceToTab: boolean;
|
||||
status: string;
|
||||
targetHistoryId?: string;
|
||||
toPosixLines: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const defaultModel: UploadItem = {
|
||||
dbKey: "?",
|
||||
deferred: false,
|
||||
enabled: true,
|
||||
extension: "auto",
|
||||
fileContent: "",
|
||||
fileData: null,
|
||||
fileMode: "",
|
||||
fileName: "",
|
||||
filePath: "",
|
||||
fileSize: 0,
|
||||
fileUri: null,
|
||||
info: null,
|
||||
optional: false,
|
||||
outputs: null,
|
||||
percentage: 0,
|
||||
spaceToTab: false,
|
||||
status: "init",
|
||||
toPosixLines: true,
|
||||
};
|
||||
@@ -82,7 +82,7 @@
|
||||
<span class="node-title">{{ title }}</span>
|
||||
<span class="float-right">
|
||||
<FontAwesomeIcon
|
||||
v-if="isInvocation && invocationStep.headerIcon"
|
||||
v-if="(isInvocation || isPopulatedInput) && invocationStep.headerIcon"
|
||||
:icon="invocationStep.headerIcon"
|
||||
:spin="invocationStep.headerIconSpin" />
|
||||
</span>
|
||||
@@ -101,7 +101,7 @@
|
||||
<div
|
||||
v-else
|
||||
class="node-body position-relative card-body p-0 mx-2"
|
||||
:class="{ 'cursor-pointer': isInvocation }"
|
||||
:class="{ 'cursor-pointer': isInvocation || isPopulatedInput }"
|
||||
@pointerdown.exact="onPointerDown"
|
||||
@pointerup.exact="onPointerUp"
|
||||
@click.shift.capture.prevent.stop="toggleSelected"
|
||||
@@ -122,15 +122,15 @@
|
||||
:readonly="readonly"
|
||||
@onChange="onChange" />
|
||||
<div v-if="!isInvocation && showRule" class="rule" />
|
||||
<NodeInvocationText v-if="isInvocation" :invocation-step="invocationStep" />
|
||||
<NodeInvocationText v-if="isInvocation || isPopulatedInput" :invocation-step="invocationStep" />
|
||||
<NodeOutput
|
||||
v-for="(output, index) in outputs"
|
||||
:key="`out-${index}-${output.name}`"
|
||||
:class="isInvocation && 'invocation-node-output'"
|
||||
:class="(isInvocation || isPopulatedInput) && 'invocation-node-output'"
|
||||
:output="output"
|
||||
:workflow-outputs="workflowOutputs"
|
||||
:post-job-actions="postJobActions"
|
||||
:blank="isInvocation"
|
||||
:blank="isInvocation || isPopulatedInput"
|
||||
:step-id="id"
|
||||
:step-type="step.type"
|
||||
:step-position="step.position ?? { top: 0, left: 0 }"
|
||||
@@ -167,6 +167,7 @@ import { useWorkflowNodeInspectorStore } from "@/stores/workflowNodeInspectorSto
|
||||
import type { InputTerminalSource, OutputTerminalSource, Step } from "@/stores/workflowStepStore";
|
||||
import { composedPartialPath, isClickable } from "@/utils/dom";
|
||||
|
||||
import { isWorkflowInput } from "../constants";
|
||||
import { ToggleStepSelectedAction } from "./Actions/stepActions";
|
||||
import type { OutputTerminals } from "./modules/terminals";
|
||||
|
||||
@@ -198,6 +199,7 @@ const props = defineProps({
|
||||
highlight: { type: Boolean, default: false },
|
||||
isInvocation: { type: Boolean, default: false },
|
||||
readonly: { type: Boolean, default: false },
|
||||
populatedInputs: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -245,6 +247,14 @@ const isEnabled = getGalaxyInstance().config.enable_tool_recommendations; // get
|
||||
|
||||
const isActive = computed(() => 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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BFormInput } from "bootstrap-vue";
|
||||
import { sanitize } from "dompurify";
|
||||
|
||||
import { isWorkflowInput } from "@/components/Workflow/constants";
|
||||
import { type GraphStep, iconClasses, statePlaceholders } from "@/composables/useInvocationGraph";
|
||||
@@ -8,6 +10,14 @@ import { type GraphStep, iconClasses, statePlaceholders } from "@/composables/us
|
||||
const props = defineProps<{
|
||||
invocationStep: GraphStep;
|
||||
}>();
|
||||
|
||||
function isColor(value?: string): boolean {
|
||||
return value ? value.match(/^#[0-9a-f]{6}$/i) !== null : false;
|
||||
}
|
||||
|
||||
function textHtml(value: string): string {
|
||||
return sanitize(value, { ALLOWED_TAGS: ["b"] });
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="p-1 unselectable">
|
||||
@@ -30,8 +40,19 @@ const props = defineProps<{
|
||||
<FontAwesomeIcon :icon="props.invocationStep.nodeText ? faCheckSquare : faSquare" />
|
||||
{{ props.invocationStep.nodeText }}
|
||||
</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-else-if="props.invocationStep.nodeText !== undefined" v-html="props.invocationStep.nodeText" />
|
||||
<span v-else-if="isColor(props.invocationStep.nodeText)" class="d-flex align-items-center">
|
||||
<i> {{ props.invocationStep.nodeText }}: </i>
|
||||
<BFormInput
|
||||
:value="props.invocationStep.nodeText"
|
||||
class="ml-1 p-0 color-input"
|
||||
type="color"
|
||||
size="sm"
|
||||
readonly />
|
||||
</span>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<span
|
||||
v-else-if="props.invocationStep.nodeText !== undefined"
|
||||
v-html="textHtml(props.invocationStep.nodeText)" />
|
||||
<span v-else>This is an input</span>
|
||||
</div>
|
||||
<div v-else-if="props.invocationStep.type === 'subworkflow'">This is a subworkflow.</div>
|
||||
@@ -44,5 +65,9 @@ const props = defineProps<{
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.color-input {
|
||||
max-height: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,6 +37,8 @@ const props = defineProps({
|
||||
isInvocation: { type: Boolean, default: false },
|
||||
showMinimap: { type: Boolean, default: true },
|
||||
showZoomControls: { type: Boolean, default: true },
|
||||
fixedHeight: { type: Number, default: undefined },
|
||||
populatedInputs: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { stateStore, stepStore } = useWorkflowStores();
|
||||
@@ -169,7 +171,7 @@ defineExpose({
|
||||
id="canvas-container"
|
||||
ref="canvas"
|
||||
class="canvas-content"
|
||||
:class="props.isInvocation ? 'fixed-window-height' : 'h-100'"
|
||||
:style="{ height: props.fixedHeight ? `${props.fixedHeight}vh` : '100%' }"
|
||||
@drop.prevent
|
||||
@dragover.prevent>
|
||||
<AdaptiveGrid
|
||||
@@ -197,6 +199,7 @@ defineExpose({
|
||||
:scale="scale"
|
||||
:readonly="readonly"
|
||||
:is-invocation="props.isInvocation"
|
||||
:populated-inputs="props.populatedInputs"
|
||||
@pan-by="panBy"
|
||||
@stopDragging="onStopDragging"
|
||||
@onDragConnector="onDragConnector"
|
||||
@@ -236,11 +239,6 @@ defineExpose({
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
overflow: hidden;
|
||||
|
||||
/* TODO: w/out this, canvas height = 0 when width goes beyond a point (invocation graph) */
|
||||
&.fixed-window-height {
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.node-area {
|
||||
|
||||
@@ -15,11 +15,11 @@ import { storeToRefs } from "pinia";
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import type { WorkflowInvocationElementView } from "@/api/invocations";
|
||||
import type { StoredWorkflowDetailed } from "@/api/workflows";
|
||||
import { useDatatypesMapper } from "@/composables/datatypesMapper";
|
||||
import { useInvocationGraph } from "@/composables/useInvocationGraph";
|
||||
import { useWorkflowStateStore } from "@/stores/workflowEditorStateStore";
|
||||
import type { Step } from "@/stores/workflowStepStore";
|
||||
import type { Workflow } from "@/stores/workflowStore";
|
||||
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
/** The invocation to display */
|
||||
invocation: WorkflowInvocationElementView;
|
||||
/** The workflow which was run */
|
||||
workflow: Workflow;
|
||||
workflow: StoredWorkflowDetailed;
|
||||
/** Whether the invocation is terminal */
|
||||
isTerminal: boolean;
|
||||
/** The zoom level for the graph */
|
||||
@@ -120,7 +120,7 @@ const initialPosition = computed(() => ({
|
||||
}));
|
||||
|
||||
function activeStepFor(activeNodeId: number): Step {
|
||||
return props.workflow.steps[activeNodeId] as Step;
|
||||
return props.workflow.steps[activeNodeId] as unknown as Step;
|
||||
}
|
||||
|
||||
/** Updates and loads the invocation graph */
|
||||
@@ -194,6 +194,7 @@ function stepClicked(nodeId: number | null) {
|
||||
:scroll-to-id="activeNodeId"
|
||||
:show-minimap="props.showMinimap"
|
||||
:show-zoom-controls="props.showZoomControls"
|
||||
:fixed-height="60"
|
||||
is-invocation
|
||||
readonly
|
||||
@stepClicked="stepClicked" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { BModal } from "bootstrap-vue";
|
||||
import { reactive, ref } from "vue";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import type { StoredWorkflowDetailed } from "@/api/workflows";
|
||||
import type { Workflow } from "@/components/Workflow/workflows.services";
|
||||
|
||||
import type { SelectedWorkflow } from "./types";
|
||||
@@ -40,6 +41,10 @@ const emit = defineEmits<{
|
||||
(e: "insertWorkflowSteps", id: string, stepCount: number): void;
|
||||
}>();
|
||||
|
||||
/** `props.workflows` typed as `StoredWorkflowDetailed[]` because that is the type expected by `WorkflowCard` */
|
||||
const detailedWorkflows = computed(() => props.workflows as unknown as StoredWorkflowDetailed[]);
|
||||
// TODO: Ideally, components like `WorkflowCard` should accept a less specific type, like `Workflow` instead of `StoredWorkflowDetailed`
|
||||
|
||||
const modalOptions = reactive({
|
||||
rename: {
|
||||
id: "",
|
||||
@@ -70,19 +75,19 @@ function onPreview(id: string) {
|
||||
}
|
||||
|
||||
// TODO: clean-up types, as soon as better Workflow type is available
|
||||
function onInsert(workflow: Workflow) {
|
||||
emit("insertWorkflow", workflow.latest_workflow_id as any, workflow.name as any);
|
||||
function onInsert(workflow: StoredWorkflowDetailed) {
|
||||
emit("insertWorkflow", (workflow as any).latest_workflow_id, workflow.name);
|
||||
}
|
||||
|
||||
function onInsertSteps(workflow: Workflow) {
|
||||
emit("insertWorkflowSteps", workflow.id as any, workflow.number_of_steps as any);
|
||||
function onInsertSteps(workflow: StoredWorkflowDetailed) {
|
||||
emit("insertWorkflowSteps", workflow.id, workflow.number_of_steps as any);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-card-list" :class="{ grid: props.gridView }">
|
||||
<WorkflowCard
|
||||
v-for="workflow in props.workflows"
|
||||
v-for="workflow in detailedWorkflows"
|
||||
:key="workflow.id"
|
||||
:workflow="workflow"
|
||||
:selectable="!publishedView && !editorView"
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faFileImport, faGlobe, faShieldAlt, faUser, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faBuilding,
|
||||
faFileImport,
|
||||
faGlobe,
|
||||
faShieldAlt,
|
||||
faUser,
|
||||
faUserEdit,
|
||||
faUsers,
|
||||
type IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BBadge, BButton } from "bootstrap-vue";
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router/composables";
|
||||
|
||||
import type { Creator, StoredWorkflowDetailed } from "@/api/workflows";
|
||||
import { useToast } from "@/composables/toast";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import { copy } from "@/utils/clipboard";
|
||||
import { isUrl } from "@/utils/url";
|
||||
|
||||
import UtcDate from "@/components/UtcDate.vue";
|
||||
|
||||
library.add(faFileImport, faGlobe, faShieldAlt, faUsers, faUser);
|
||||
|
||||
// TODO: replace me with a proper definition
|
||||
type Workflow = any;
|
||||
interface BadgeData {
|
||||
name: string;
|
||||
url?: string;
|
||||
icon: IconDefinition;
|
||||
title?: string;
|
||||
class?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workflow: Workflow;
|
||||
workflow: StoredWorkflowDetailed;
|
||||
publishedView: boolean;
|
||||
noEditTime?: boolean;
|
||||
filterable?: boolean;
|
||||
@@ -64,22 +77,51 @@ const sourceType = computed(() => {
|
||||
|
||||
const sourceTitle = computed(() => {
|
||||
if (sourceType.value.includes("trs")) {
|
||||
return `Imported from TRS ID (version: ${props.workflow.source_metadata.trs_version_id}). Click to copy ID`;
|
||||
return `Imported from TRS ID (version: ${props.workflow.source_metadata?.trs_version_id}). Click to copy ID`;
|
||||
} else if (sourceType.value == "url") {
|
||||
return `Imported from ${props.workflow.source_metadata.url}. Click to copy link`;
|
||||
return `Imported from ${props.workflow.source_metadata?.url}. Click to copy link`;
|
||||
} else {
|
||||
return `Imported from ${props.workflow.source_type}`;
|
||||
return `Imported from ${(props.workflow as any).source_type}`;
|
||||
}
|
||||
});
|
||||
|
||||
const creatorBadges = computed<BadgeData[] | undefined>(() => {
|
||||
return props.workflow.creator
|
||||
?.map((creator: Creator) => {
|
||||
if (!creator.name) {
|
||||
return;
|
||||
}
|
||||
let url: string | undefined;
|
||||
let titleEnd = "Workflow Creator";
|
||||
|
||||
if (creator.url && isUrl(creator.url)) {
|
||||
url = creator.url;
|
||||
}
|
||||
const orcidRegex = /^https:\/\/orcid\.org\/\d{4}-\d{4}-\d{4}-\d{3}[0-9X]$/;
|
||||
if (creator.identifier && orcidRegex.test(creator.identifier)) {
|
||||
url = creator.identifier;
|
||||
titleEnd = "ORCID profile";
|
||||
}
|
||||
|
||||
return {
|
||||
name: creator.name,
|
||||
url,
|
||||
icon: creator.class === "Organization" ? faBuilding : faUserEdit,
|
||||
title: `${url ? "Click to view " : ""}${titleEnd}`,
|
||||
class: { "cursor-pointer": !!url, "outline-badge": !!url },
|
||||
};
|
||||
})
|
||||
.filter((b) => !!b);
|
||||
});
|
||||
|
||||
const { success } = useToast();
|
||||
|
||||
function onCopyLink() {
|
||||
if (sourceType.value == "url") {
|
||||
copy(props.workflow.source_metadata.url);
|
||||
copy(props.workflow.source_metadata?.url);
|
||||
success("URL copied");
|
||||
} else if (sourceType.value.includes("trs")) {
|
||||
copy(props.workflow.source_metadata.trs_tool_id);
|
||||
copy(props.workflow.source_metadata?.trs_tool_id);
|
||||
success("TRS ID copied");
|
||||
}
|
||||
}
|
||||
@@ -171,6 +213,22 @@ function getStepText(steps: number) {
|
||||
<FontAwesomeIcon :icon="faUser" size="sm" fixed-width />
|
||||
<span class="font-weight-bold"> {{ workflow.owner }} </span>
|
||||
</BBadge>
|
||||
|
||||
<template v-if="creatorBadges?.length">
|
||||
<BBadge
|
||||
v-for="creator in creatorBadges"
|
||||
:key="creator.name"
|
||||
v-b-tooltip.noninteractive.hover
|
||||
data-description="external creator badge"
|
||||
class="mx-1"
|
||||
:class="creator.class"
|
||||
:title="creator.title"
|
||||
:href="creator.url"
|
||||
target="_blank">
|
||||
<FontAwesomeIcon :icon="creator.icon" size="sm" fixed-width />
|
||||
<span class="font-weight-bold"> {{ creator.name }} </span>
|
||||
</BBadge>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ import { type AxiosError } from "axios";
|
||||
import { BAlert, BButton, BCard } from "bootstrap-vue";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { type WorkflowSummary } from "@/api/workflows";
|
||||
import { type StoredWorkflowDetailed, type WorkflowSummary } from "@/api/workflows";
|
||||
import { fromSimple } from "@/components/Workflow/Editor/modules/model";
|
||||
import { getWorkflowFull, getWorkflowInfo } from "@/components/Workflow/workflows.services";
|
||||
import { useDatatypesMapper } from "@/composables/datatypesMapper";
|
||||
import { provideScopedWorkflowStores } from "@/composables/workflowStores";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import type { Workflow } from "@/stores/workflowStore";
|
||||
import type { Steps } from "@/stores/workflowStepStore";
|
||||
import { assertDefined } from "@/utils/assertions";
|
||||
import { withPrefix } from "@/utils/redirect";
|
||||
|
||||
@@ -60,7 +60,7 @@ const { stateStore } = provideScopedWorkflowStores(props.id);
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const workflowInfo = ref<WorkflowSummary>();
|
||||
const workflow = ref<Workflow | null>(null);
|
||||
const workflow = ref<StoredWorkflowDetailed | null>(null);
|
||||
|
||||
const hasError = computed(() => !!errorMessage.value);
|
||||
|
||||
@@ -91,6 +91,9 @@ const editButtonTitle = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Workflow steps force typed as `Steps` from the `workflowStepStore` */
|
||||
const workflowSteps = computed(() => (workflow.value?.steps as unknown as Steps) ?? []);
|
||||
|
||||
function logInTitle(title: string) {
|
||||
if (userStore.isAnonymous) {
|
||||
return `Log in to ${title}`;
|
||||
@@ -233,7 +236,7 @@ onMounted(async () => {
|
||||
<WorkflowGraph
|
||||
v-if="workflow && datatypesMapper"
|
||||
ref="workflowGraph"
|
||||
:steps="workflow.steps"
|
||||
:steps="workflowSteps"
|
||||
:datatypes-mapper="datatypesMapper"
|
||||
:initial-position="initialPosition"
|
||||
:show-minimap="props.showMinimap"
|
||||
|
||||
@@ -208,7 +208,7 @@ defineExpose({
|
||||
v-else-if="invocations.length > 0"
|
||||
:invocations="invocations"
|
||||
:workflow-name="workflowName" />
|
||||
<div v-else class="ui-form-composite">
|
||||
<div v-else class="h-100">
|
||||
<BAlert
|
||||
v-if="hasUpgradeMessages || hasStepVersionChanges"
|
||||
class="mb-4"
|
||||
@@ -223,7 +223,7 @@ defineExpose({
|
||||
<BLink v-else @click="onImport">click here to import the workflow and review the issues</BLink>
|
||||
<span>before running this workflow.</span>
|
||||
</BAlert>
|
||||
<div v-else>
|
||||
<div v-else class="h-100">
|
||||
<BAlert
|
||||
v-if="submissionError"
|
||||
class="mb-4"
|
||||
@@ -247,7 +247,8 @@ defineExpose({
|
||||
:model="workflowModel"
|
||||
:can-mutate-current-history="canRunOnHistory"
|
||||
@submissionSuccess="handleInvocations"
|
||||
@submissionError="handleSubmissionError" />
|
||||
@submissionError="handleSubmissionError"
|
||||
@showSimple="advancedForm = false" />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -8,13 +8,22 @@
|
||||
</BAlert>
|
||||
<div class="h4 clearfix mb-3">
|
||||
<b>Workflow: {{ model.name }}</b> <i>(version: {{ model.runData.version + 1 }})</i>
|
||||
<ButtonSpinner
|
||||
id="run-workflow"
|
||||
class="float-right"
|
||||
title="Run Workflow"
|
||||
:disabled="!canRunOnHistory"
|
||||
:wait="showExecuting"
|
||||
@onClick="onExecute" />
|
||||
<div class="float-right d-flex flex-gapx-1">
|
||||
<b-button
|
||||
v-b-tooltip.hover.noninteractive
|
||||
variant="link"
|
||||
class="text-decoration-none"
|
||||
title="Use simplified run form instead"
|
||||
@click="$emit('showSimple')">
|
||||
<span class="fas fa-arrow-left" /> Simple Form
|
||||
</b-button>
|
||||
<ButtonSpinner
|
||||
id="run-workflow"
|
||||
title="Run Workflow"
|
||||
:disabled="!canRunOnHistory"
|
||||
:wait="showExecuting"
|
||||
@onClick="onExecute" />
|
||||
</div>
|
||||
</div>
|
||||
<FormCard v-if="wpInputsAvailable" title="Workflow Parameters">
|
||||
<template v-slot:body>
|
||||
|
||||
@@ -1,7 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { faSitemap } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton, BDropdown, BDropdownForm, BFormCheckbox } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { allowCachedJobs } from "@/components/Tool/utilities";
|
||||
import { isWorkflowInput } from "@/components/Workflow/constants";
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { usePanels } from "@/composables/usePanels";
|
||||
import { provideScopedWorkflowStores } from "@/composables/workflowStores";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
import { invokeWorkflow } from "./services";
|
||||
|
||||
import WorkflowAnnotation from "../WorkflowAnnotation.vue";
|
||||
import WorkflowNavigationTitle from "../WorkflowNavigationTitle.vue";
|
||||
import WorkflowRunGraph from "./WorkflowRunGraph.vue";
|
||||
import WorkflowStorageConfiguration from "./WorkflowStorageConfiguration.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import FormDisplay from "@/components/Form/FormDisplay.vue";
|
||||
|
||||
interface Props {
|
||||
model: Record<string, any>;
|
||||
targetHistory?: string;
|
||||
useJobCache?: boolean;
|
||||
canMutateCurrentHistory: boolean;
|
||||
requestState?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
targetHistory: "current",
|
||||
useJobCache: false,
|
||||
requestState: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "showAdvanced"): void;
|
||||
(e: "submissionSuccess", invocations: any): void;
|
||||
(e: "submissionError", error: string): void;
|
||||
}>();
|
||||
|
||||
const { stateStore } = provideScopedWorkflowStores(props.model.workflowId);
|
||||
const { activeNodeId } = storeToRefs(stateStore);
|
||||
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
const { showPanels } = usePanels();
|
||||
|
||||
const formData = ref<Record<string, any>>({});
|
||||
const inputTypes = ref<Record<string, string>>({});
|
||||
const stepValidation = ref<[string, string] | null>(null);
|
||||
const sendToNewHistory = ref(props.targetHistory === "new" || props.targetHistory === "prefer_new");
|
||||
const useCachedJobs = ref(props.useJobCache);
|
||||
const splitObjectStore = ref(false);
|
||||
const preferredObjectStoreId = ref<string | null>(null);
|
||||
const preferredIntermediateObjectStoreId = ref<string | null>(null);
|
||||
const waitingForRequest = ref(false);
|
||||
// TODO: Once we add readme/help to the right side of the form, we can default the unified `showRightPanel`
|
||||
// (panel that toggles between readme/help or graph) to `true` if the readme/help exists, and if no
|
||||
// readme/help exists, it will be `false`.
|
||||
const showGraph = ref(!showPanels.value);
|
||||
|
||||
watch(
|
||||
() => showGraph.value,
|
||||
(show) => {
|
||||
if (!show) {
|
||||
activeNodeId.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
const computedActiveNodeId = computed<number | undefined>(() => {
|
||||
if (showGraph.value) {
|
||||
if (activeNodeId.value !== null && activeNodeId.value !== undefined) {
|
||||
return activeNodeId.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const formInputs = computed(() => {
|
||||
const inputs = [] as any[];
|
||||
// Add workflow parameters.
|
||||
Object.values(props.model.wpInputs).forEach((input) => {
|
||||
const inputCopy = Object.assign({}, input) as any;
|
||||
// do we want to keep the color if we're not showing steps?
|
||||
inputCopy.color = undefined;
|
||||
inputs.push(inputCopy);
|
||||
inputTypes.value[inputCopy.name] = "replacement_parameter";
|
||||
});
|
||||
// Add actual input modules.
|
||||
props.model.steps.forEach((step: any, i: number) => {
|
||||
if (isWorkflowInput(step.step_type)) {
|
||||
const stepName = new String(step.step_index) as any;
|
||||
const stepLabel = step.step_label || new String(step.step_index + 1);
|
||||
const stepType = step.step_type;
|
||||
const help = step.annotation;
|
||||
const longFormInput = step.inputs[0];
|
||||
const stepAsInput = Object.assign({}, longFormInput, {
|
||||
name: stepName,
|
||||
help: help,
|
||||
label: stepLabel,
|
||||
});
|
||||
if (props.requestState && props.requestState[stepLabel]) {
|
||||
const value = props.requestState[stepLabel];
|
||||
stepAsInput.value = value;
|
||||
}
|
||||
// disable collection mapping...
|
||||
stepAsInput.flavor = "module";
|
||||
inputs.push(stepAsInput);
|
||||
inputTypes.value[stepName] = stepType;
|
||||
}
|
||||
});
|
||||
return inputs;
|
||||
});
|
||||
|
||||
const hasValidationErrors = computed(() => stepValidation.value !== null);
|
||||
|
||||
const canRunOnHistory = computed(() => props.canMutateCurrentHistory || sendToNewHistory.value);
|
||||
|
||||
function onValidation(validation: [string, string] | null) {
|
||||
if (validation && validation.length == 2) {
|
||||
stepValidation.value = [validation[0], validation[1]];
|
||||
} else {
|
||||
stepValidation.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reuseAllowed(user: any) {
|
||||
return user && allowCachedJobs(user.preferences);
|
||||
}
|
||||
|
||||
function showRuntimeSettings(user: any) {
|
||||
return props.targetHistory && (props.targetHistory.indexOf("prefer") >= 0 || (user && reuseAllowed(user)));
|
||||
}
|
||||
|
||||
function onChange(data: any) {
|
||||
formData.value = data;
|
||||
}
|
||||
|
||||
function onStorageUpdate(objectStoreId: string, intermediate: boolean) {
|
||||
if (intermediate) {
|
||||
preferredIntermediateObjectStoreId.value = objectStoreId;
|
||||
} else {
|
||||
preferredObjectStoreId.value = objectStoreId;
|
||||
}
|
||||
}
|
||||
|
||||
async function onExecute() {
|
||||
waitingForRequest.value = true;
|
||||
|
||||
const replacementParams: Record<string, any> = {};
|
||||
const inputs: Record<string, any> = {};
|
||||
for (const inputName in formData.value) {
|
||||
const value = formData.value[inputName];
|
||||
const inputType = inputTypes.value[inputName];
|
||||
if (inputType == "replacement_parameter") {
|
||||
replacementParams[inputName] = value;
|
||||
} else if (inputType && isWorkflowInput(inputType)) {
|
||||
inputs[inputName] = value;
|
||||
}
|
||||
}
|
||||
const data: Record<string, any> = {
|
||||
replacement_dict: replacementParams,
|
||||
inputs: inputs,
|
||||
inputs_by: "step_index",
|
||||
batch: true,
|
||||
use_cached_job: useCachedJobs.value,
|
||||
require_exact_tool_versions: false,
|
||||
version: props.model.runData.version,
|
||||
};
|
||||
if (sendToNewHistory.value) {
|
||||
data.new_history_name = props.model.name;
|
||||
} else {
|
||||
data.history_id = props.model.historyId;
|
||||
}
|
||||
if (splitObjectStore.value) {
|
||||
if (preferredObjectStoreId.value != null) {
|
||||
data.preferred_outputs_object_store_id = preferredObjectStoreId.value;
|
||||
}
|
||||
if (preferredIntermediateObjectStoreId.value != null && splitObjectStore.value) {
|
||||
data.preferred_intermediate_object_store_id = preferredIntermediateObjectStoreId.value;
|
||||
}
|
||||
} else {
|
||||
if (preferredObjectStoreId.value != null) {
|
||||
data.preferred_object_store_id = preferredObjectStoreId.value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const invocations = await invokeWorkflow(props.model.workflowId, data);
|
||||
emit("submissionSuccess", invocations);
|
||||
} catch (error) {
|
||||
emit("submissionError", errorMessageAsString(error));
|
||||
} finally {
|
||||
waitingForRequest.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui-form-header-underlay sticky-top" />
|
||||
<div v-if="isConfigLoaded" class="sticky-top">
|
||||
<div :class="{ 'd-flex flex-column h-100': showGraph }">
|
||||
<div v-if="!showGraph" class="ui-form-header-underlay sticky-top" />
|
||||
<div v-if="isConfigLoaded" :class="{ 'sticky-top': !showGraph }">
|
||||
<BAlert v-if="!canRunOnHistory" variant="warning" show>
|
||||
<span v-localize>
|
||||
The workflow cannot run because the current history is immutable. Please select a different history
|
||||
@@ -14,7 +216,16 @@
|
||||
:run-waiting="waitingForRequest"
|
||||
@on-execute="onExecute">
|
||||
<template v-slot:workflow-title-actions>
|
||||
<b-dropdown
|
||||
<BButton
|
||||
v-b-tooltip.hover.noninteractive.html
|
||||
size="sm"
|
||||
:title="!showGraph ? 'Show workflow graph' : 'Hide workflow graph'"
|
||||
variant="link"
|
||||
:pressed="showGraph"
|
||||
@click="showGraph = !showGraph">
|
||||
<FontAwesomeIcon :icon="faSitemap" fixed-width />
|
||||
</BButton>
|
||||
<BDropdown
|
||||
v-if="showRuntimeSettings(currentUser)"
|
||||
id="dropdown-form"
|
||||
ref="dropdown"
|
||||
@@ -26,21 +237,25 @@
|
||||
<template v-slot:button-content>
|
||||
<span class="fa fa-cog" />
|
||||
</template>
|
||||
<b-dropdown-form>
|
||||
<b-form-checkbox v-model="sendToNewHistory" class="workflow-run-settings-target">
|
||||
<BDropdownForm>
|
||||
<BFormCheckbox v-model="sendToNewHistory" class="workflow-run-settings-target">
|
||||
Send results to a new history
|
||||
</b-form-checkbox>
|
||||
<b-form-checkbox
|
||||
</BFormCheckbox>
|
||||
<BFormCheckbox
|
||||
v-if="reuseAllowed(currentUser)"
|
||||
v-model="useCachedJobs"
|
||||
title="This may skip executing jobs that you have already run.">
|
||||
Attempt to re-use jobs with identical parameters?
|
||||
</b-form-checkbox>
|
||||
<b-form-checkbox
|
||||
</BFormCheckbox>
|
||||
<BFormCheckbox
|
||||
v-if="isConfigLoaded && config.object_store_allows_id_selection"
|
||||
v-model="splitObjectStore">
|
||||
Send outputs and intermediate to different storage locations?
|
||||
</b-form-checkbox>
|
||||
</BFormCheckbox>
|
||||
<!-- Options to default one way or the other, disable if admins want, etc.. -->
|
||||
<BFormCheckbox class="workflow-expand-form-link" @change="emit('showAdvanced')">
|
||||
Expand to full workflow form.
|
||||
</BFormCheckbox>
|
||||
<WorkflowStorageConfiguration
|
||||
v-if="isConfigLoaded && config.object_store_allows_id_selection"
|
||||
:split-object-store="splitObjectStore"
|
||||
@@ -48,203 +263,42 @@
|
||||
:invocation-intermediate-preferred-object-store-id="preferredIntermediateObjectStoreId"
|
||||
@updated="onStorageUpdate">
|
||||
</WorkflowStorageConfiguration>
|
||||
</b-dropdown-form>
|
||||
</b-dropdown>
|
||||
</BDropdownForm>
|
||||
</BDropdown>
|
||||
</template>
|
||||
</WorkflowNavigationTitle>
|
||||
</div>
|
||||
|
||||
<WorkflowAnnotation :workflow-id="model.runData.workflow_id" :history-id="model.historyId" show-details />
|
||||
<FormDisplay
|
||||
:inputs="formInputs"
|
||||
:allow-empty-value-on-required-input="true"
|
||||
@onChange="onChange"
|
||||
@onValidation="onValidation" />
|
||||
<!-- Options to default one way or the other, disable if admins want, etc.. -->
|
||||
<a href="#" class="workflow-expand-form-link" @click="$emit('showAdvanced')">Expand to full workflow form.</a>
|
||||
|
||||
<div class="overflow-auto h-100">
|
||||
<div class="d-flex h-100">
|
||||
<div
|
||||
:class="showGraph ? 'w-50 flex-grow-1' : 'w-100'"
|
||||
:style="{ 'overflow-y': 'auto', 'overflow-x': 'hidden' }">
|
||||
<div v-if="showGraph" class="ui-form-header-underlay sticky-top" />
|
||||
<Heading v-if="showGraph" class="sticky-top" h2 separator bold size="sm"> Parameters </Heading>
|
||||
<FormDisplay
|
||||
:inputs="formInputs"
|
||||
:allow-empty-value-on-required-input="true"
|
||||
:sync-with-graph="showGraph"
|
||||
:active-node-id="computedActiveNodeId"
|
||||
workflow-run
|
||||
@onChange="onChange"
|
||||
@onValidation="onValidation"
|
||||
@update:active-node-id="($event) => (activeNodeId = $event)" />
|
||||
</div>
|
||||
<div v-if="showGraph" class="h-100 w-50 d-flex flex-shrink-0">
|
||||
<WorkflowRunGraph
|
||||
v-if="isConfigLoaded"
|
||||
:workflow-id="model.workflowId"
|
||||
:step-validation="stepValidation || undefined"
|
||||
:stored-id="model.runData.workflow_id"
|
||||
:version="model.runData.version"
|
||||
:inputs="formData"
|
||||
:form-inputs="formInputs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormDisplay from "components/Form/FormDisplay";
|
||||
import { allowCachedJobs } from "components/Tool/utilities";
|
||||
import { isWorkflowInput } from "components/Workflow/constants";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { errorMessageAsString } from "utils/simple-error";
|
||||
import Vue from "vue";
|
||||
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
import { invokeWorkflow } from "./services";
|
||||
import WorkflowStorageConfiguration from "./WorkflowStorageConfiguration";
|
||||
|
||||
import WorkflowAnnotation from "../../Workflow/WorkflowAnnotation.vue";
|
||||
import WorkflowNavigationTitle from "../WorkflowNavigationTitle.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormDisplay,
|
||||
WorkflowAnnotation,
|
||||
WorkflowNavigationTitle,
|
||||
WorkflowStorageConfiguration,
|
||||
},
|
||||
props: {
|
||||
model: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
targetHistory: {
|
||||
type: String,
|
||||
default: "current",
|
||||
},
|
||||
useJobCache: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canMutateCurrentHistory: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
requestState: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { config, isConfigLoaded } = useConfig(true);
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
return { config, isConfigLoaded, currentUser };
|
||||
},
|
||||
data() {
|
||||
const newHistory = this.targetHistory == "new" || this.targetHistory == "prefer_new";
|
||||
return {
|
||||
formData: {},
|
||||
inputTypes: {},
|
||||
stepValidations: {},
|
||||
sendToNewHistory: newHistory,
|
||||
useCachedJobs: this.useJobCache, // TODO:
|
||||
splitObjectStore: false,
|
||||
preferredObjectStoreId: null,
|
||||
preferredIntermediateObjectStoreId: null,
|
||||
waitingForRequest: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
formInputs() {
|
||||
const inputs = [];
|
||||
// Add workflow parameters.
|
||||
Object.values(this.model.wpInputs).forEach((input) => {
|
||||
const inputCopy = Object.assign({}, input);
|
||||
// do we want to keep the color if we're not showing steps?
|
||||
inputCopy.color = undefined;
|
||||
inputs.push(inputCopy);
|
||||
this.inputTypes[inputCopy.name] = "replacement_parameter";
|
||||
});
|
||||
// Add actual input modules.
|
||||
this.model.steps.forEach((step, i) => {
|
||||
if (isWorkflowInput(step.step_type)) {
|
||||
const stepName = new String(step.step_index);
|
||||
const stepLabel = step.step_label || new String(step.step_index + 1);
|
||||
const stepType = step.step_type;
|
||||
const help = step.annotation;
|
||||
const longFormInput = step.inputs[0];
|
||||
const stepAsInput = Object.assign({}, longFormInput, {
|
||||
name: stepName,
|
||||
help: help,
|
||||
label: stepLabel,
|
||||
});
|
||||
if (this.requestState && this.requestState[stepLabel]) {
|
||||
const value = this.requestState[stepLabel];
|
||||
stepAsInput.value = value;
|
||||
}
|
||||
// disable collection mapping...
|
||||
stepAsInput.flavor = "module";
|
||||
inputs.push(stepAsInput);
|
||||
this.inputTypes[stepName] = stepType;
|
||||
}
|
||||
});
|
||||
return inputs;
|
||||
},
|
||||
hasValidationErrors() {
|
||||
return Boolean(Object.values(this.stepValidations).find((value) => value !== null && value !== undefined));
|
||||
},
|
||||
canRunOnHistory() {
|
||||
return this.canMutateCurrentHistory || this.sendToNewHistory;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onValidation(validation) {
|
||||
if (validation) {
|
||||
Vue.set(this.stepValidations, validation[0], validation[1]);
|
||||
} else {
|
||||
this.stepValidations = {};
|
||||
}
|
||||
},
|
||||
reuseAllowed(user) {
|
||||
return user && allowCachedJobs(user.preferences);
|
||||
},
|
||||
showRuntimeSettings(user) {
|
||||
return this.targetHistory.indexOf("prefer") >= 0 || (user && this.reuseAllowed(user));
|
||||
},
|
||||
onChange(data) {
|
||||
this.formData = data;
|
||||
},
|
||||
onStorageUpdate: function (objectStoreId, intermediate) {
|
||||
if (intermediate) {
|
||||
this.preferredIntermediateObjectStoreId = objectStoreId;
|
||||
} else {
|
||||
this.preferredObjectStoreId = objectStoreId;
|
||||
}
|
||||
},
|
||||
onExecute() {
|
||||
const replacementParams = {};
|
||||
const inputs = {};
|
||||
for (const inputName in this.formData) {
|
||||
const value = this.formData[inputName];
|
||||
const inputType = this.inputTypes[inputName];
|
||||
if (inputType == "replacement_parameter") {
|
||||
replacementParams[inputName] = value;
|
||||
} else if (isWorkflowInput(inputType)) {
|
||||
inputs[inputName] = value;
|
||||
}
|
||||
}
|
||||
const data = {
|
||||
replacement_dict: replacementParams,
|
||||
inputs: inputs,
|
||||
inputs_by: "step_index",
|
||||
batch: true,
|
||||
use_cached_job: this.useCachedJobs,
|
||||
require_exact_tool_versions: false,
|
||||
version: this.model.runData.version,
|
||||
};
|
||||
if (this.sendToNewHistory) {
|
||||
data.new_history_name = this.model.name;
|
||||
} else {
|
||||
data.history_id = this.model.historyId;
|
||||
}
|
||||
if (this.splitObjectStore) {
|
||||
if (this.preferredObjectStoreId != null) {
|
||||
data.preferred_outputs_object_store_id = this.preferredObjectStoreId;
|
||||
}
|
||||
if (this.preferredIntermediateObjectStoreId != null && this.splitObjectStore) {
|
||||
data.preferred_intermediate_object_store_id = this.preferredIntermediateObjectStoreId;
|
||||
}
|
||||
} else {
|
||||
if (this.preferredObjectStoreId != null) {
|
||||
data.preferred_object_store_id = this.preferredObjectStoreId;
|
||||
}
|
||||
}
|
||||
this.waitingForRequest = true;
|
||||
invokeWorkflow(this.model.workflowId, data)
|
||||
.then((invocations) => {
|
||||
this.$emit("submissionSuccess", invocations);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$emit("submissionError", errorMessageAsString(error));
|
||||
})
|
||||
.finally(() => (this.waitingForRequest = false));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { BAlert, BCard } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ref, toRef } from "vue";
|
||||
|
||||
import { type DataInput, useWorkflowRunGraph } from "@/composables/useWorkflowRunGraph";
|
||||
import { useDatatypesMapperStore } from "@/stores/datatypesMapperStore";
|
||||
import { useWorkflowStateStore } from "@/stores/workflowEditorStateStore";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
import WorkflowGraph from "@/components/Workflow/Editor/WorkflowGraph.vue";
|
||||
|
||||
interface Props {
|
||||
workflowId: string;
|
||||
storedId: string;
|
||||
version: number;
|
||||
inputs: Record<string, DataInput | null>;
|
||||
stepValidation?: [string, string];
|
||||
formInputs: any[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const errorMessage = ref<string | null>(null);
|
||||
|
||||
const { activeNodeId } = storeToRefs(useWorkflowStateStore(props.workflowId));
|
||||
|
||||
const datatypesMapperStore = useDatatypesMapperStore();
|
||||
const { datatypesMapper, loading: datatypesMapperLoading } = storeToRefs(datatypesMapperStore);
|
||||
|
||||
const { steps, loading, loadWorkflowOntoGraph } = useWorkflowRunGraph(
|
||||
props.workflowId,
|
||||
props.version,
|
||||
toRef(props, "inputs"),
|
||||
toRef(props, "formInputs"),
|
||||
toRef(props, "stepValidation")
|
||||
);
|
||||
|
||||
try {
|
||||
loadWorkflowOntoGraph();
|
||||
} catch (error) {
|
||||
errorMessage.value = errorMessageAsString(error);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BAlert v-if="errorMessage" variant="danger" show>
|
||||
{{ errorMessage }}
|
||||
</BAlert>
|
||||
<BAlert v-else-if="datatypesMapperLoading || loading" variant="info" show>
|
||||
<LoadingSpan message="Loading workflow" />
|
||||
</BAlert>
|
||||
<div v-else-if="datatypesMapper && !loading" class="d-flex flex-column">
|
||||
<Heading h2 separator bold size="sm"> Graph </Heading>
|
||||
<BCard class="workflow-preview mx-1 flex-grow-1">
|
||||
<WorkflowGraph
|
||||
v-if="steps"
|
||||
:steps="steps"
|
||||
:datatypes-mapper="datatypesMapper"
|
||||
:scroll-to-id="activeNodeId"
|
||||
populated-inputs
|
||||
show-minimap
|
||||
show-zoom-controls
|
||||
readonly />
|
||||
</BCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alert {
|
||||
width: 100%;
|
||||
margin: 2rem;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -8,9 +8,9 @@ import { RouterLink } from "vue-router";
|
||||
|
||||
import { isRegisteredUser } from "@/api";
|
||||
import type { WorkflowInvocationElementView } from "@/api/invocations";
|
||||
import type { StoredWorkflowDetailed } from "@/api/workflows";
|
||||
import { useWorkflowInstance } from "@/composables/useWorkflowInstance";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import type { Workflow } from "@/stores/workflowStore";
|
||||
import localize from "@/utils/localization";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { copyWorkflow } from "./workflows.services";
|
||||
|
||||
import AsyncButton from "../Common/AsyncButton.vue";
|
||||
import ButtonSpinner from "../Common/ButtonSpinner.vue";
|
||||
import LoadingSpan from "../LoadingSpan.vue";
|
||||
import WorkflowRunButton from "./WorkflowRunButton.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -36,7 +37,7 @@ const emit = defineEmits<{
|
||||
(e: "on-execute"): void;
|
||||
}>();
|
||||
|
||||
const { workflow, error } = useWorkflowInstance(props.workflowId);
|
||||
const { workflow, loading, error } = useWorkflowInstance(props.workflowId);
|
||||
|
||||
const { currentUser, isAnonymous } = storeToRefs(useUserStore());
|
||||
const owned = computed(() => {
|
||||
@@ -48,7 +49,7 @@ const owned = computed(() => {
|
||||
});
|
||||
|
||||
const importErrorMessage = ref<string | null>(null);
|
||||
const importedWorkflow = ref<Workflow | null>(null);
|
||||
const importedWorkflow = ref<StoredWorkflowDetailed | null>(null);
|
||||
const workflowImportedAttempted = ref(false);
|
||||
|
||||
async function onImport() {
|
||||
@@ -57,7 +58,7 @@ async function onImport() {
|
||||
}
|
||||
try {
|
||||
const wf = await copyWorkflow(workflow.value.id, workflow.value.owner);
|
||||
importedWorkflow.value = wf as unknown as Workflow;
|
||||
importedWorkflow.value = wf as unknown as StoredWorkflowDetailed;
|
||||
} catch (error) {
|
||||
importErrorMessage.value = errorMessageAsString(error, "Failed to import workflow");
|
||||
} finally {
|
||||
@@ -163,6 +164,9 @@ const workflowImportTitle = computed(() => {
|
||||
Successfully invoked workflow
|
||||
<b>{{ getWorkflowName() }}</b>
|
||||
</div>
|
||||
<BAlert v-else-if="loading" variant="info" show>
|
||||
<LoadingSpan message="Loading workflow details" />
|
||||
</BAlert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,9 +18,12 @@ export function useFileDrop(
|
||||
solo: MaybeRefOrGetter<boolean>,
|
||||
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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<UploadItem[]>,
|
||||
historyId: Ref<string>,
|
||||
enableStart: Ref<boolean>,
|
||||
creatingPairedType: Ref<boolean>
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<UploadConfigurations | null>(() =>
|
||||
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<ExtensionDetails[]>([]);
|
||||
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<DbKey[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<StoredWorkflowDetailed, "steps"> {
|
||||
steps: { [index: number]: GraphStep };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
headerIcon?: IconDefinition;
|
||||
headerIconSpin?: boolean;
|
||||
nodeText?: string | boolean;
|
||||
}
|
||||
|
||||
interface WorkflowRunStep extends Readonly<Step>, 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<Record<string, DataInput | null>>,
|
||||
formInputs: Ref<any[]>,
|
||||
stepValidation: Ref<[string, string] | undefined>
|
||||
) {
|
||||
/** The workflow that is to be run */
|
||||
const loadedWorkflow = ref<any>(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<Record<string, Readonly<Step>>>(() => loadedWorkflow.value?.steps);
|
||||
|
||||
const steps = computed<Record<string, WorkflowRunStep>>(() => {
|
||||
if (!workflowSteps.value || !formInputs.value || !inputs.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, WorkflowRunStep> = {};
|
||||
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}: <b>${item.name}</b>`, 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: <b>${dataInput}</b>`;
|
||||
break;
|
||||
default:
|
||||
text = `<b>${dataInput}</b>`;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<R>({ url, headers, params, errorSimplify = true }: UrlDataOptions): Promise<R> {
|
||||
try {
|
||||
headers = headers || {};
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user