mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #22813 from davelopez/26.1_fix_import_workflow_usability
[26.1] Improve import workflow usability
This commit is contained in:
@@ -1,44 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { faCloudUploadAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import axios from "axios";
|
||||
import { BAlert, BForm, BFormFile, BFormGroup } from "bootstrap-vue";
|
||||
import { BForm, BFormFile, BFormGroup } from "bootstrap-vue";
|
||||
import { computed, type Ref, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router/composables";
|
||||
|
||||
import { getRedirectOnImportPath } from "@/components/Workflow/redirectPath";
|
||||
import { useFileDrop } from "@/composables/fileDrop";
|
||||
import { withPrefix } from "@/utils/redirect";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
import { validateWorkflowFile, WORKFLOW_FILE_ACCEPT } from "./workflowValidation";
|
||||
|
||||
import GAlert from "@/components/BaseComponents/GAlert.vue";
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
hideSubmitButton?: boolean;
|
||||
/** A file dropped on the parent's method card, to be consumed on mount */
|
||||
droppedFile?: File | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hideSubmitButton: false,
|
||||
});
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input-valid", valid: boolean): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const sourceFile: Ref<string | null> = ref(null);
|
||||
const sourceFile: Ref<File | null> = ref(null);
|
||||
const errorMessage: Ref<string | null> = ref(null);
|
||||
const acceptedWorkflowFormats = ".ga, .yml";
|
||||
const dropZoneRef = ref<HTMLElement | null>(null);
|
||||
const fileSetViaDrop = ref(false);
|
||||
|
||||
const isImportDisabled = computed(() => {
|
||||
return !sourceFile.value;
|
||||
});
|
||||
|
||||
const importTooltip = computed(() => {
|
||||
return isImportDisabled.value ? "You must provide a workflow file." : "Import workflow from File";
|
||||
});
|
||||
|
||||
const hasErrorMessage = computed(() => {
|
||||
return errorMessage.value != null;
|
||||
});
|
||||
const hasErrorMessage = computed(() => errorMessage.value != null);
|
||||
|
||||
// Validation state for wizard mode
|
||||
const isValid = computed(() => sourceFile.value !== null);
|
||||
@@ -47,15 +43,65 @@ watch(isValid, (newValue) => {
|
||||
emit("input-valid", newValue);
|
||||
});
|
||||
|
||||
// Hide submit button in wizard mode (or when hideSubmitButton is true)
|
||||
const showSubmitButton = computed(() => {
|
||||
return !props.hideSubmitButton;
|
||||
// Consume a file dropped on the parent's method card
|
||||
if (props.droppedFile) {
|
||||
sourceFile.value = props.droppedFile;
|
||||
fileSetViaDrop.value = true;
|
||||
}
|
||||
|
||||
// Drop zone handling
|
||||
async function onDrop(evt: DragEvent) {
|
||||
if (evt.dataTransfer?.files?.length) {
|
||||
const file = evt.dataTransfer.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const result = await validateWorkflowFile(file, { checkContent: true });
|
||||
if (result.valid) {
|
||||
sourceFile.value = file;
|
||||
fileSetViaDrop.value = true;
|
||||
} else {
|
||||
errorMessage.value = result.error ?? "Invalid workflow file.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { isFileOverDropZone } = useFileDrop({
|
||||
dropZone: dropZoneRef,
|
||||
onDrop,
|
||||
onDropCancel: () => {},
|
||||
solo: false,
|
||||
idleTime: 10000,
|
||||
ignoreChildrenOnLeave: true,
|
||||
});
|
||||
|
||||
/** When the user selects a file via the native input, validate it */
|
||||
async function onFileInput(file: File | null) {
|
||||
if (!file) {
|
||||
sourceFile.value = null;
|
||||
fileSetViaDrop.value = false;
|
||||
return;
|
||||
}
|
||||
const result = await validateWorkflowFile(file, { checkContent: true });
|
||||
if (result.valid) {
|
||||
sourceFile.value = file;
|
||||
fileSetViaDrop.value = false;
|
||||
} else {
|
||||
sourceFile.value = null;
|
||||
fileSetViaDrop.value = false;
|
||||
errorMessage.value = result.error ?? "Invalid workflow file.";
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the current file and reset to the browse state */
|
||||
function clearFile() {
|
||||
sourceFile.value = null;
|
||||
fileSetViaDrop.value = false;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submit(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
async function submit() {
|
||||
const formData = new FormData();
|
||||
|
||||
if (sourceFile.value) {
|
||||
@@ -78,38 +124,71 @@ async function submit(ev: SubmitEvent) {
|
||||
|
||||
// Expose method for wizard submit
|
||||
async function attemptImport() {
|
||||
await submit(new Event("submit") as SubmitEvent);
|
||||
await submit();
|
||||
}
|
||||
|
||||
defineExpose({ attemptImport });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BForm class="mt-4 workflow-import-file" @submit="submit">
|
||||
<h2 class="h-sm">Import from a workflow file</h2>
|
||||
<BForm class="mt-4 workflow-import-file" @submit.prevent="submit">
|
||||
<div
|
||||
ref="dropZoneRef"
|
||||
class="workflow-drop-zone"
|
||||
:class="{ 'workflow-drop-zone-active': isFileOverDropZone }"
|
||||
data-galaxy-file-drop-target>
|
||||
<div v-if="!sourceFile" class="mb-3">
|
||||
<FontAwesomeIcon :icon="faCloudUploadAlt" size="3x" class="text-muted" />
|
||||
<p class="mt-2 mb-1 font-weight-medium">Drag a workflow file here</p>
|
||||
<p class="text-muted small mb-2">or</p>
|
||||
</div>
|
||||
<BFormGroup :label="sourceFile ? 'Workflow File' : 'Browse for a file'">
|
||||
<!-- When a file was set via drop, the native file input can't reflect
|
||||
it programmatically, so we show the filename manually instead. -->
|
||||
<div v-if="fileSetViaDrop && sourceFile" class="text-center py-2">
|
||||
<span class="font-weight-bold text-break">{{ sourceFile.name }}</span>
|
||||
<div class="mt-1">
|
||||
<GButton transparent size="small" @click="clearFile">Choose another</GButton>
|
||||
</div>
|
||||
</div>
|
||||
<BFormFile
|
||||
v-show="!fileSetViaDrop"
|
||||
v-model="sourceFile"
|
||||
:accept="WORKFLOW_FILE_ACCEPT"
|
||||
@input="onFileInput" />
|
||||
<span v-if="!sourceFile" class="text-muted small">
|
||||
Accepted formats: <code>*.ga</code>, <code>*.yml</code>, <code>*.yaml</code>
|
||||
</span>
|
||||
</BFormGroup>
|
||||
</div>
|
||||
|
||||
<BFormGroup label="Archived Workflow File">
|
||||
<BFormFile v-model="sourceFile" :accept="acceptedWorkflowFormats" />
|
||||
If the workflow is in a file on your computer, choose it and then click Import.
|
||||
</BFormGroup>
|
||||
|
||||
<BAlert :show="hasErrorMessage" variant="danger">
|
||||
<GAlert :show="hasErrorMessage" variant="danger">
|
||||
{{ errorMessage }}
|
||||
</BAlert>
|
||||
</GAlert>
|
||||
|
||||
<BAlert v-if="loading" show variant="info">
|
||||
<GAlert v-if="loading" variant="info">
|
||||
<LoadingSpan message="Loading your workflow, this may take a while - please be patient." />
|
||||
</BAlert>
|
||||
|
||||
<GButton
|
||||
v-if="showSubmitButton"
|
||||
id="workflow-import-button"
|
||||
type="submit"
|
||||
:disabled="isImportDisabled"
|
||||
:title="importTooltip"
|
||||
tooltip
|
||||
color="blue">
|
||||
Import workflow
|
||||
</GButton>
|
||||
</GAlert>
|
||||
</BForm>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "@/style/scss/theme/blue.scss";
|
||||
|
||||
.workflow-drop-zone {
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: $border-radius-large;
|
||||
text-align: center;
|
||||
background-color: $gray-100;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
transition:
|
||||
border-color 0.3s ease,
|
||||
background-color 0.3s ease;
|
||||
|
||||
&.workflow-drop-zone-active {
|
||||
border-color: $brand-primary;
|
||||
background-color: lighten($brand-primary, 60%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getLocalVue } from "@tests/vitest/helpers";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import VueRouter from "vue-router";
|
||||
|
||||
import FromFile from "./FromFile.vue";
|
||||
import FromUrl from "./FromUrl.vue";
|
||||
@@ -17,6 +18,7 @@ vi.mock("axios", () => ({
|
||||
}));
|
||||
|
||||
const localVue = getLocalVue(true);
|
||||
localVue.use(VueRouter);
|
||||
|
||||
const sharedUrl = "http://127.0.0.1:8081/u/admin/w/unnamed-workflow";
|
||||
const sharedUrlTrailingSlash = "http://127.0.0.1:8081/u/admin/w/unnamed-workflow/";
|
||||
@@ -25,7 +27,8 @@ const invalidUrl = "http://127.0.0.1:8081/u/admin/w/unnamed-workflow/additional-
|
||||
|
||||
describe("FromUrl", () => {
|
||||
it("converts shared urls to json urls", async () => {
|
||||
const wrapper = mount(FromUrl as object, { localVue });
|
||||
const router = new VueRouter();
|
||||
const wrapper = mount(FromUrl as object, { localVue, router });
|
||||
|
||||
{
|
||||
const input = wrapper.find("#workflow-import-url-input");
|
||||
@@ -71,7 +74,8 @@ describe("FromUrl", () => {
|
||||
|
||||
describe("FromFile", () => {
|
||||
it("can mount the component", async () => {
|
||||
const wrapper = mount(FromFile as object, { localVue });
|
||||
expect(wrapper.find("#workflow-import-button").exists()).toBe(true);
|
||||
const router = new VueRouter();
|
||||
const wrapper = mount(FromFile as object, { localVue, router });
|
||||
expect(wrapper.find("form").exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import axios from "axios";
|
||||
import { BAlert, BForm, BFormGroup, BFormInput } from "bootstrap-vue";
|
||||
import { computed, type Ref, ref, watch } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { useRouter } from "vue-router/composables";
|
||||
|
||||
import { getRedirectOnImportPath } from "@/components/Workflow/redirectPath";
|
||||
@@ -9,14 +10,6 @@ import { withPrefix } from "@/utils/redirect";
|
||||
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
hideSubmitButton?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hideSubmitButton: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input-valid", valid: boolean): void;
|
||||
}>();
|
||||
@@ -25,14 +18,6 @@ const loading = ref(false);
|
||||
const sourceURL: Ref<string | null> = ref(null);
|
||||
const errorMessage: Ref<string | null> = ref(null);
|
||||
|
||||
const isImportDisabled = computed(() => {
|
||||
return !sourceURL.value;
|
||||
});
|
||||
|
||||
const importTooltip = computed(() => {
|
||||
return isImportDisabled.value ? "You must provide a workflow archive URL." : "Import workflow from URL";
|
||||
});
|
||||
|
||||
const hasErrorMessage = computed(() => {
|
||||
return errorMessage.value != null;
|
||||
});
|
||||
@@ -46,11 +31,6 @@ watch(isValid, (newValue) => {
|
||||
emit("input-valid", newValue);
|
||||
});
|
||||
|
||||
// Hide submit button in wizard mode (or when hideSubmitButton is true)
|
||||
const showSubmitButton = computed(() => {
|
||||
return !props.hideSubmitButton;
|
||||
});
|
||||
|
||||
function autoAppendJson(urlString: string): string {
|
||||
const sharedWorkflowRegex = /^(https?:\/\/[\S]+\/u\/[\S]+\/w\/[^\s/]+)\/?$/;
|
||||
const matches = urlString.match(sharedWorkflowRegex);
|
||||
@@ -65,8 +45,7 @@ function autoAppendJson(urlString: string): string {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submit(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
async function submit() {
|
||||
const formData = new FormData();
|
||||
|
||||
if (sourceURL.value) {
|
||||
@@ -94,14 +73,14 @@ async function submit(ev: SubmitEvent) {
|
||||
|
||||
// Expose method for wizard submit
|
||||
async function attemptImport() {
|
||||
await submit(new Event("submit") as SubmitEvent);
|
||||
await submit();
|
||||
}
|
||||
|
||||
defineExpose({ attemptImport });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BForm class="mt-4 workflow-import-url" @submit="submit">
|
||||
<BForm class="mt-4 workflow-import-url" @submit.prevent="submit">
|
||||
<h2 class="h-sm">Import from a Galaxy workflow export URL</h2>
|
||||
|
||||
<BFormGroup label="Workflow Archive URL">
|
||||
@@ -111,7 +90,7 @@ defineExpose({ attemptImport });
|
||||
aria-label="Workflow Import URL"
|
||||
type="url" />
|
||||
If your URL is from a workflow repository and doesn't end in <code>.ga</code>, you might need to use the
|
||||
<a href="/workflows/import?trs_url=Enter%20a%20TRS%20URL">TRS import method</a>
|
||||
<RouterLink to="/workflows/import?trs_url=Enter%20a%20TRS%20URL">TRS import method</RouterLink>
|
||||
instead.
|
||||
</BFormGroup>
|
||||
|
||||
@@ -122,16 +101,5 @@ defineExpose({ attemptImport });
|
||||
<BAlert v-if="loading" show variant="info">
|
||||
<LoadingSpan message="Loading your workflow, this may take a while - please be patient." />
|
||||
</BAlert>
|
||||
|
||||
<GButton
|
||||
v-if="showSubmitButton"
|
||||
id="workflow-import-button"
|
||||
type="submit"
|
||||
:disabled="isImportDisabled"
|
||||
:title="importTooltip"
|
||||
tooltip
|
||||
color="blue">
|
||||
Import workflow
|
||||
</GButton>
|
||||
</BForm>
|
||||
</template>
|
||||
|
||||
@@ -134,8 +134,6 @@ defineExpose({ triggerImport });
|
||||
|
||||
<template>
|
||||
<div class="workflow-import-trs-id">
|
||||
<h2 class="h-sm">Import from TRS ID</h2>
|
||||
|
||||
<div>
|
||||
<b>TRS Server:</b>
|
||||
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
import { BForm, BFormGroup, BFormInput } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import GButton from "@/components/BaseComponents/GButton.vue";
|
||||
|
||||
interface Props {
|
||||
queryTrsUrl?: string;
|
||||
hideSubmitButton?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
queryTrsUrl: "",
|
||||
hideSubmitButton: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -21,14 +17,6 @@ const emit = defineEmits<{
|
||||
|
||||
const trsUrl = ref(props.queryTrsUrl);
|
||||
|
||||
const isImportDisabled = computed(() => {
|
||||
return !trsUrl.value;
|
||||
});
|
||||
|
||||
const importTooltip = computed(() => {
|
||||
return isImportDisabled.value ? "You must provide a TRS URL." : "Import workflow from TRS URL";
|
||||
});
|
||||
|
||||
// Validation state for wizard mode
|
||||
const isValid = computed(() => {
|
||||
return trsUrl.value !== null && trsUrl.value !== undefined && trsUrl.value.length > 0;
|
||||
@@ -42,11 +30,6 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Hide submit button in wizard mode (or when hideSubmitButton is true)
|
||||
const showSubmitButton = computed(() => {
|
||||
return !props.hideSubmitButton;
|
||||
});
|
||||
|
||||
function submit(ev: SubmitEvent) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -67,22 +50,9 @@ defineExpose({ triggerImport });
|
||||
|
||||
<template>
|
||||
<BForm class="mt-4" @submit="submit">
|
||||
<h2 class="h-sm">Import from TRS URL</h2>
|
||||
|
||||
<BFormGroup label="TRS URL:" label-class="font-weight-bold">
|
||||
<BFormInput id="trs-import-url-input" v-model="trsUrl" aria-label="TRS URL" type="url" />
|
||||
If the workflow is accessible via a TRS URL, enter the URL above and click Import.
|
||||
</BFormGroup>
|
||||
|
||||
<GButton
|
||||
v-if="showSubmitButton"
|
||||
id="trs-url-import-button"
|
||||
type="submit"
|
||||
:disabled="isImportDisabled"
|
||||
:title="importTooltip"
|
||||
tooltip
|
||||
color="blue">
|
||||
Import workflow
|
||||
</GButton>
|
||||
</BForm>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Accepted workflow file extensions */
|
||||
export const WORKFLOW_FILE_EXTENSIONS = [".ga", ".gxwf.yml", ".gxwf.yaml", ".yml", ".yaml"] as const;
|
||||
|
||||
/** Human-readable list of accepted formats for file inputs */
|
||||
export const WORKFLOW_FILE_ACCEPT = ".ga, .yml, .yaml";
|
||||
|
||||
/**
|
||||
* Check if a filename has a valid workflow file extension.
|
||||
* Supports .ga (Galaxy Archive), .yml/.yaml (Galaxy Workflow Format), and .gxwf.yml/.gxwf.yaml
|
||||
*/
|
||||
export function hasWorkflowFileExtension(fileName: string): boolean {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return WORKFLOW_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a workflow file by checking its extension and optionally its content.
|
||||
*
|
||||
* For .ga files (JSON format), checks for `"a_galaxy_workflow": "true"`.
|
||||
* For .yml/.yaml files (YAML format), checks for `class: GalaxyWorkflow` or `class: GalaxyWorkflow`.
|
||||
*
|
||||
* Content validation reads only the first 1KB of the file for efficiency.
|
||||
*/
|
||||
export async function validateWorkflowFile(
|
||||
file: File,
|
||||
options: { checkContent?: boolean } = {},
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
if (!hasWorkflowFileExtension(file.name)) {
|
||||
return { valid: false, error: `Invalid file type. Accepted formats: ${WORKFLOW_FILE_ACCEPT}` };
|
||||
}
|
||||
|
||||
if (options.checkContent) {
|
||||
const lowerName = file.name.toLowerCase();
|
||||
|
||||
if (lowerName.endsWith(".ga")) {
|
||||
const hasMarker = await readFileMarker(file, '"a_galaxy_workflow"');
|
||||
if (!hasMarker) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "File does not appear to be a Galaxy workflow (.ga). Missing workflow marker.",
|
||||
};
|
||||
}
|
||||
} else if (lowerName.endsWith(".yml") || lowerName.endsWith(".yaml")) {
|
||||
const hasMarker = await readFileMarker(file, "class:");
|
||||
if (!hasMarker) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "File does not appear to be a Galaxy workflow (.yml/.yaml). Missing 'class:' marker.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first 1KB of a file and check if it contains the given marker string.
|
||||
* This is efficient and avoids loading the entire file into memory.
|
||||
*/
|
||||
async function readFileMarker(file: File, marker: string): Promise<boolean> {
|
||||
try {
|
||||
const slice = file.slice(0, 1024);
|
||||
const text = await slice.text();
|
||||
return text.includes(marker);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { faCloudUploadAlt, faFileImport, faIdBadge, faLink, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { computed, nextTick, onMounted, type Ref, ref } from "vue";
|
||||
import { useRoute } from "vue-router/composables";
|
||||
|
||||
import { useWizard } from "@/components/Common/Wizard/useWizard";
|
||||
import type { TrsSelection } from "@/components/Workflow/Import/types";
|
||||
import { validateWorkflowFile } from "@/components/Workflow/Import/workflowValidation";
|
||||
import { Services } from "@/components/Workflow/services";
|
||||
import { useFileDrop } from "@/composables/fileDrop";
|
||||
|
||||
import GCard from "@/components/Common/GCard.vue";
|
||||
import GenericWizard from "@/components/Common/Wizard/GenericWizard.vue";
|
||||
@@ -22,6 +26,8 @@ const trsServers: Ref<TrsSelection[]> = ref([]);
|
||||
const fileComponent = ref<InstanceType<typeof FromFile>>();
|
||||
const urlComponent = ref<InstanceType<typeof FromUrl>>();
|
||||
const trsComponent = ref<InstanceType<typeof TrsImport>>();
|
||||
const uploadCardRef = ref<HTMLElement | null>(null);
|
||||
const droppedFile: Ref<File | null> = ref(null);
|
||||
|
||||
// Validation states for each step
|
||||
const uploadValid = ref(false);
|
||||
@@ -30,6 +36,32 @@ const trsSearchValid = ref(false);
|
||||
const trsUrlValid = ref(false);
|
||||
const trsIdValid = ref(false);
|
||||
|
||||
// Drop zone on the "Upload file" method card — allows dropping a workflow file
|
||||
// directly on the card to skip straight to the upload step with the file pre-populated
|
||||
async function onUploadCardDrop(evt: DragEvent) {
|
||||
if (evt.dataTransfer?.files?.length) {
|
||||
const file = evt.dataTransfer.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const result = await validateWorkflowFile(file, { checkContent: true });
|
||||
if (result.valid) {
|
||||
droppedFile.value = file;
|
||||
selectMethod("upload");
|
||||
}
|
||||
// If invalid, the drop is silently ignored — the user can still click the card
|
||||
}
|
||||
}
|
||||
|
||||
const { isFileOverDropZone: isFileOverUploadCard } = useFileDrop({
|
||||
dropZone: uploadCardRef,
|
||||
onDrop: onUploadCardDrop,
|
||||
onDropCancel: () => {},
|
||||
solo: false,
|
||||
idleTime: 10000,
|
||||
ignoreChildrenOnLeave: true,
|
||||
});
|
||||
|
||||
const queryParams = computed(() => ({
|
||||
trsId: route.query.trs_id as string | undefined,
|
||||
trsUrl: route.query.trs_url as string | undefined,
|
||||
@@ -153,10 +185,26 @@ onMounted(async () => {
|
||||
|
||||
function selectMethod(method: ImportMethod) {
|
||||
selectedMethod.value = method;
|
||||
// Navigate to the appropriate step based on the selected method
|
||||
if (method === "upload") {
|
||||
wizard.goTo("upload-file");
|
||||
} else if (method === "fetch") {
|
||||
wizard.goTo("fetch-url");
|
||||
} else if (method === "repository") {
|
||||
wizard.goTo("select-trs-method");
|
||||
}
|
||||
}
|
||||
|
||||
function selectTrsMethod(method: TrsMethod) {
|
||||
selectedTrsMethod.value = method;
|
||||
// Navigate to the appropriate TRS step
|
||||
if (method === "search") {
|
||||
wizard.goTo("trs-search");
|
||||
} else if (method === "url") {
|
||||
wizard.goTo("trs-url");
|
||||
} else if (method === "id") {
|
||||
wizard.goTo("trs-id");
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
@@ -202,17 +250,24 @@ function onTrsIdValid(e: boolean) {
|
||||
<div v-if="wizard.isCurrent('select-method')" class="method-selection">
|
||||
<div class="row">
|
||||
<div class="col-xl-4 my-5">
|
||||
<GCard
|
||||
class="h-100 workflow-import-file-link text-center"
|
||||
:class="{ selected: selectedMethod === 'upload' }"
|
||||
:clickable="true"
|
||||
@click="selectMethod('upload')">
|
||||
<h4>Upload file</h4>
|
||||
<p class="text-muted mb-0">
|
||||
Upload a <code>*.ga</code> file from your computer. These files can be downloaded from
|
||||
Galaxy servers or workflow repositories.
|
||||
</p>
|
||||
</GCard>
|
||||
<div
|
||||
ref="uploadCardRef"
|
||||
class="h-100"
|
||||
:class="{ 'drop-zone-active': isFileOverUploadCard }"
|
||||
data-galaxy-file-drop-target>
|
||||
<GCard
|
||||
class="h-100 workflow-import-file-link text-center"
|
||||
:class="{ selected: selectedMethod === 'upload' }"
|
||||
:clickable="true"
|
||||
@click="selectMethod('upload')">
|
||||
<FontAwesomeIcon :icon="faFileImport" size="3x" class="mb-3 text-muted" />
|
||||
<h4>Upload file</h4>
|
||||
<p class="text-muted mb-0">
|
||||
Upload a <code>*.ga</code> or <code>*.gxwf.yml</code> file from your computer. You
|
||||
can also drag and drop a file here.
|
||||
</p>
|
||||
</GCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 my-5">
|
||||
@@ -221,10 +276,11 @@ function onTrsIdValid(e: boolean) {
|
||||
:class="{ selected: selectedMethod === 'fetch' }"
|
||||
:clickable="true"
|
||||
@click="selectMethod('fetch')">
|
||||
<FontAwesomeIcon :icon="faLink" size="3x" class="mb-3 text-muted" />
|
||||
<h4>Fetch URL</h4>
|
||||
<p class="text-muted mb-0">
|
||||
Fetch a remote <code>*.ga</code> file from any publicly accessible URL. This can be
|
||||
generated by any Galaxy server, or public repositories like GitHub.
|
||||
Fetch a remote <code>Galaxy Workflow</code> file from any publicly accessible URL. This
|
||||
can be generated by any Galaxy server, or public repositories like GitHub.
|
||||
</p>
|
||||
</GCard>
|
||||
</div>
|
||||
@@ -235,6 +291,7 @@ function onTrsIdValid(e: boolean) {
|
||||
:class="{ selected: selectedMethod === 'repository' }"
|
||||
:clickable="true"
|
||||
@click="selectMethod('repository')">
|
||||
<FontAwesomeIcon :icon="faCloudUploadAlt" size="3x" class="mb-3 text-muted" />
|
||||
<h4>Import from repository</h4>
|
||||
<p class="text-muted mb-0">
|
||||
Search and import workflows from our configured workflow repositories ({{
|
||||
@@ -248,7 +305,7 @@ function onTrsIdValid(e: boolean) {
|
||||
|
||||
<div v-else-if="wizard.isCurrent('upload-file')" class="import-form">
|
||||
<div class="container-narrow">
|
||||
<FromFile ref="fileComponent" @input-valid="onUploadValid" />
|
||||
<FromFile ref="fileComponent" :dropped-file="droppedFile" @input-valid="onUploadValid" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -266,6 +323,7 @@ function onTrsIdValid(e: boolean) {
|
||||
:class="{ selected: selectedTrsMethod === 'search' }"
|
||||
:clickable="true"
|
||||
@click="selectTrsMethod('search')">
|
||||
<FontAwesomeIcon :icon="faSearch" size="3x" class="mb-3 text-muted" />
|
||||
<h4>Search workflow registries</h4>
|
||||
<p class="text-muted mb-0">Search for workflows across configured GA4GH servers.</p>
|
||||
</GCard>
|
||||
@@ -277,6 +335,7 @@ function onTrsIdValid(e: boolean) {
|
||||
:class="{ selected: selectedTrsMethod === 'url' }"
|
||||
:clickable="true"
|
||||
@click="selectTrsMethod('url')">
|
||||
<FontAwesomeIcon :icon="faLink" size="3x" class="mb-3 text-muted" />
|
||||
<h4>TRS URL</h4>
|
||||
<p class="text-muted mb-0">Import directly from any GA4GH server with a TRS URL.</p>
|
||||
</GCard>
|
||||
@@ -288,6 +347,7 @@ function onTrsIdValid(e: boolean) {
|
||||
:class="{ selected: selectedTrsMethod === 'id' }"
|
||||
:clickable="true"
|
||||
@click="selectTrsMethod('id')">
|
||||
<FontAwesomeIcon :icon="faIdBadge" size="3x" class="mb-3 text-muted" />
|
||||
<h4>TRS ID</h4>
|
||||
<p class="text-muted mb-0">
|
||||
When you know the TRS ID for a workflow in one of the configured GA4GH servers.
|
||||
@@ -355,4 +415,9 @@ function onTrsIdValid(e: boolean) {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
.drop-zone-active {
|
||||
border-radius: 0.3rem;
|
||||
outline: 2px dashed var(--brand-primary, #2c3143);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -115,6 +115,9 @@ $h6-font-size: $font-size-base * 0.85; // default 1
|
||||
$dropdown-link-hover-color: $brand-light;
|
||||
$dropdown-link-hover-bg: $brand-primary;
|
||||
|
||||
// Code elements (override Bootstrap default $code-color: $pink which fails WCAG AA contrast)
|
||||
$code-color: #c2266e;
|
||||
|
||||
// Color of input elements
|
||||
$input-color: $text-color;
|
||||
$input-color-placeholder: $text-muted;
|
||||
|
||||
@@ -385,26 +385,23 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
|
||||
self.navigate_to(self.build_url("workflows/trs_import"))
|
||||
self.components.masthead._.wait_for_visible()
|
||||
# The wizard auto-navigates to TRS method selection on mount
|
||||
# Click the "Search workflow registries" card then advance the wizard
|
||||
# Clicking the "Search workflow registries" card auto-navigates to the search form
|
||||
self.components.workflows.import_trs_search_link.wait_for_and_click()
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
|
||||
def go_to_trs_by_id(self) -> None:
|
||||
self.navigate_to(self.build_url("workflows/trs_import"))
|
||||
self.components.masthead._.wait_for_visible()
|
||||
# The wizard auto-navigates to TRS method selection on mount
|
||||
# Click the TRS ID sub-card then advance the wizard
|
||||
# Clicking the TRS ID card auto-navigates to the TRS ID form
|
||||
self.components.workflows.import_trs_id_link.wait_for_and_click()
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
|
||||
def go_to_trs_by_url(self) -> None:
|
||||
self.navigate_to(self.build_url("workflows/trs_import"))
|
||||
self.components.masthead._.wait_for_visible()
|
||||
# The wizard auto-navigates to TRS method selection on mount
|
||||
# Click the TRS URL sub-card then advance the wizard
|
||||
# Clicking the TRS URL card auto-navigates to the TRS URL form
|
||||
self.components.workflows.import_trs_url_link.wait_for_and_click()
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
# Wait for the URL input to be visible after wizard navigates to the form
|
||||
# Wait for the URL input to be visible after wizard auto-navigates to the form
|
||||
self.components.trs_import.url_input.wait_for_visible()
|
||||
|
||||
def go_to_workflow_sharing(self, workflow_id: str) -> None:
|
||||
@@ -2037,9 +2034,8 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
|
||||
|
||||
def workflow_import_submit_url(self, url):
|
||||
# Click the "Fetch URL" card to select that import method
|
||||
# (auto-navigates to the URL input step)
|
||||
self.components.workflows.import_url_link.wait_for_and_click()
|
||||
# Click the wizard's Next button to proceed to the URL input step
|
||||
self.wait_for_and_click_selector(".wizard-actions .go-next-btn")
|
||||
# Enter the URL
|
||||
url_element = self.wait_for_selector_visible("#workflow-import-url-input")
|
||||
url_element.send_keys(url)
|
||||
|
||||
@@ -33,28 +33,23 @@ class TestWorkflowManagement(SeleniumTestCase, TestsGalaxyPagers, UsesWorkflowAs
|
||||
workflows = self.components.workflows
|
||||
|
||||
self.workflow_index_click_import()
|
||||
# Clicking the method card auto-navigates to the upload step
|
||||
workflows.import_file_link.wait_for_and_click()
|
||||
# Click Next to navigate to the file upload view
|
||||
self.wait_for_and_click_selector(".wizard-actions .go-next-btn")
|
||||
workflows.import_file.assert_no_axe_violations_with_impact_of_at_least("moderate")
|
||||
|
||||
self.navigate_to_workflows_import()
|
||||
# Clicking the TRS card auto-navigates to the TRS method selection step
|
||||
workflows.import_trs_link.wait_for_and_click()
|
||||
# Click Next to navigate to TRS method selection
|
||||
self.wait_for_and_click_selector(".wizard-actions .go-next-btn")
|
||||
# Clicking the search card auto-navigates to the TRS search form
|
||||
workflows.import_trs_search_link.wait_for_and_click()
|
||||
# Click Next to navigate to TRS search view
|
||||
self.wait_for_and_click_selector(".wizard-actions .go-next-btn")
|
||||
# moderate violation relating to header ordering
|
||||
workflows.import_trs_search.assert_no_axe_violations_with_impact_of_at_least("serious")
|
||||
|
||||
self.navigate_to_workflows_import()
|
||||
# Clicking the TRS card auto-navigates to the TRS method selection step
|
||||
workflows.import_trs_link.wait_for_and_click()
|
||||
# Click Next to navigate to TRS method selection
|
||||
self.wait_for_and_click_selector(".wizard-actions .go-next-btn")
|
||||
# Clicking the TRS ID card auto-navigates to the TRS ID form
|
||||
workflows.import_trs_id_link.wait_for_and_click()
|
||||
# Click Next to navigate to TRS ID view
|
||||
self.wait_for_and_click_selector(".wizard-actions .go-next-btn")
|
||||
# ditto - moderate violation relating to header ordering
|
||||
workflows.import_trs_id.assert_no_axe_violations_with_impact_of_at_least("serious")
|
||||
|
||||
|
||||
@@ -51,6 +51,18 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
def trs_config_dir(cls):
|
||||
return cls.temp_config_dir("trs")
|
||||
|
||||
def _click_wizard_import_button(self, wait_for_validation=False):
|
||||
"""Click the wizard's Import button after ensuring it's enabled.
|
||||
|
||||
The Import button is disabled until the current step's validation passes.
|
||||
Set wait_for_validation=True to sleep briefly before clicking (needed when
|
||||
user input was just entered and Vue validation hasn't propagated yet).
|
||||
"""
|
||||
if wait_for_validation:
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
import_button = self.wait_for_selector_clickable(".wizard-actions .go-next-btn.btn-primary:not([disabled])")
|
||||
import_button.click()
|
||||
|
||||
def assert_workflow_imported(self, name):
|
||||
# surround name with quotes to consider case where name contains colons
|
||||
self.workflow_index_search_for(f'"{name}"')
|
||||
@@ -69,8 +81,7 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
full_url = self.build_url(import_url)
|
||||
self.driver.get(full_url)
|
||||
self.components.workflows.workflow_trs_import.wait_for_visible()
|
||||
# Wait for the wizard's Import button to become enabled and click it
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
self._click_wizard_import_button()
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
self.workflow_index_open()
|
||||
self.assert_workflow_imported(WORKFLOW_NAME)
|
||||
@@ -81,8 +92,7 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
self.components.trs_search.search_result(
|
||||
workflow_name="galaxy-workflow-dockstore-example-1"
|
||||
).wait_for_and_click()
|
||||
# Wait for the wizard's Import button to become enabled and click it
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
self._click_wizard_import_button(wait_for_validation=True)
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
self.workflow_index_open()
|
||||
self.assert_workflow_imported("Test Workflow")
|
||||
@@ -94,8 +104,7 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
# Select version from dropdown
|
||||
version_select = self.components.trs_search.version_select.wait_for_visible()
|
||||
Select(version_select).select_by_visible_text("v0.4")
|
||||
# Wait for the wizard's Import button to become enabled and click it
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
self._click_wizard_import_button(wait_for_validation=True)
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
self.workflow_index_open()
|
||||
self.assert_workflow_imported(WORKFLOW_NAME)
|
||||
@@ -109,8 +118,7 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
self.components.trs_search.select_server(server="workflowhub").wait_for_and_click()
|
||||
self.components.trs_search.search.wait_for_and_send_keys(WORKFLOW_NAME)
|
||||
self.components.trs_search.search_result(workflow_name=WORKFLOW_NAME).wait_for_and_click()
|
||||
# Wait for the wizard's Import button to become enabled and click it
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
self._click_wizard_import_button(wait_for_validation=True)
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
self.workflow_index_open()
|
||||
self.assert_workflow_imported(WORKFLOW_NAME)
|
||||
@@ -146,8 +154,7 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
# Select version from dropdown
|
||||
version_select = self.components.trs_import.version_select.wait_for_visible()
|
||||
Select(version_select).select_by_visible_text("v0.4")
|
||||
# Wait for the wizard's Import button to become enabled and click it
|
||||
self.components.workflows.import_next_button.wait_for_and_click()
|
||||
self._click_wizard_import_button(wait_for_validation=True)
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
self.workflow_index_open()
|
||||
self.assert_workflow_imported(WORKFLOW_NAME)
|
||||
@@ -155,7 +162,7 @@ class TestTrsImport(SeleniumIntegrationTestCase):
|
||||
def _import_by_trs_url(self, trs_url):
|
||||
self.go_to_trs_by_url()
|
||||
self.components.trs_import.url_input.wait_for_and_send_keys(trs_url)
|
||||
self.components.trs_import.url_import_button.wait_for_and_click()
|
||||
self._click_wizard_import_button(wait_for_validation=True)
|
||||
self.sleep_for(self.wait_types.UX_RENDER)
|
||||
self.workflow_index_open()
|
||||
self.assert_workflow_imported(WORKFLOW_NAME)
|
||||
|
||||
Reference in New Issue
Block a user