Merge pull request #19294 from ahmedhamidawan/simplified_wf_run

Workflow Run Form Enhancements
This commit is contained in:
Marius van den Beek
2025-03-10 14:18:11 +01:00
committed by GitHub
56 changed files with 2442 additions and 995 deletions
+4
View File
@@ -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
View File
@@ -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
@@ -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,
+35 -7
View File
@@ -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>
+239 -130
View File
@@ -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>
+39 -2
View File
@@ -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;
@@ -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";
@@ -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();
});
+183 -156
View File
@@ -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">&nbsp;</div>
<div v-if="!!hasBrowserSupport">&nbsp;</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>
+40 -76
View File
@@ -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"
-20
View File
@@ -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,
};
+53
View File
@@ -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,
};
+17 -7
View File
@@ -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>
+5 -2
View File
@@ -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";
+1 -1
View File
@@ -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,
};
}
+2 -2
View File
@@ -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,
};
}
+4 -16
View File
@@ -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];
+2 -21
View File
@@ -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 -7
View File
@@ -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(
[
+7 -1
View File
@@ -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();
});
});
+22
View File
@@ -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 || {};
+1
View File
@@ -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()