From 5107a2494a182a96ccd25cb99a60b763028b301b Mon Sep 17 00:00:00 2001 From: John Chilton Date: Tue, 10 Dec 2024 15:19:46 -0500 Subject: [PATCH] Build dataset collection input definition on the client. --- .../Form/Elements/FormSelection.vue | 2 +- .../Editor/Forms/FormCollectionType.vue | 54 ++++++++++ .../Workflow/Editor/Forms/FormDatatype.vue | 36 +++++-- .../Workflow/Editor/Forms/FormDefault.vue | 10 +- .../Editor/Forms/FormInputCollection.vue | 102 ++++++++++++++++++ .../Editor/composables/useStepProps.ts | 2 + .../Editor/composables/useToolState.ts | 40 +++++++ .../modules/collectionTypeDescription.ts | 10 ++ lib/galaxy/webapps/galaxy/api/workflows.py | 16 +-- lib/galaxy/workflow/modules.py | 34 +----- 10 files changed, 258 insertions(+), 48 deletions(-) create mode 100644 client/src/components/Workflow/Editor/Forms/FormCollectionType.vue create mode 100644 client/src/components/Workflow/Editor/Forms/FormInputCollection.vue create mode 100644 client/src/components/Workflow/Editor/composables/useToolState.ts diff --git a/client/src/components/Form/Elements/FormSelection.vue b/client/src/components/Form/Elements/FormSelection.vue index 15176a48f063..12d681f4a517 100644 --- a/client/src/components/Form/Elements/FormSelection.vue +++ b/client/src/components/Form/Elements/FormSelection.vue @@ -100,7 +100,7 @@ watch( ); const showSelectPreference = computed( - () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" + () => props.multiple && props.display !== "checkboxes" && props.display !== "radio" && props.display !== "simple" ); const displayMany = computed(() => showSelectPreference.value && useMany.value); diff --git a/client/src/components/Workflow/Editor/Forms/FormCollectionType.vue b/client/src/components/Workflow/Editor/Forms/FormCollectionType.vue new file mode 100644 index 000000000000..a93cb5ded6f1 --- /dev/null +++ b/client/src/components/Workflow/Editor/Forms/FormCollectionType.vue @@ -0,0 +1,54 @@ + + + diff --git a/client/src/components/Workflow/Editor/Forms/FormDatatype.vue b/client/src/components/Workflow/Editor/Forms/FormDatatype.vue index 466a9fca75e3..b77474cada04 100644 --- a/client/src/components/Workflow/Editor/Forms/FormDatatype.vue +++ b/client/src/components/Workflow/Editor/Forms/FormDatatype.vue @@ -1,5 +1,5 @@ diff --git a/client/src/components/Workflow/Editor/Forms/FormDefault.vue b/client/src/components/Workflow/Editor/Forms/FormDefault.vue index 3a0f82c0a9d6..a455ba3a0c5b 100644 --- a/client/src/components/Workflow/Editor/Forms/FormDefault.vue +++ b/client/src/components/Workflow/Editor/Forms/FormDefault.vue @@ -43,8 +43,15 @@ v-if="isSubworkflow" :step="step" @onUpdateStep="(id, step) => emit('onUpdateStep', id, step)" /> + + +import { computed, toRef } from "vue"; + +import type { DatatypesMapperModel } from "@/components/Datatypes/model"; +import type { Step } from "@/stores/workflowStepStore"; + +import { useToolState } from "../composables/useToolState"; + +import FormElement from "@/components/Form/FormElement.vue"; +import FormCollectionType from "@/components/Workflow/Editor/Forms/FormCollectionType.vue"; +import FormDatatype from "@/components/Workflow/Editor/Forms/FormDatatype.vue"; + +interface ToolState { + collection_type: string | null; + optional: boolean; + format: string | null; + tag: string | null; +} + +const props = defineProps<{ + step: Step; + datatypes: DatatypesMapperModel["datatypes"]; +}>(); + +const stepRef = toRef(props, "step"); +const { toolState } = useToolState(stepRef); + +function cleanToolState(): ToolState { + if (toolState.value) { + return { ...toolState.value } as unknown as ToolState; + } else { + return { + collection_type: null, + optional: false, + tag: null, + format: null, + }; + } +} + +const emit = defineEmits(["onChange"]); + +function onDatatype(newDatatype: string[]) { + const state = cleanToolState(); + state.format = newDatatype.join(","); + emit("onChange", state); +} + +function onTags(newTags: string | null) { + const state = cleanToolState(); + state.tag = newTags; + emit("onChange", state); +} + +function onOptional(newOptional: boolean) { + const state = cleanToolState(); + state.optional = newOptional; + emit("onChange", state); +} + +function onCollectionType(newCollectionType: string | null) { + const state = cleanToolState(); + state.collection_type = newCollectionType; + emit("onChange", state); +} + +const formatsAsList = computed(() => { + const formatStr = toolState.value?.format as string | null; + if (formatStr) { + return formatStr.split(/\s*,\s*/); + } else { + return []; + } +}); + +// Terrible Hack: The parent component (./FormDefault.vue) ignores the first update, so +// I am sending a dummy update here. Ideally, the parent FormDefault would not expect this. +emit("onChange", cleanToolState()); + + + diff --git a/client/src/components/Workflow/Editor/composables/useStepProps.ts b/client/src/components/Workflow/Editor/composables/useStepProps.ts index 95e4787bdfd9..fe63f09b3930 100644 --- a/client/src/components/Workflow/Editor/composables/useStepProps.ts +++ b/client/src/components/Workflow/Editor/composables/useStepProps.ts @@ -12,6 +12,7 @@ export function useStepProps(step: Ref) { inputs: stepInputs, outputs: stepOutputs, post_job_actions: postJobActions, + tool_state: toolState, } = toRefs(step); const label = computed(() => step.value.label ?? undefined); @@ -29,5 +30,6 @@ export function useStepProps(step: Ref) { stepOutputs, configForm, postJobActions, + toolState, }; } diff --git a/client/src/components/Workflow/Editor/composables/useToolState.ts b/client/src/components/Workflow/Editor/composables/useToolState.ts new file mode 100644 index 000000000000..6feb13589de9 --- /dev/null +++ b/client/src/components/Workflow/Editor/composables/useToolState.ts @@ -0,0 +1,40 @@ +import { toRefs } from "@vueuse/core"; +import { computed, type Ref } from "vue"; + +import { type Step } from "@/stores/workflowStepStore"; + +export function useToolState(step: Ref) { + const { tool_state: rawToolStateRef } = toRefs(step); + + const toolState = computed(() => { + const rawToolState: Record = rawToolStateRef.value; + const parsedToolState: Record = {}; + + // This is less than ideal in a couple ways. The fact the JSON response + // has encoded JSON is gross and it would be great for module types that + // do not use the tool form to just return a simple JSON blob without + // the extra encoded. As a step two if each of these module types could + // also define a schema so we could use typed entities shared between the + // client and server that would be ideal. + for (const key in rawToolState) { + if (Object.prototype.hasOwnProperty.call(rawToolState, key)) { + const value = rawToolState[key]; + if (typeof value === "string") { + try { + const parsedValue = JSON.parse(value); + parsedToolState[key] = parsedValue; + } catch (error) { + parsedToolState[key] = rawToolState[key]; + } + } else { + parsedToolState[key] = rawToolState[key]; + } + } + } + return parsedToolState; + }); + + return { + toolState, + }; +} diff --git a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts index 792a842b0df9..cc48c2a69117 100644 --- a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts +++ b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts @@ -128,3 +128,13 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { return str.indexOf(suffix, str.length - suffix.length) !== -1; } } + +const collectionTypeRegex = /^(list|paired)(:(list|paired))*$/; + +export function isValidCollectionTypeStr(collectionType: string | undefined) { + if (collectionType) { + return collectionTypeRegex.test(collectionType); + } else { + return true; + } +} diff --git a/lib/galaxy/webapps/galaxy/api/workflows.py b/lib/galaxy/webapps/galaxy/api/workflows.py index b52d495d40e6..795706029178 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -528,15 +528,19 @@ def build_module(self, trans: GalaxyWebTransaction, payload=None): # payload is tool state if payload is None: payload = {} + module_type = payload.get("type", "tool") inputs = payload.get("inputs", {}) trans.workflow_building_mode = workflow_building_modes.ENABLED - module = module_factory.from_dict(trans, payload, from_tool_form=True) - + from_tool_form = True if module_type != "data_collection_input" else False + module = module_factory.from_dict(trans, payload, from_tool_form=from_tool_form) module_state: Dict[str, Any] = {} errors: ParameterValidationErrorsT = {} - populate_state(trans, module.get_inputs(), inputs, module_state, errors=errors, check=True) - module.recover_state(module_state, from_tool_form=True) - module.check_and_update_state() + if from_tool_form: + populate_state(trans, module.get_inputs(), inputs, module_state, errors=errors, check=True) + module.recover_state(module_state, from_tool_form=True) + module.check_and_update_state() + else: + module_state = module.get_export_state() step_dict = { "name": module.get_name(), "tool_state": module_state, @@ -546,7 +550,7 @@ def build_module(self, trans: GalaxyWebTransaction, payload=None): "config_form": module.get_config_form(), "errors": errors or None, } - if payload["type"] == "tool": + if module_type == "tool": step_dict["tool_version"] = module.get_version() return step_dict diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index 835b9ae2a710..3d792c1b9866 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -284,7 +284,9 @@ def __init__(self, trans, content_id=None, **kwds): def from_dict(Class, trans, d, **kwds): module = Class(trans, **kwds) input_connections = d.get("input_connections", {}) - module.recover_state(d.get("tool_state"), input_connections=input_connections, **kwds) + from_tool_form = kwds.get("from_tool_form", True) + state_key = "tool_state" if from_tool_form else "inputs" + module.recover_state(d.get(state_key), input_connections=input_connections, **kwds) module.label = d.get("label") return module @@ -1114,34 +1116,8 @@ class InputDataCollectionModule(InputModule): collection_type = default_collection_type def get_inputs(self): - parameter_def = self._parse_state_into_dict() - collection_type = parameter_def["collection_type"] - tag = parameter_def["tag"] - optional = parameter_def["optional"] - collection_type_source = dict( - name="collection_type", label="Collection type", type="text", value=collection_type - ) - collection_type_source["options"] = [ - {"value": "list", "label": "List of Datasets"}, - {"value": "paired", "label": "Dataset Pair"}, - {"value": "list:paired", "label": "List of Dataset Pairs"}, - ] - input_collection_type = TextToolParameter(None, collection_type_source) - tag_source = dict( - name="tag", - label="Tag filter", - type="text", - optional="true", - value=tag, - help="Tags to automatically filter inputs", - ) - input_tag = TextToolParameter(None, tag_source) - inputs = {} - inputs["collection_type"] = input_collection_type - inputs["optional"] = optional_param(optional) - inputs["format"] = format_param(self.trans, parameter_def.get("format")) - inputs["tag"] = input_tag - return inputs + # migrated to frontend + return {} def get_runtime_inputs(self, step, connections: Optional[Iterable[WorkflowStepConnection]] = None): parameter_def = self._parse_state_into_dict()