From 53f23072942758c40c05bfd11cbf11c41b29610d 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 | 104 ++++++++++++++++++ .../Editor/composables/useStepProps.ts | 2 + .../Editor/composables/useToolState.ts | 40 +++++++ .../modules/collectionTypeDescription.ts | 10 ++ lib/galaxy/webapps/galaxy/api/workflows.py | 19 +++- lib/galaxy/workflow/modules.py | 30 +---- test/unit/workflows/test_modules.py | 11 -- 11 files changed, 260 insertions(+), 58 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..cc50d02a612f 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 | string[] | null; + if (formatStr && typeof formatStr === "string") { + return formatStr.split(/\s*,\s*/); + } else if (formatStr) { + return formatStr; + } 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..e82dc623497a 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -528,15 +528,22 @@ 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 + if not from_tool_form and "tool_state" not in payload and "inputs" in payload: + # tool state not sent, use the manually constructed inputs + payload["tool_state"] = payload["inputs"] + 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 +553,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..3d0f267ff3bb 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -1114,34 +1114,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() diff --git a/test/unit/workflows/test_modules.py b/test/unit/workflows/test_modules.py index 8052c6b11950..fa1e0177701f 100644 --- a/test/unit/workflows/test_modules.py +++ b/test/unit/workflows/test_modules.py @@ -125,17 +125,6 @@ def test_data_collection_input_connections(): assert output["collection_type"] == "list:paired" -def test_data_collection_input_config_form(): - module = __from_step( - type="data_collection_input", - tool_inputs={ - "collection_type": "list:paired", - }, - ) - result = module.get_config_form() - assert result["inputs"][0]["value"], "list:paired" - - def test_cannot_create_tool_modules_for_missing_tools(): trans = MockTrans() module = modules.module_factory.from_dict(trans, {"type": "tool", "tool_id": "cat1"})