Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions client/src/components/Tool/ToolForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@
@onClick="onExecute(config, currentHistoryId)" />
</template>
</ToolCard>

<ToolTourAutoStarter
v-if="startTour && formConfig.id && formConfig.version"
:tool-id="formConfig.id"
:tool-version="formConfig.version"
:start="startTour" />
</div>
</template>

Expand Down Expand Up @@ -133,6 +139,7 @@ import FormSelect from "@/components/Form/Elements/FormSelect.vue";
import FormDisplay from "@/components/Form/FormDisplay.vue";
import FormElement from "@/components/Form/FormElement.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
import ToolTourAutoStarter from "@/components/Tool/ToolTourAutoStarter.vue";
import ToolEntryPoints from "@/components/ToolEntryPoints/ToolEntryPoints.vue";

export default {
Expand All @@ -141,6 +148,7 @@ export default {
LoadingSpan,
FormDisplay,
ToolCard,
ToolTourAutoStarter,
FormElement,
FormSelect,
ToolEntryPoints,
Expand Down Expand Up @@ -169,13 +177,22 @@ export default {
type: String,
default: null,
},
startTour: {
type: [Boolean, String],
default: false,
},
},
setup() {
const { config, isLoaded: isConfigLoaded } = storeToRefs(useConfigStore());
const configStore = useConfigStore();
const { config, isLoaded: isConfigLoaded } = storeToRefs(configStore);

const { getCredentialsExecutionContextForTool } = useUserToolsServiceCredentialsStore();

return { config, isConfigLoaded, getCredentialsExecutionContextForTool };
return {
config,
isConfigLoaded,
getCredentialsExecutionContextForTool,
};
},
data() {
return {
Expand Down Expand Up @@ -307,8 +324,8 @@ export default {
},
methods: {
...mapActions(useJobStore, ["saveLatestResponse"]),
...mapActions(useTourStore, ["setTour"]),
...mapActions(useHistoryStore, ["startWatchingHistory"]),
...mapActions(useTourStore, ["setTour"]),
emailAllowed(config, user) {
return config.server_mail_configured && !user.isAnonymous;
},
Expand Down Expand Up @@ -379,7 +396,7 @@ export default {
},
onExecute(config, historyId) {
// If a tour is active that was generated for this tool, end it.
if (this.currentTour?.id.startsWith(`tool-generated-${this.formConfig.id}`)) {
if (this.currentTour?.id?.startsWith(`tool-generated-${this.formConfig.id}`)) {
this.setTour(undefined);
}

Expand Down
241 changes: 241 additions & 0 deletions client/src/components/Tool/ToolTourAutoStarter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<script setup lang="ts">
/**
* ToolTourAutoStarter Component
*
* Automatically generates and launches interactive tours for Galaxy tools.
* Manages the complete lifecycle of tool tours including generation, dataset
* preparation, and tour initialization.
*
* Features:
* - Automatic tour generation from backend
* - History item state monitoring
* - Deferred tour launch until datasets are ready
* - Error handling for failed dataset uploads
* - Version-based tour caching
* - URL parameter-driven tour activation
*
* @component ToolTourAutoStarter
* @example
* <ToolTourAutoStarter
* :tool-id="'seqtk_seq'"
* :tool-version="'1.4+galaxy0'"
* :start="true" />
*/

import { storeToRefs } from "pinia";
import { computed, onMounted, ref, watch } from "vue";

import { ERROR_STATES } from "@/api/jobs";
import { generateTour, type TourDetails } from "@/api/tours";
import { Toast } from "@/composables/toast";
import { useHistoryItemsStore } from "@/stores/historyItemsStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useTourStore } from "@/stores/tourStore";
import { errorMessageAsString } from "@/utils/simple-error";

/**
* Component props for ToolTourAutoStarter
*/
interface Props {
/**
* The ID of the tool for which to generate a tour
* @type {string}
*/
toolId: string;

/**
* The version of the tool
* @type {string}
*/
toolVersion: string;

/**
* Whether to start the tour automatically
* Can be a boolean or string representation ("true"/"false", "1"/"0")
* @type {boolean | string}
*/
start: boolean | string;
}

const props = defineProps<Props>();

const tourStore = useTourStore();
const { toolGeneratedTours } = storeToRefs(tourStore);

const { currentHistoryId } = storeToRefs(useHistoryStore());
const historyItemsStore = useHistoryItemsStore();

/** Tracks whether a tour generation request is currently in progress */
const generatingTour = ref(false);
/** Tracks the last tool version that was processed to prevent duplicate generation */
const lastProcessedVersion = ref<string | null>(null);
/** Stores the generated tour data and associated history item IDs */
const tourGenerationResult = ref<{ tour: TourDetails; hids: number[] } | null>(null);

/**
* Unique identifier for the generated tour
* @returns {string} Tour ID in format "tool-generated-{toolId}-{toolVersion}"
*/
const generatedTourId = computed(() => `tool-generated-${props.toolId}-${props.toolVersion}`);

/**
* Determines whether the tour should be started based on the start prop
* Handles boolean values, string representations, and null/undefined
* @returns {boolean} True if tour should start
*/
const shouldStartTour = computed(() => {
if (props.start === undefined || props.start === null) {
return false;
}
if (typeof props.start === "boolean") {
return props.start;
}
const normalized = `${props.start}`.toLowerCase();
return normalized !== "false" && normalized !== "0";
});

/**
* States of all history items required by the generated tour
* @returns {Record<string, string>} Map of history item IDs to their states
*/
const historyItemStates = computed(() => {
if (!tourGenerationResult.value?.hids?.length || !currentHistoryId.value) {
return {};
}
return historyItemsStore.getStatesForHids(currentHistoryId.value, tourGenerationResult.value.hids);
});

/**
* Checks if all required history items have completed successfully
* @returns {boolean} True when all history items are in 'ok' state
*/
const areHistoryItemsReady = computed(() => {
if (!tourGenerationResult.value?.hids?.length) {
return false;
}
const states = Object.values(historyItemStates.value);
if (!states.length) {
return false;
}
return states.every((state) => state && state === "ok");
});

/**
* Checks if any required history items have failed
* @returns {boolean} True if any history item is in an error state
*/
const hasFailedHistoryItems = computed(() => {
if (!tourGenerationResult.value?.hids?.length) {
return false;
}
return Object.values(historyItemStates.value).some((state) => state && ERROR_STATES.includes(state));
});

/**
* Conditionally starts the tour if all preconditions are met
* Checks for start prop, tool ID/version presence, and prevents duplicate generation
* @returns {void}
*/
function maybeStartTour(): void {
if (!shouldStartTour.value) {
return;
}
if (!props.toolId || !props.toolVersion) {
return;
}
const versionKey = `${props.toolId}/${props.toolVersion}`;
if (lastProcessedVersion.value === versionKey) {
return;
}
lastProcessedVersion.value = versionKey;
startToolTour();
}

/**
* Initiates tour generation from the backend API
* Handles tour data with or without required history items
* @returns {void}
*/
function startToolTour(): void {
if (generatingTour.value) {
return;
}
generatingTour.value = true;
generateTour(props.toolId, props.toolVersion)
.then(({ tour, uploaded_hids, use_datasets }) => {
const hids = use_datasets ? uploaded_hids : [];
tourGenerationResult.value = { tour, hids };
if (!hids.length) {
initializeGeneratedTour();
} else {
Toast.info("This tour waits for history datasets to be ready.", "Please wait");
}
})
.catch((error) => {
Toast.error(errorMessageAsString(error) || "An unknown error occurred", "Failed to generate tour");
resetTourGenerationState(true);
});
}

/**
* Registers the generated tour in the store and launches it
* Only proceeds if tour data is available
* @returns {void}
*/
function initializeGeneratedTour(): void {
if (!tourGenerationResult.value?.tour || !generatedTourId.value) {
return;
}
toolGeneratedTours.value[generatedTourId.value] = tourGenerationResult.value.tour;
tourStore.setTour(generatedTourId.value);
resetTourGenerationState();
}

/**
* Clears tour generation state and optionally resets version tracking
* @param {boolean} [resetKey=false] - Whether to also clear the last processed version
* @returns {void}
*/
function resetTourGenerationState(resetKey: boolean = false): void {
tourGenerationResult.value = null;
generatingTour.value = false;
if (resetKey) {
lastProcessedVersion.value = null;
}
}

/** Initialize the tour once all required history items are ready */
watch(areHistoryItemsReady, (isReady) => {
if (isReady) {
initializeGeneratedTour();
}
});

/** Handle failed history items by showing error and resetting state */
watch(hasFailedHistoryItems, (hasFailed) => {
if (hasFailed) {
Toast.error(
"This tour uploads datasets that failed to be created. You can try generating the tour again.",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something wrong in this sentence.

"Failed to generate tour",
);
resetTourGenerationState(true);
}
});

/** Re-initialize tour generation when tool or start parameter changes */
watch(
() => [props.toolId, props.toolVersion, shouldStartTour.value],
() => {
resetTourGenerationState(true);
maybeStartTour();
},
);

onMounted(() => {
maybeStartTour();
});
</script>

<template>
<span aria-hidden="true" />
</template>
12 changes: 12 additions & 0 deletions client/src/entry/analysis/modules/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ export default {
if (tool_version) {
result.version = tool_version.indexOf("+") >= 0 ? tool_version : decodeUriComponent(tool_version);
}
const startTourRaw = this.query.startTour ?? this.query["start-tour"];
if (startTourRaw !== undefined) {
// Treat empty string or null as true; only explicit "false" or "0" disable it
if (startTourRaw === "" || startTourRaw === null) {
result.startTour = true;
} else {
const normalized = String(startTourRaw).toLowerCase();
result.startTour = normalized !== "false" && normalized !== "0";
}
// Remove kebab-case to prevent conflicts when v-bind spreads props
delete result["start-tour"];
}
return result;
},
workflowParams() {
Expand Down
29 changes: 29 additions & 0 deletions lib/galaxy_test/selenium/test_tool_describing_tours.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,32 @@ def test_generate_tour_with_data(self):
self.tool_form_execute()
self.history_panel_wait_for_hid_ok(2)
self.screenshot("tool_describing_tour_3_after_execute")

@selenium_test
def test_start_tour_with_query_parameter(self):
"""Ensure start-tour query parameter auto-starts a tool tour."""
tool_id = "md5sum"

self.get(f"?tool_id={tool_id}&start-tour=true")

popover_component = self.components.tour.popover._
popover_component.wait_for_visible()

title = popover_component.title.wait_for_visible().text
assert title == "md5sum Tour", title

# Ensure any uploaded datasets from the generated tour are ready
self.history_panel_wait_for_hid_ok(1)

@selenium_test
def test_start_tour_with_query_parameter_no_value(self):
"""Ensure start-tour works when passed without a value."""
tool_id = "md5sum"

self.get(f"?tool_id={tool_id}&start-tour")

popover_component = self.components.tour.popover._
popover_component.wait_for_visible()

title = popover_component.title.wait_for_visible().text
assert title == "md5sum Tour", title
Loading