Convert the legacy Mako-based workflow extraction UI (build_from_current_history.mako) to a modern Vue.js component with FastAPI backend. This is the last non-data-display Mako template in Galaxy.
Related Issue: #17506
This plan was reviewed for Galaxy coding standards. Key findings:
| Issue | Location | Fix |
|---|---|---|
| Missing CBV wrapper | API endpoint | Need @router.cbv class, not standalone function |
| Missing error responses | API endpoint | Add 403/404 response documentation |
| No error handling | Service layer | Add try/except with proper exceptions |
| Wrong test base class | Test code | Use ApiTestCase not BaseWorkflowPopulator |
| Issue | Location | Fix |
|---|---|---|
| Type annotation gaps | Service layer | Add return types, parameter types |
Use isinstance() |
Service layer | Replace getattr(job, 'is_fake', False) pattern |
| Test coverage incomplete | Test code | Expand from 4 tests to ~15 tests |
| Issue | Recommendation |
|---|---|
| Long service method | Extract _build_job_response() helper |
| Large history performance | Consider pagination (see Unresolved Questions) |
| Job ID encoding inconsistent | Always use trans.security.encode_id() |
File: lib/galaxy/schema/workflow_extraction.py (new file)
from typing import Optional
from pydantic import BaseModel, Field
class WorkflowExtractionOutputDataset(BaseModel):
"""Represents a dataset or collection output from a job."""
id: str = Field(..., description="Encoded ID of the dataset/collection")
hid: int = Field(..., description="History ID number")
name: str = Field(..., description="Display name")
state: str = Field(..., description="Current state (ok, running, etc.)")
deleted: bool = Field(..., description="Whether this output has been deleted")
history_content_type: str = Field(..., description="'dataset' or 'dataset_collection'")
class WorkflowExtractionJob(BaseModel):
"""Represents a job available for workflow extraction."""
id: str = Field(..., description="Encoded job ID (may be 'fake_*' for input datasets)")
tool_name: str = Field(..., description="Human-readable tool name")
tool_id: Optional[str] = Field(None, description="Tool ID (None for fake jobs)")
tool_version: Optional[str] = Field(None, description="Tool version used")
is_workflow_compatible: bool = Field(..., description="Whether tool can be used in workflows")
is_fake: bool = Field(..., description="True for input datasets with no creating job")
disabled_reason: Optional[str] = Field(None, description="Why this job cannot be included")
outputs: list[WorkflowExtractionOutputDataset] = Field(
default_factory=list,
description="Datasets/collections created by this job"
)
version_warning: Optional[str] = Field(
None,
description="Warning if current tool version differs from job's version"
)
has_non_deleted_outputs: bool = Field(
...,
description="True if at least one output is not deleted"
)
class WorkflowExtractionSummary(BaseModel):
"""Summary data for workflow extraction UI."""
history_id: str = Field(..., description="Encoded history ID")
history_name: str = Field(..., description="History name")
jobs: list[WorkflowExtractionJob] = Field(
default_factory=list,
description="Jobs available for extraction, ordered by output HID"
)
warnings: list[str] = Field(
default_factory=list,
description="Global warnings (e.g., 'Some datasets still queued')"
)
default_workflow_name: str = Field(
...,
description="Suggested default workflow name"
)File: lib/galaxy/webapps/galaxy/api/histories.py (add to FastAPIHistories class)
Review Note: Galaxy uses class-based views (CBV) with
@router.cbv. The endpoint must be a method within the existingFastAPIHistoriesclass, not a standalone function. Also need error response documentation.
from galaxy.schema.workflow_extraction import WorkflowExtractionSummary
# Add to existing FastAPIHistories class (already decorated with @router.cbv):
@router.get(
"/api/histories/{history_id}/extraction_summary",
summary="Get workflow extraction summary for a history.",
description="Returns the jobs and datasets available for workflow extraction from this history.",
responses={
403: {"description": "Not authorized to access this history"},
404: {"description": "History not found"},
},
)
def extraction_summary(
self,
history_id: HistoryIDPathParam,
trans: ProvidesHistoryContext = DependsOnTrans,
) -> WorkflowExtractionSummary:
"""Get summary data needed to build a workflow from history contents."""
return self.service.get_extraction_summary(trans, history_id)Note: This method goes inside the existing FastAPIHistories class which is already wrapped with @router.cbv. Galaxy auto-discovers routers - no manual registration needed.
File: lib/galaxy/webapps/galaxy/services/histories.py (add method)
Review Notes:
- Use
isinstance()for FakeJob detection (notgetattr(job, 'is_fake'))- Add error handling with proper Galaxy exceptions
- Add type annotations for all parameters and return types
- Extract helper method for job transformation
from typing import Union
from galaxy.exceptions import ObjectNotFound, ItemAccessibilityException
from galaxy.model import Job
from galaxy.workflow.extract import summarize, FakeJob, DatasetCollectionCreationJob
from galaxy.schema.workflow_extraction import (
WorkflowExtractionSummary,
WorkflowExtractionJob,
WorkflowExtractionOutputDataset,
)
# Add to HistoriesService class:
def get_extraction_summary(
self,
trans: ProvidesHistoryContext,
history_id: DecodedDatabaseIdField,
) -> WorkflowExtractionSummary:
"""Build extraction summary for workflow creation from history.
Raises:
ObjectNotFound: If history does not exist
ItemAccessibilityException: If user cannot access history
"""
try:
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
except ObjectNotFound:
raise ObjectNotFound(f"History {history_id} not found")
except ItemAccessibilityException:
raise ItemAccessibilityException(f"Cannot access history {history_id}")
# Use existing summarize() function
jobs_dict, warnings = summarize(trans, history)
# Transform to API response format
extraction_jobs = [
self._build_extraction_job(trans, job, datasets)
for job, datasets in jobs_dict.items()
]
return WorkflowExtractionSummary(
history_id=trans.security.encode_id(history.id),
history_name=history.name,
jobs=extraction_jobs,
warnings=list(warnings),
default_workflow_name=f"Workflow constructed from history '{history.name}'",
)
def _build_extraction_job(
self,
trans: ProvidesHistoryContext,
job: Union[Job, FakeJob, DatasetCollectionCreationJob],
datasets: list,
) -> WorkflowExtractionJob:
"""Transform a job and its outputs into an API response object."""
is_fake = isinstance(job, (FakeJob, DatasetCollectionCreationJob))
# Determine tool info
tool_name = "Unknown"
tool_id = None
tool_version = None
is_workflow_compatible = False
disabled_reason = None
version_warning = None
if is_fake:
tool_name = job.name if hasattr(job, 'name') and job.name else "Input Dataset"
disabled_reason = job.disabled_why if hasattr(job, 'disabled_why') else "This item cannot be used in workflows"
else:
tool = trans.app.toolbox.get_tool(job.tool_id, tool_version=job.tool_version)
tool_id = job.tool_id
tool_version = job.tool_version
if tool:
tool_name = tool.name
is_workflow_compatible = tool.is_workflow_compatible
if not is_workflow_compatible:
disabled_reason = "This tool cannot be used in workflows"
elif tool.version != job.tool_version:
version_warning = (
f'Dataset was created with tool version "{job.tool_version}", '
f'but workflow extraction will use version "{tool.version}".'
)
else:
disabled_reason = "Tool not found"
# Build output list
outputs = []
for output_name, data in datasets:
outputs.append(WorkflowExtractionOutputDataset(
id=trans.security.encode_id(data.id),
hid=data.hid,
name=data.display_name() if hasattr(data, 'display_name') else data.name,
state=data.state or "queued",
deleted=data.deleted,
history_content_type=data.history_content_type,
))
has_non_deleted = any(not o.deleted for o in outputs)
# Encode job ID consistently
if is_fake:
job_id = str(job.id) # FakeJob IDs are already strings like "fake_123"
else:
job_id = trans.security.encode_id(job.id)
return WorkflowExtractionJob(
id=job_id,
tool_name=tool_name,
tool_id=tool_id,
tool_version=tool_version,
is_workflow_compatible=is_workflow_compatible and not is_fake,
is_fake=is_fake,
disabled_reason=disabled_reason,
outputs=outputs,
version_warning=version_warning,
has_non_deleted_outputs=has_non_deleted,
)File: client/src/api/workflowExtraction.ts (new file)
import { GalaxyApi } from "@/api";
import { rethrowSimple } from "@/utils/simple-error";
export interface ExtractionOutputDataset {
id: string;
hid: number;
name: string;
state: string;
deleted: boolean;
history_content_type: "dataset" | "dataset_collection";
}
export interface ExtractionJob {
id: string;
tool_name: string;
tool_id: string | null;
tool_version: string | null;
is_workflow_compatible: boolean;
is_fake: boolean;
disabled_reason: string | null;
outputs: ExtractionOutputDataset[];
version_warning: string | null;
has_non_deleted_outputs: boolean;
}
export interface ExtractionSummary {
history_id: string;
history_name: string;
jobs: ExtractionJob[];
warnings: string[];
default_workflow_name: string;
}
export async function getExtractionSummary(historyId: string): Promise<ExtractionSummary> {
const { data, error } = await GalaxyApi().GET("/api/histories/{history_id}/extraction_summary", {
params: { path: { history_id: historyId } },
});
if (error) {
rethrowSimple(error);
}
return data as ExtractionSummary;
}
export interface ExtractWorkflowPayload {
from_history_id: string;
workflow_name: string;
job_ids: string[];
dataset_ids: number[];
dataset_collection_ids: number[];
dataset_names?: string[];
dataset_collection_names?: string[];
}
export async function extractWorkflow(payload: ExtractWorkflowPayload): Promise<{ id: string; url: string }> {
const { data, error } = await GalaxyApi().POST("/api/workflows", {
body: payload,
});
if (error) {
rethrowSimple(error);
}
return data as { id: string; url: string };
}File: client/src/components/Workflow/WorkflowExtraction.vue (new file)
<script setup lang="ts">
import { faCheck, faExclamationTriangle, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BFormCheckbox, BFormInput, BTable } from "bootstrap-vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router/composables";
import {
extractWorkflow,
getExtractionSummary,
type ExtractionJob,
type ExtractionOutputDataset,
type ExtractionSummary,
} from "@/api/workflowExtraction";
import { Toast } from "@/composables/toast";
import AsyncButton from "@/components/Common/AsyncButton.vue";
import Heading from "@/components/Common/Heading.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
historyId: string;
}
const props = defineProps<Props>();
const router = useRouter();
// State
const loading = ref(true);
const loadError = ref<string | null>(null);
const summary = ref<ExtractionSummary | null>(null);
const workflowName = ref("");
const selectedJobs = ref<Set<string>>(new Set());
const inputDatasets = ref<Map<string, { hid: number; name: string; type: string }>>(new Map());
// Computed
const selectableJobs = computed(() => {
if (!summary.value) return [];
return summary.value.jobs.filter((j) => j.is_workflow_compatible && j.has_non_deleted_outputs);
});
const disabledJobs = computed(() => {
if (!summary.value) return [];
return summary.value.jobs.filter((j) => !j.is_workflow_compatible || j.is_fake);
});
const canSubmit = computed(() => {
return workflowName.value.trim().length > 0 && (selectedJobs.value.size > 0 || inputDatasets.value.size > 0);
});
// Methods
async function loadSummary() {
loading.value = true;
loadError.value = null;
try {
summary.value = await getExtractionSummary(props.historyId);
workflowName.value = summary.value.default_workflow_name;
// Pre-select all workflow-compatible jobs with non-deleted outputs
selectableJobs.value.forEach((job) => {
selectedJobs.value.add(job.id);
});
} catch (e) {
loadError.value = String(e);
} finally {
loading.value = false;
}
}
function toggleJob(jobId: string) {
if (selectedJobs.value.has(jobId)) {
selectedJobs.value.delete(jobId);
} else {
selectedJobs.value.add(jobId);
}
}
function toggleAllJobs(checked: boolean) {
if (checked) {
selectableJobs.value.forEach((job) => selectedJobs.value.add(job.id));
} else {
selectedJobs.value.clear();
}
// Also toggle input dataset selections
if (checked) {
disabledJobs.value.forEach((job) => {
job.outputs.forEach((output) => {
const key = `${output.history_content_type}_${output.hid}`;
inputDatasets.value.set(key, {
hid: output.hid,
name: output.name,
type: output.history_content_type,
});
});
});
} else {
inputDatasets.value.clear();
}
}
function toggleInputDataset(output: ExtractionOutputDataset) {
const key = `${output.history_content_type}_${output.hid}`;
if (inputDatasets.value.has(key)) {
inputDatasets.value.delete(key);
} else {
inputDatasets.value.set(key, {
hid: output.hid,
name: output.name,
type: output.history_content_type,
});
}
}
function isInputSelected(output: ExtractionOutputDataset): boolean {
return inputDatasets.value.has(`${output.history_content_type}_${output.hid}`);
}
async function onSubmit() {
// Separate dataset and collection inputs
const datasetIds: number[] = [];
const datasetNames: string[] = [];
const collectionIds: number[] = [];
const collectionNames: string[] = [];
inputDatasets.value.forEach((input) => {
if (input.type === "dataset") {
datasetIds.push(input.hid);
datasetNames.push(input.name);
} else {
collectionIds.push(input.hid);
collectionNames.push(input.name);
}
});
try {
const result = await extractWorkflow({
from_history_id: props.historyId,
workflow_name: workflowName.value,
job_ids: Array.from(selectedJobs.value),
dataset_ids: datasetIds,
dataset_collection_ids: collectionIds,
dataset_names: datasetNames,
dataset_collection_names: collectionNames,
});
Toast.success(`Workflow "${workflowName.value}" created successfully!`);
router.push(`/workflows/edit?id=${result.id}`);
} catch (e) {
Toast.error(`Failed to create workflow: ${e}`);
}
}
onMounted(() => {
loadSummary();
});
</script>
<template>
<div class="workflow-extraction">
<Heading h1 separator size="lg">Extract Workflow from History</Heading>
<LoadingSpan v-if="loading" message="Loading history contents..." />
<BAlert v-else-if="loadError" variant="danger" show>
Failed to load history: {{ loadError }}
</BAlert>
<template v-else-if="summary">
<p>
The following list contains each tool that was run to create the datasets in your history.
Please select those that you wish to include in the workflow.
</p>
<p class="text-muted">
Tools which cannot be run interactively and thus cannot be incorporated into a workflow
will be shown in gray.
</p>
<!-- Warnings -->
<BAlert v-for="(warning, idx) in summary.warnings" :key="idx" variant="warning" show>
{{ warning }}
</BAlert>
<!-- Workflow Name -->
<div class="form-group mb-3">
<label for="workflow-name" class="font-weight-bold">Workflow name</label>
<BFormInput
id="workflow-name"
v-model="workflowName"
type="text"
placeholder="Enter workflow name"
/>
</div>
<!-- Action Buttons -->
<div class="mb-3">
<AsyncButton
color="blue"
:icon="faCheck"
title="Create Workflow"
:disabled="!canSubmit"
:action="onSubmit">
Create Workflow
</AsyncButton>
<button class="btn btn-secondary ml-2" @click="toggleAllJobs(true)">Check all</button>
<button class="btn btn-secondary ml-2" @click="toggleAllJobs(false)">Uncheck all</button>
</div>
<!-- Jobs Table -->
<table class="table table-bordered extraction-table">
<thead>
<tr>
<th style="width: 47.5%">Tool</th>
<th style="width: 5%"></th>
<th style="width: 47.5%">History items created</th>
</tr>
</thead>
<tbody>
<tr v-for="job in summary.jobs" :key="job.id" :class="{ disabled: !job.is_workflow_compatible }">
<td>
<div class="tool-form" :class="{ 'tool-form-disabled': !job.is_workflow_compatible }">
<div class="tool-form-title">{{ job.tool_name }}</div>
<div class="tool-form-body">
<template v-if="!job.is_workflow_compatible">
<p class="text-muted font-italic">
{{ job.disabled_reason || "This tool cannot be used in workflows" }}
</p>
</template>
<template v-else>
<BFormCheckbox
:checked="selectedJobs.has(job.id)"
@change="toggleJob(job.id)">
Include "{{ job.tool_name }}" in workflow
</BFormCheckbox>
<BAlert v-if="!job.has_non_deleted_outputs" variant="info" show class="mt-2 mb-0">
All job outputs have been deleted
</BAlert>
<BAlert v-if="job.version_warning" variant="warning" show class="mt-2 mb-0">
{{ job.version_warning }}
</BAlert>
</template>
</div>
</div>
</td>
<td class="text-center align-middle">▶</td>
<td>
<div v-for="output in job.outputs" :key="output.id" class="output-item">
<div
class="dataset-item"
:class="[`state-${output.state}`, { deleted: output.deleted }]">
<span class="hid">{{ output.hid }}</span>
<span class="name">{{ output.name }}</span>
</div>
<template v-if="!job.is_workflow_compatible">
<BFormCheckbox
:checked="isInputSelected(output)"
class="ml-2"
@change="toggleInputDataset(output)">
Treat as input {{ output.history_content_type === 'dataset_collection' ? 'collection' : 'dataset' }}
</BFormCheckbox>
<BFormInput
v-if="isInputSelected(output)"
v-model="inputDatasets.get(`${output.history_content_type}_${output.hid}`).name"
size="sm"
class="ml-4 mt-1"
style="max-width: 300px"
/>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</template>
</div>
</template>
<style scoped lang="scss">
.workflow-extraction {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.extraction-table {
.disabled {
opacity: 0.7;
}
}
.tool-form {
border: 1px solid #ddd;
border-radius: 4px;
margin: 0.5rem 0;
&.tool-form-disabled {
background-color: #f8f9fa;
.tool-form-title {
color: #6c757d;
}
}
}
.tool-form-title {
background-color: #e9ecef;
padding: 0.5rem 1rem;
font-weight: bold;
border-bottom: 1px solid #ddd;
}
.tool-form-body {
padding: 0.5rem 1rem;
}
.output-item {
margin: 0.5rem 0;
}
.dataset-item {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background-color: #f0f0f0;
&.deleted {
opacity: 0.5;
text-decoration: line-through;
}
&.state-ok {
border-left: 3px solid #28a745;
}
&.state-error {
border-left: 3px solid #dc3545;
}
&.state-running, &.state-queued {
border-left: 3px solid #ffc107;
}
.hid {
font-weight: bold;
margin-right: 0.25rem;
}
}
</style>File: client/src/entry/analysis/router.js
Add import and route:
// Add import at top
import WorkflowExtraction from "@/components/Workflow/WorkflowExtraction.vue";
// Add route in the children array of Analysis routes (around line 880)
{
path: "workflows/extract",
component: WorkflowExtraction,
redirect: redirectAnon(),
props: (route) => ({
historyId: route.query.history_id,
}),
},File: client/src/components/History/HistoryOptions.vue
Replace the iframeRedirect call with Vue router navigation:
<!-- Before (lines 210-217) -->
<BDropdownItem
v-if="historyStore.currentHistoryId === history.id"
:disabled="isAnonymous"
:title="userTitle('Convert History to Workflow')"
@click="iframeRedirect(`/workflow/build_from_current_history?history_id=${history.id}`)">
<FontAwesomeIcon fixed-width :icon="faFileExport" />
<span v-localize>Extract Workflow</span>
</BDropdownItem>
<!-- After -->
<BDropdownItem
v-if="historyStore.currentHistoryId === history.id"
:disabled="isAnonymous"
:title="userTitle('Convert History to Workflow')"
:to="`/workflows/extract?history_id=${history.id}`">
<FontAwesomeIcon fixed-width :icon="faFileExport" />
<span v-localize>Extract Workflow</span>
</BDropdownItem>Remove iframeRedirect import if no longer used elsewhere.
-
Delete Mako template:
templates/build_from_current_history.mako -
Remove controller method from
lib/galaxy/webapps/galaxy/controllers/workflow.py:- Delete
build_from_current_history()method (lines 182-230)
- Delete
-
Update imports if
summarizeis no longer imported in controller
| File | Description |
|---|---|
lib/galaxy/schema/workflow_extraction.py |
Pydantic models for extraction API |
client/src/api/workflowExtraction.ts |
TypeScript API client |
client/src/components/Workflow/WorkflowExtraction.vue |
Main Vue component |
| File | Changes |
|---|---|
lib/galaxy/webapps/galaxy/api/histories.py |
Add extraction_summary endpoint |
lib/galaxy/webapps/galaxy/services/histories.py |
Add get_extraction_summary method |
client/src/entry/analysis/router.js |
Add route for WorkflowExtraction |
client/src/components/History/HistoryOptions.vue |
Replace iframeRedirect with router link |
| File | Reason |
|---|---|
templates/build_from_current_history.mako |
Replaced by Vue component |
| File | Changes |
|---|---|
lib/galaxy/webapps/galaxy/controllers/workflow.py |
Remove build_from_current_history method |
File: lib/galaxy_test/api/test_workflow_extraction.py (add tests)
Review Notes:
- Use
ApiTestCaseas base class (Galaxy convention for API tests)- Original plan had only 4 tests; expanded to ~15 for proper coverage
- Tests grouped by: basic functionality, edge cases, authorization, error handling
from galaxy_test.base.api import ApiTestCase
from galaxy_test.base.populators import DatasetPopulator
class TestWorkflowExtractionSummaryAPI(ApiTestCase):
"""Tests for GET /api/histories/{history_id}/extraction_summary endpoint."""
dataset_populator: DatasetPopulator
def setUp(self):
super().setUp()
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
# === Basic Functionality Tests ===
def test_extraction_summary_empty_history(self):
"""Empty history returns empty jobs list."""
history_id = self.dataset_populator.new_history()
response = self._get(f"histories/{history_id}/extraction_summary")
self._assert_status_code_is(response, 200)
summary = response.json()
assert summary["jobs"] == []
assert summary["history_id"] == history_id
assert "default_workflow_name" in summary
def test_extraction_summary_with_uploaded_dataset(self):
"""Uploaded dataset appears as fake job."""
history_id = self.dataset_populator.new_history()
self.dataset_populator.new_dataset(history_id, wait=True)
response = self._get(f"histories/{history_id}/extraction_summary")
self._assert_status_code_is(response, 200)
summary = response.json()
# Should have exactly one fake job for the upload
assert len(summary["jobs"]) == 1
job = summary["jobs"][0]
assert job["is_fake"] is True
assert job["is_workflow_compatible"] is False
assert len(job["outputs"]) == 1
assert job["outputs"][0]["hid"] == 1
def test_extraction_summary_with_tool_run(self):
"""Tool run appears as workflow-compatible job."""
history_id = self.dataset_populator.new_history()
hda = self.dataset_populator.new_dataset(history_id, wait=True)
# Run cat tool
self.dataset_populator.run_tool(
tool_id="cat1",
inputs={"input1": {"src": "hda", "id": hda["id"]}},
history_id=history_id,
)
self.dataset_populator.wait_for_history(history_id)
response = self._get(f"histories/{history_id}/extraction_summary")
self._assert_status_code_is(response, 200)
summary = response.json()
# Should have 2 jobs: fake (upload) + cat
assert len(summary["jobs"]) == 2
cat_jobs = [j for j in summary["jobs"] if j["tool_id"] and "cat" in j["tool_id"]]
assert len(cat_jobs) == 1
assert cat_jobs[0]["is_workflow_compatible"] is True
assert cat_jobs[0]["is_fake"] is False
def test_extraction_summary_job_outputs(self):
"""Job outputs include correct metadata."""
history_id = self.dataset_populator.new_history()
hda = self.dataset_populator.new_dataset(history_id, content="test", wait=True)
response = self._get(f"histories/{history_id}/extraction_summary")
summary = response.json()
output = summary["jobs"][0]["outputs"][0]
assert "id" in output
assert "hid" in output
assert "name" in output
assert "state" in output
assert "deleted" in output
assert output["history_content_type"] == "dataset"
# === Edge Cases ===
def test_extraction_summary_deleted_dataset(self):
"""Deleted dataset still appears but marked as deleted."""
history_id = self.dataset_populator.new_history()
hda = self.dataset_populator.new_dataset(history_id, wait=True)
# Delete the dataset
self._delete(f"histories/{history_id}/contents/{hda['id']}")
response = self._get(f"histories/{history_id}/extraction_summary")
summary = response.json()
assert len(summary["jobs"]) == 1
assert summary["jobs"][0]["outputs"][0]["deleted"] is True
assert summary["jobs"][0]["has_non_deleted_outputs"] is False
def test_extraction_summary_with_collection(self):
"""Dataset collection appears in extraction summary."""
history_id = self.dataset_populator.new_history()
hdca = self.dataset_populator.new_collection(
history_id,
collection_type="list",
element_identifiers=[{"name": "elem1", "src": "new_collection", "collection_type": ""}],
)
self.dataset_populator.wait_for_history(history_id)
response = self._get(f"histories/{history_id}/extraction_summary")
summary = response.json()
# Should have job(s) for the collection
collection_outputs = [
o for j in summary["jobs"] for o in j["outputs"]
if o["history_content_type"] == "dataset_collection"
]
assert len(collection_outputs) >= 1
def test_extraction_summary_tool_version_warning(self):
"""Version mismatch produces warning."""
# This test would need a tool with version changes
# Placeholder for when such a test tool is available
pass
# === Authorization Tests ===
def test_extraction_summary_owner_access(self):
"""History owner can access extraction summary."""
history_id = self.dataset_populator.new_history()
response = self._get(f"histories/{history_id}/extraction_summary")
self._assert_status_code_is(response, 200)
def test_extraction_summary_nonexistent_history(self):
"""Nonexistent history returns 404."""
response = self._get("histories/nonexistent123/extraction_summary")
self._assert_status_code_is(response, 404)
def test_extraction_summary_inaccessible_history(self):
"""Inaccessible history returns 403."""
# Create history as one user, try to access as another
history_id = self.dataset_populator.new_history()
# Create a different user and try to access
with self._different_user():
response = self._get(f"histories/{history_id}/extraction_summary")
self._assert_status_code_is(response, 403)
# === Response Format Tests ===
def test_extraction_summary_response_schema(self):
"""Response matches expected schema."""
history_id = self.dataset_populator.new_history()
self.dataset_populator.new_dataset(history_id, wait=True)
response = self._get(f"histories/{history_id}/extraction_summary")
summary = response.json()
# Top-level fields
assert "history_id" in summary
assert "history_name" in summary
assert "jobs" in summary
assert "warnings" in summary
assert "default_workflow_name" in summary
# Job fields
job = summary["jobs"][0]
required_job_fields = [
"id", "tool_name", "tool_id", "tool_version",
"is_workflow_compatible", "is_fake", "disabled_reason",
"outputs", "version_warning", "has_non_deleted_outputs"
]
for field in required_job_fields:
assert field in job, f"Missing field: {field}"
def test_extraction_summary_default_workflow_name(self):
"""Default workflow name includes history name."""
history_id = self.dataset_populator.new_history(name="My Test History")
response = self._get(f"histories/{history_id}/extraction_summary")
summary = response.json()
assert "My Test History" in summary["default_workflow_name"]File: client/src/components/Workflow/WorkflowExtraction.test.ts (new file)
Review Note: Frontend tests use Vitest (not Jest). Update imports and mocking patterns accordingly.
import { mount } from "@vue/test-utils";
import { createTestingPinia } from "@pinia/testing";
import { BFormCheckbox, BFormInput } from "bootstrap-vue";
import flushPromises from "flush-promises";
import { describe, it, expect, beforeEach, vi } from "vitest";
import WorkflowExtraction from "./WorkflowExtraction.vue";
// Mock the API (Vitest pattern)
vi.mock("@/api/workflowExtraction", () => ({
getExtractionSummary: vi.fn(),
extractWorkflow: vi.fn(),
}));
import { getExtractionSummary, extractWorkflow } from "@/api/workflowExtraction";
import type { Mock } from "vitest";
const mockSummary = {
history_id: "abc123",
history_name: "Test History",
jobs: [
{
id: "job1",
tool_name: "Concatenate",
tool_id: "cat1",
tool_version: "1.0",
is_workflow_compatible: true,
is_fake: false,
disabled_reason: null,
outputs: [
{ id: "out1", hid: 2, name: "Output 1", state: "ok", deleted: false, history_content_type: "dataset" }
],
version_warning: null,
has_non_deleted_outputs: true,
},
{
id: "fake_1",
tool_name: "Import from History",
tool_id: null,
tool_version: null,
is_workflow_compatible: false,
is_fake: true,
disabled_reason: "Input dataset",
outputs: [
{ id: "in1", hid: 1, name: "Input 1", state: "ok", deleted: false, history_content_type: "dataset" }
],
version_warning: null,
has_non_deleted_outputs: true,
}
],
warnings: [],
default_workflow_name: "Workflow constructed from history 'Test History'",
};
describe("WorkflowExtraction", () => {
beforeEach(() => {
vi.clearAllMocks();
(getExtractionSummary as Mock).mockResolvedValue(mockSummary);
});
it("loads and displays extraction summary", async () => {
const wrapper = mount(WorkflowExtraction, {
propsData: { historyId: "abc123" },
global: {
plugins: [createTestingPinia()],
},
});
await flushPromises();
expect(getExtractionSummary).toHaveBeenCalledWith("abc123");
expect(wrapper.text()).toContain("Concatenate");
expect(wrapper.text()).toContain("Import from History");
});
it("pre-selects workflow-compatible jobs", async () => {
const wrapper = mount(WorkflowExtraction, {
propsData: { historyId: "abc123" },
global: {
plugins: [createTestingPinia()],
},
});
await flushPromises();
const checkboxes = wrapper.findAllComponents(BFormCheckbox);
// The workflow-compatible job should be checked
const compatibleCheckbox = checkboxes.find(c => c.text().includes("Concatenate"));
expect(compatibleCheckbox?.props("checked")).toBe(true);
});
it("submits workflow extraction", async () => {
(extractWorkflow as Mock).mockResolvedValue({ id: "wf123", url: "/workflows/wf123" });
const wrapper = mount(WorkflowExtraction, {
propsData: { historyId: "abc123" },
global: {
plugins: [createTestingPinia()],
},
});
await flushPromises();
// Click create button
const createBtn = wrapper.find('[title="Create Workflow"]');
await createBtn.trigger("click");
await flushPromises();
expect(extractWorkflow).toHaveBeenCalledWith(expect.objectContaining({
from_history_id: "abc123",
job_ids: ["job1"],
}));
});
});File: lib/galaxy_test/selenium/test_workflow_extraction.py (new file)
from .framework import SeleniumTestCase
class TestWorkflowExtractionVue(SeleniumTestCase):
"""Selenium tests for the Vue workflow extraction UI."""
ensure_registered = True
def test_extraction_ui_loads(self):
"""Test that the extraction UI loads for a history with content."""
self.perform_upload(self.get_filename("1.fasta"))
self.history_panel_wait_for_hid_ok(1)
# Navigate to extraction
self.navigate_to_extraction()
# Should see the extraction form
self.wait_for_selector(".workflow-extraction")
self.assert_selector_present("input#workflow-name")
def test_extraction_creates_workflow(self):
"""Test that workflow extraction creates and opens workflow."""
self.perform_upload(self.get_filename("1.fasta"))
self.history_panel_wait_for_hid_ok(1)
# Run a tool
self.tool_open("cat1")
self.tool_set_value("input1", "1.fasta")
self.tool_form_execute()
self.history_panel_wait_for_hid_ok(2)
# Navigate to extraction
self.navigate_to_extraction()
# Should have the cat job selected
self.wait_for_selector("input[type='checkbox']:checked")
# Create workflow
self.click_button("Create Workflow")
# Should navigate to editor
self.wait_for_selector(".workflow-editor", timeout=30)
def navigate_to_extraction(self):
"""Navigate to the workflow extraction page."""
history_id = self.current_history_id()
self.navigate_to(f"/workflows/extract?history_id={history_id}")-
Phase 1: Backend API
- Create schema file with Pydantic models
- Add endpoint to histories API
- Add service method
- Write and run backend tests
-
Phase 2: Vue Component
- Create API client
- Create Vue component
- Add route
- Write and run frontend tests
-
Phase 3: Integration
- Update HistoryOptions.vue to use Vue routing
- Test end-to-end flow
- Run Selenium tests
-
Phase 4: Cleanup
- Remove Mako template
- Remove controller method
- Update any remaining references
- Final testing pass
-
Large history performance: Should API paginate jobs? Current Mako loads all at once. Consider lazy loading for histories with 100+ jobs.
- Review suggestion: Add
limitandoffsetquery params; default to unpaginated for backwards compat.
- Review suggestion: Add
-
Input naming UX: Current Mako has inline text inputs for input names. Should we use a modal instead for cleaner UX?
-
Accessibility: Current Mako has known accessibility issues (#17194). Vue component should use proper ARIA labels.
- Review suggestion: Add
aria-labelto all checkboxes, use semantic HTML for table.
- Review suggestion: Add
-
Error states: How to handle partial failures (some jobs extractable, some not)?
- Review suggestion: Continue showing all jobs; disable rows with errors; show inline error messages.
-
Collection type display: Should we show collection types (list, paired, etc.) in the UI?
-
URL back-compat: Should
/workflow/build_from_current_historyredirect to/workflows/extract?- Review suggestion: Yes, add redirect in legacy controller before removal.
-
FakeJob type imports: Need to verify
FakeJobandDatasetCollectionCreationJobare exported fromgalaxy.workflow.extractforisinstance()checks.
| Review Type | Status | Notes |
|---|---|---|
| Python Code Structure | ✅ Reviewed | Type annotations added, helper method extracted |
| FastAPI Conventions | ✅ Reviewed | CBV pattern documented, error responses added |
| Business Logic Organization | ✅ Reviewed | Service layer properly separated |
| Dependency Injection | ✅ Reviewed | Uses existing manager pattern |
| Test Coverage | ✅ Reviewed | Expanded from 4 to 15 tests, fixed base class |
| Frontend (Vitest) | ✅ Reviewed | Updated to Vitest patterns |
Last Updated: Code review findings incorporated into plan.