diff --git a/front-end/src/API.js b/front-end/src/API.js index 94790cd..a61e6e8 100644 --- a/front-end/src/API.js +++ b/front-end/src/API.js @@ -60,10 +60,12 @@ export async function deleteNode(node) { * Update configuration of node in server-side workflow * @param {CustomNodeModel} node - JS node to remove * @param {Object} config - configuration from options form + * @param {Object} flowConfig - flow variable configuration options * @returns {Promise} - server response (serialized node) */ -export async function updateNode(node, config) { +export async function updateNode(node, config, flowConfig) { node.config = config; + node.options.option_replace = flowConfig; const payload = {...node.options, options: node.config}; const options = { method: "POST", @@ -212,9 +214,11 @@ export async function downloadDataFile(node) { .then(async resp => { if (!resp.ok) return Promise.reject(await resp.json()); contentType = resp.headers.get("content-type"); + let filename = resp.headers.get("Content-Disposition"); + if (contentType.startsWith("text")) { resp.text().then(data => { - downloadFile(data, contentType, node.config["file"]); + downloadFile(data, contentType, filename); }) } }).catch(err => console.log(err)); diff --git a/front-end/src/components/CustomNode/CustomNodeWidget.js b/front-end/src/components/CustomNode/CustomNodeWidget.js index 3cf8cbc..a5a8e2b 100644 --- a/front-end/src/components/CustomNode/CustomNodeWidget.js +++ b/front-end/src/components/CustomNode/CustomNodeWidget.js @@ -37,8 +37,8 @@ export default class CustomNodeWidget extends React.Component { }).catch(err => console.log(err)); } - acceptConfiguration(formData) { - API.updateNode(this.props.node, formData).then(() => { + acceptConfiguration(optionsData, flowData) { + API.updateNode(this.props.node, optionsData, flowData).then(() => { this.props.node.setStatus("configured"); this.forceUpdate(); this.props.engine.repaintCanvas(); @@ -77,6 +77,7 @@ export default class CustomNodeWidget extends React.Component {
{String.fromCharCode(this.icon)}
({ - ...prevState, - data: { - ...prevState.data, - [key]: value - } - }) - ); + updateData(key, value, flow = false) { + if (flow) { + this.setState((prevState) => ({ + ...prevState, + flowData: { + ...prevState.flowData, + [key]: value + } + }) + ); + } else { + this.setState((prevState) => ({ + ...prevState, + data: { + ...prevState.data, + [key]: value + } + }) + ); + } }; // confirm, fire delete callback, close modal @@ -42,7 +55,12 @@ export default class NodeConfig extends React.Component { handleSubmit(e) { e.preventDefault(); console.log(this.state.data); - this.props.onSubmit(this.state.data); + // remove items from flow vars if null + const flowData = {...this.state.flowData}; + for (let key in flowData) { + if (flowData[key] === null) delete flowData[key]; + } + this.props.onSubmit(this.state.data, flowData); this.props.toggleShow(); }; @@ -50,7 +68,8 @@ export default class NodeConfig extends React.Component { if (!this.props.node) return null; return ( e.stopPropagation()}> + dialogClassName="NodeConfig" + onWheel={e => e.stopPropagation()}>
{this.props.node.options.name} Configuration @@ -61,6 +80,9 @@ export default class NodeConfig extends React.Component { onChange={this.updateData} node={this.props.node} value={this.props.node.config[key]} + flowValue={this.props.node.options.option_replace ? + this.props.node.options.option_replace[key] : null} + globals={this.props.globals} disableFunc={(v) => this.setState({disabled: v})}/> )} @@ -96,27 +118,61 @@ NodeConfig.propTypes = { */ function OptionInput(props) { + const [isFlow, setIsFlow] = useState(props.flowValue ? true : false); + + const handleFlowCheck = (bool) => { + // if un-checking, fire callback with null so no stale value is in `option_replace` + if (!bool) props.onChange(props.keyName, null, true); + setIsFlow(bool); + }; + + // fire callback to update `option_replace` with flow node info + const handleFlowVariable = (value) => { + props.onChange(props.keyName, value, true); + }; + let inputComp; if (props.type === "file") { - inputComp = + inputComp = } else if (props.type === "string") { +<<<<<<< HEAD + inputComp = +======= inputComp = } else if (props.type === "text") { inputComp = +>>>>>>> master } else if (props.type === "int") { - inputComp = + inputComp = } else if (props.type === "boolean") { +<<<<<<< HEAD + inputComp = +======= inputComp = } else if (props.type === "select") { inputComp = +>>>>>>> master } else { return (<>) } + + const hideFlow = props.node.options.is_global + || props.type === "file" || props.globals.length === 0 return ( - {props.label} -
{props.docstring}
- { inputComp } + {props.label} +
{props.docstring}
+ + { inputComp } + {hideFlow ? null : + + } +
) } @@ -236,8 +292,44 @@ function BooleanInput(props) { return ( + disabled={props.disabled} + checked={value} + onChange={handleChange} /> + ) +} + + +function FlowVariableOverride(props) { + + const handleSelect = (event) => { + const uuid = event.target.value; + const flow = props.flowNodes.find(d => d.id === uuid); + const obj = { + node_id: uuid, + is_global: flow.is_global + }; + props.onChange(obj); + }; + const handleCheck = (event) => { props.onFlowCheck(event.target.checked) }; + + return ( + + + {props.checked ? + + + )} + + : null + } + ) } diff --git a/front-end/src/components/Workspace.js b/front-end/src/components/Workspace.js index 38937a4..d12410b 100644 --- a/front-end/src/components/Workspace.js +++ b/front-end/src/components/Workspace.js @@ -51,7 +51,10 @@ class Workspace extends React.Component { getGlobalVars() { API.getGlobalVars() - .then(vars => this.setState({globals: vars})) + .then(vars => { + this.setState({globals: vars}); + this.model.globals = vars; + }) .catch(err => console.log(err)); } diff --git a/front-end/src/styles/NodeConfig.css b/front-end/src/styles/NodeConfig.css new file mode 100644 index 0000000..9dcbda8 --- /dev/null +++ b/front-end/src/styles/NodeConfig.css @@ -0,0 +1,7 @@ +.option-docstring { + font-size: 0.7rem; +} + +.NodeConfig { + min-width: 50%; +} \ No newline at end of file diff --git a/front-end/src/styles/Workspace.css b/front-end/src/styles/Workspace.css index a6ef99d..3be7a81 100644 --- a/front-end/src/styles/Workspace.css +++ b/front-end/src/styles/Workspace.css @@ -1,9 +1,6 @@ -.Workspace { - border: 1px solid grey; -} .NodeMenu, .GlobalFlowMenu { padding: 1rem; - margin: 0.5rem 0; + margin-bottom: 1rem; box-shadow: 0px 3px 6px grey; } .NodeMenu ul { @@ -23,7 +20,8 @@ font-size: 1rem; } .diagram-canvas { - height: 75vh; + box-shadow: 0px 2px 4px grey; + height: 100vh; background-size: 20px 20px; background-image: linear-gradient(to right, rgba(54, 169, 231, 0.1) 1px, transparent 1px), diff --git a/pyworkflow/pyworkflow/node.py b/pyworkflow/pyworkflow/node.py index 8ccc6ec..0a3bc20 100644 --- a/pyworkflow/pyworkflow/node.py +++ b/pyworkflow/pyworkflow/node.py @@ -28,15 +28,19 @@ def __init__(self, node_info): def execute(self, predecessor_data, flow_vars): raise NotImplementedError() - def get_execution_options(self, flow_nodes): + def get_execution_options(self, workflow, flow_nodes): """Replace Node options with flow variables. If the user has specified any flow variables to replace Node options, perform the replacement and return a dict with all options to use for execution. If no flow variables are included, this method will return - a copy of all Node options unchanged. + a copy of all Node options. + + For any 'file' options, the value will be replaced with a path based on + the Workflow's root directory. Args: + workflow: Workflow object to construct file paths flow_nodes: dict of FlowNodes used to replace options Returns: @@ -49,7 +53,12 @@ def get_execution_options(self, flow_nodes): for key, option in self.options.items(): if key in flow_nodes: - option.set_value(flow_nodes[key].get_replacement_value()) + replacement_value = flow_nodes[key].get_replacement_value() + else: + replacement_value = option.get_value() + + if key == 'file': + option.set_value(workflow.path(replacement_value)) execution_options[key] = option @@ -64,8 +73,9 @@ def validate(self): Raises: ParameterValidationError: invalid Parameter value """ - for option in self.options.values(): - option.validate() + for key, option in self.options.items(): + if key not in self.option_replace: + option.validate() def validate_input_data(self, num_input_data): """Validate Node input data. diff --git a/pyworkflow/pyworkflow/workflow.py b/pyworkflow/pyworkflow/workflow.py index acdabcb..6d29a07 100644 --- a/pyworkflow/pyworkflow/workflow.py +++ b/pyworkflow/pyworkflow/workflow.py @@ -304,7 +304,7 @@ def execute(self, node_id): try: # Validate input data, and replace flow variables node_to_execute.validate_input_data(len(preceding_data)) - execution_options = node_to_execute.get_execution_options(flow_nodes) + execution_options = node_to_execute.get_execution_options(self, flow_nodes) # Pass in data to current Node to use in execution output = node_to_execute.execute(preceding_data, execution_options) @@ -353,7 +353,7 @@ def load_flow_nodes(self, option_replace): else: flow_node = self.get_node(flow_node_id) - if flow_node is None or flow_node.node_type != 'FlowNode': + if flow_node is None or flow_node.node_type != 'flow_control': raise WorkflowException('load flow vars', 'The workflow does not contain FlowNode %s' % flow_node_id) flow_nodes[key] = flow_node @@ -419,8 +419,15 @@ def download_file(self, node_id): return None try: - # TODO: Change to generic "file" option to allow for more than WriteCsv - to_open = self.path(node.options['file'].get_value()) + # Check if file option is overridden by flow variable + if 'file' in node.option_replace: + flow_node = self.load_flow_nodes({'file': node.option_replace['file']}) + filename = flow_node['file'].get_replacement_value() + else: + filename = node.options['file'].get_value() + + # Construct path to file in Workflow dir + to_open = self.path(filename) return open(to_open) except KeyError: raise WorkflowException('download_file', '%s does not have an associated file' % node_id) diff --git a/vp/node/views.py b/vp/node/views.py index 740c85a..be5302a 100644 --- a/vp/node/views.py +++ b/vp/node/views.py @@ -264,13 +264,9 @@ def create_node(request): """Pass all request info to Node Factory. """ + # Any filenames/paths passed through as-is + # A Workflow-specific path is constructed at execution json_data = json.loads(request.body) - # for options with type 'file', replace value with FileStorage path - for field, info in json_data.get("option_types", dict()).items(): - if info["type"] == "file" or info["label"] == "Filename": - opt_value = json_data["options"][field] - if opt_value is not None: - json_data["options"][field] = request.pyworkflow.path(opt_value) try: return node_factory(json_data) diff --git a/vp/workflow/views.py b/vp/workflow/views.py index 15769a1..98a2b04 100644 --- a/vp/workflow/views.py +++ b/vp/workflow/views.py @@ -293,6 +293,7 @@ def download_file(request): # Construct response response = HttpResponse(content_type=content) + response['Content-Disposition'] = os.path.basename(f.name) response.write(f.read()) # File not opened with `with`; need to close