src/components/utilities/wdl-graph/index.js (675 lines of code) (raw):
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {computed, observable} from 'mobx';
import {inject, observer} from 'mobx-react';
import {
Alert,
AutoComplete,
Row,
Button,
Icon,
Input,
message,
Popover,
Tooltip,
} from 'antd';
import classNames from 'classnames';
import pipeline from 'pipeline-builder';
import {Loading} from '..';
import {textFileProcessor} from '../../../utils';
import 'pipeline-builder/dist/pipeline.css';
import styles from './wdl-graph.css';
const graphFitContentOpts = {padding: 24};
const graphSelectableTypes = [
'VisualGroup', 'VisualStep', 'VisualWorkflow',
];
const blobProcessorFn = async blob => textFileProcessor(blob);
@inject('filesCache')
@inject(({history, workflowCache}, {workflowId}) => ({
workflow: workflowId ? workflowCache.getWorkflow(workflowId) : null,
history,
}))
@observer
export default class WdlGraph extends Component {
static propTypes = {
workflowId: PropTypes.string,
wdlScript: PropTypes.string,
selectedTaskName: PropTypes.string,
onSelect: PropTypes.func,
className: PropTypes.string,
fitAllSpace: PropTypes.bool,
onGraphReady: PropTypes.func,
getNodeInfo: PropTypes.func,
hideError: PropTypes.bool,
canEdit: PropTypes.bool,
onGraphUpdated: PropTypes.func,
};
static defaultProps = {
workflowId: null,
wdlScript: null,
selectedTaskName: '',
onSelect: null,
className: '',
fitAllSpace: true,
onGraphReady: null,
getNodeInfo: null,
hideError: false,
canEdit: true,
onGraphUpdated: null,
};
state = {
canZoomIn: false,
canZoomOut: false,
error: false,
fullScreen: false,
modified: false,
};
wdlVisualizer = null;
workflow = null;
@observable
previousSuccessfulCode = null;
@observable
itemEditForm = null;
@observable
workflowUrlScriptRequest = null;
componentDidMount() {
const {onGraphReady} = this.props;
onGraphReady && onGraphReady(this);
this.loadWorkflowUrlScript();
}
componentDidUpdate() {
this.loadWorkflowUrlScript();
}
get editable() {
const {canEdit} = this.props;
return canEdit;
}
@computed
get graphSearchDataSource() {
return this.getGraphFilteredElements().map(this.renderOption);
}
@computed
get mainScript() {
const {workflow, wdlScript} = this.props;
if (wdlScript && wdlScript.length) {
return wdlScript;
}
let script = null;
if (!workflow || !workflow.loaded) {
return script;
}
if (workflow.metadata.loaded && workflow.metadata.value.submittedFiles) {
if (workflow.metadata.value.submittedFiles.workflow) {
script = workflow.metadata.value.submittedFiles.workflow;
} else if (this.workflowUrlScriptRequest && this.workflowUrlScriptRequest.loaded) {
script = this.workflowUrlScriptRequest.value || null;
}
}
return script;
}
loadWorkflowUrlScript = async () => {
const {workflow, filesCache, wdlScript} = this.props;
if ((wdlScript && wdlScript.length)
|| !workflow || !workflow.loaded || !workflow.metadata.value.submittedFiles.workflowUrl) {
return;
}
if (workflow.metadata.value.submittedFiles.workflowUrl) {
this.workflowUrlScriptRequest = filesCache.getFileContents(
workflow.metadata.value.submittedFiles.workflowUrl,
blobProcessorFn,
);
}
};
initializeContainer = async (container) => {
if (container) {
const {workflow} = this.props;
this.wdlVisualizer = new pipeline.Visualizer(container);
this.wdlVisualizer.paper.on('cell:pointerclick', this.onSelectItem);
this.wdlVisualizer.paper.on('blank:pointerclick', this.onSelectItem);
this.wdlVisualizer.paper.on('link:connect', this.modelChanged);
this.wdlVisualizer.paper.model.on('remove', this.modelChanged);
if (this.mainScript
|| (workflow && workflow.metadata.value && !workflow.metadata.value.submittedFiles.workflowUrl)) {
await this.applyCode(this.mainScript, true);
}
} else {
if (this.wdlVisualizer) {
this.wdlVisualizer.paper.off('link:connect', this.modelChanged);
this.wdlVisualizer.paper.model.off('remove', this.modelChanged);
this.wdlVisualizer.paper.off('cell:pointerclick', this.onSelectItem);
this.wdlVisualizer.paper.off('blank:pointerclick', this.onSelectItem);
}
this.wdlVisualizer = null;
}
};
applyCode = async (code = '', clearModifiedConfig) => {
const hide = message.loading('Loading...');
const onError = (msg) => {
if (clearModifiedConfig) {
this.setState({
selectedElement: null,
error: msg,
});
} else {
this.setState({
selectedElement: null,
error: msg,
});
}
};
try {
const parseResult = await pipeline.parse(code);
if (parseResult.status) {
this.workflow = parseResult.model[0];
this.clearWrongPorts(this.workflow);
this.previousSuccessfulCode = code;
this.wdlVisualizer.attachTo(this.workflow);
this.updateData();
if (clearModifiedConfig) {
this.setState({
selectedElement: null,
canZoomIn: true,
canZoomOut: true,
error: null,
});
} else {
this.setState({
selectedElement: null,
canZoomIn: true,
canZoomOut: true,
error: null,
});
}
this.onFullScreenChanged();
} else {
onError(parseResult.message);
}
} catch (e) {
onError(e);
} finally {
hide();
}
};
updateData = () => {
const {selectedTaskName, getNodeInfo} = this.props;
this.wdlVisualizer && this.wdlVisualizer.paper.model.getElements().forEach((e) => {
const view = this.wdlVisualizer.paper.findViewByModel(e);
if (selectedTaskName && e.step && e.step.name === selectedTaskName
&& graphSelectableTypes.includes(e.attributes.type)) {
this.wdlVisualizer.disableSelection();
this.wdlVisualizer.enableSelection();
this.wdlVisualizer.selection.push(e);
view && view.el && view.el.classList.toggle('selected', true);
}
if (view && view.el && e.step.type !== 'workflow') {
if (!view.el.classList.contains(styles.wdlTask)) {
view.el.classList.add(styles.wdlTask);
}
if (!view.el.dataset) {
view.el.dataset = {};
}
let status;
if (getNodeInfo) {
const info = getNodeInfo({task: {name: e.step.name || (e.step.action || {}).name}});
if (info) {
status = info.status;
}
}
if (e.step.action && e.step.action.data
&& e.step.action.data.runtime && !!e.step.action.data.runtime.pipeline) {
if (!view.el.classList.contains(styles.wdlPipelineTask)) {
view.el.classList.add(styles.wdlPipelineTask);
}
}
if (status) {
view.el.dataset.taskstatus = status.toLowerCase();
} else {
delete view.el.dataset.taskstatus;
}
}
});
};
clearWrongPorts = (step) => {
if (step.i) {
const keys = Object.keys(step.i || {});
for (let vIndex = 0; vIndex < keys.length; vIndex++) {
const variable = keys[vIndex];
if (Object.hasOwnProperty.call(step.i, variable) && step.i[variable].inputs) {
const inputsToRemove = [];
for (let i = 0; i < step.i[variable].inputs.length; i++) {
if (!step.i[variable].inputs[i].from || step.i[variable].inputs[i].from === '') {
inputsToRemove.push(step.i[variable].inputs[i]);
}
}
for (let i = 0; i < inputsToRemove.length; i++) {
const index = step.i[variable].inputs.indexOf(inputsToRemove[i]);
if (index >= 0) {
step.i[variable].inputs.splice(index, 1);
}
}
}
}
}
if (step.children) {
const childrenKeys = Object.keys(step.children || {});
for (let childIndex = 0; childIndex < childrenKeys.length; childIndex++) {
const child = childrenKeys[childIndex];
if (Object.hasOwnProperty.call(step.children, child)) {
this.clearWrongPorts(step.children[child]);
}
}
}
};
onSelectItem = () => {
const {onSelect} = this.props;
const {selectedElement} = this.state;
if ((this.wdlVisualizer.selection[0] || {}).step === selectedElement) {
return;
}
if (this.wdlVisualizer && this.wdlVisualizer.selection
&& this.wdlVisualizer.selection[0] && this.wdlVisualizer.selection[0].step) {
this.setState({selectedElement: this.wdlVisualizer.selection[0].step},
() => {
// eslint-disable-next-line react/destructuring-assignment
onSelect && onSelect({task: {name: this.state.selectedElement.name}});
});
} else {
onSelect && onSelect(null);
this.setState(
{selectedElement: null},
);
}
};
/* eslint-disable no-underscore-dangle */
fitToSelectedItem = () => {
if (this.wdlVisualizer && this.wdlVisualizer.selection
&& this.wdlVisualizer.selection[0]) {
const getOffset = (el) => {
el = el.getBoundingClientRect();
return {
left: el.left + window.scrollX,
top: el.top + window.scrollY,
};
};
const offset = getOffset(this.wdlVisualizer.paper.el);
this.wdlVisualizer.paper.setOrigin(0, 0);
this.wdlVisualizer.paper.scale(1, 1);
this.wdlVisualizer.zoom._currDeg = 0;
const paperSize = this.wdlVisualizer.paper.clientToLocalPoint({
x: this.wdlVisualizer.paper.options.width + offset.left,
y: this.wdlVisualizer.paper.options.height + offset.top,
});
const zoomLevel = 0.75; // we want selected element to take 75% of visualizer's size
const desiredSize = {x: paperSize.x * zoomLevel, y: paperSize.y * zoomLevel};
const elementSize = this.wdlVisualizer.paper.clientToLocalPoint({
x: this.wdlVisualizer.selection[0].attributes.size.width + offset.left,
y: this.wdlVisualizer.selection[0].attributes.size.height + offset.top,
});
const scale = Math.min(1, desiredSize.x / elementSize.x, desiredSize.y / elementSize.y);
const degree = Math.log(scale) / Math.log(this.wdlVisualizer.zoom._mult) - this.wdlVisualizer.zoom._currDeg;
const elementPosition = this.wdlVisualizer.selection[0].attributes.position;
this.wdlVisualizer.paper.setOrigin(
(-elementPosition.x + paperSize.x / 2.0 - elementSize.x / 2.0),
(-elementPosition.y + paperSize.y / 2.0 - elementSize.y / 2.0),
);
this.wdlVisualizer.zoom._scale(degree, {
x: this.wdlVisualizer.paper.options.width / 2.0 + offset.left,
y: this.wdlVisualizer.paper.options.height / 2.0 + offset.top,
});
}
};
/* eslint-enable */
modelChanged = () => {
this.setState({modified: true});
};
draw = () => {
this.onFullScreenChanged();
};
onFullScreenChanged = () => {
if (this.wdlVisualizer) {
const parent = this.wdlVisualizer.paper.el.parentElement;
if (parent) {
this.wdlVisualizer.paper.setDimensions(parent.offsetWidth, parent.offsetHeight);
}
this.wdlVisualizer.zoom.fitToPage(graphFitContentOpts);
}
};
zoomIn = () => {
if (this.wdlVisualizer) {
this.wdlVisualizer.zoom.zoomIn();
}
};
zoomOut = () => {
if (this.wdlVisualizer) {
this.wdlVisualizer.zoom.zoomOut();
}
};
revertChanges = async () => {
if (this.mainScript) {
await this.applyCode(this.mainScript);
this.setState(
{modified: false, selectedElement: null},
);
}
};
toggleLinks = () => {
let {showAllLinks} = this.state;
showAllLinks = !showAllLinks;
this.setState({showAllLinks}, () => {
this.wdlVisualizer && this.wdlVisualizer.paper.model.off('remove', this.modelChanged);
this.wdlVisualizer && this.wdlVisualizer.togglePorts(true, showAllLinks);
setTimeout(() => {
this.wdlVisualizer && this.wdlVisualizer.paper.model.on('remove', this.modelChanged);
}, 100);
});
};
fitGraph = () => {
this.wdlVisualizer && this.wdlVisualizer.zoom.fitToPage(graphFitContentOpts);
};
layoutGraph = () => {
this.wdlVisualizer && this.wdlVisualizer.layout() && this.fitGraph();
};
selectElement = (label, option) => {
const {name} = option.props.step;
this.wdlVisualizer && this.wdlVisualizer.paper.model.getElements().forEach((e) => {
if (name && e.step && e.step.name === name && graphSelectableTypes.includes(e.attributes.type)) {
const view = this.wdlVisualizer.paper.findViewByModel(e);
this.wdlVisualizer.disableSelection();
this.wdlVisualizer.enableSelection();
this.wdlVisualizer.selection.push(e);
view && view.el && view.el.classList.toggle('selected', true);
this.onSelectItem();
this.fitToSelectedItem();
}
});
};
getGraphFilteredElements = (element = this.workflow) => {
const {graphSearch} = this.state;
const elements = [];
if (!graphSearch) {
return [];
}
const childrenKeys = Object.keys(element.children || {});
for (let keyIndex = 0; keyIndex < childrenKeys.length; keyIndex++) {
const key = childrenKeys[keyIndex];
if (Object.hasOwnProperty.call(element.children, key)) {
if (key.toLowerCase().includes(graphSearch.toLowerCase())
|| (element.children[key].type || 'task').toLowerCase().includes(graphSearch.toLowerCase())) {
elements.push({
alias: element.children[key].name,
type: element.children[key].type || 'task',
step: element.children[key],
});
}
if (element.children[key].children && Object.keys(element.children[key].children || {}).length) {
const childEls = this.getGraphFilteredElements(element.children[key].children);
if (childEls.length) {
elements.concat(childEls);
}
}
}
}
return elements;
};
renderOption = (item) => {
let expression = '';
if (item.type.toLowerCase() === 'if' || item.type.toLowerCase() === 'while'
|| item.type.toLowerCase() === 'scatter') {
if (item.step && item.step.action
&& item.step.action.data && item.step.action.data.expression) {
expression = `(${item.step.action.data.expression})`;
}
} else {
expression = item.alias;
}
return (
<AutoComplete.Option key={item.alias} value={item.alias} step={item.step}>
{item.type} {expression}
</AutoComplete.Option>
);
};
clearSearchAutocomplete = () => {
this.setState({graphSearch: null});
};
onSearchChange = (graphSearch) => {
this.setState({graphSearch});
};
onTooltipVisibleChange = (tooltipVisible) => {
this.setState({tooltipVisible});
};
handleSearchControlVisible = (searchControlVisible) => {
const handleChange = () => {
this.setState({searchControlVisible, tooltipVisible: false}, () => {
if (!searchControlVisible) {
this.clearSearchAutocomplete();
}
});
};
if (!searchControlVisible) {
setTimeout(handleChange, 300);
} else {
handleChange();
}
};
renderGraphSearch = () => {
const {searchControlVisible, tooltipVisible, graphSearch} = this.state;
const searchControl = (
<AutoComplete
dataSource={this.graphSearchDataSource}
value={graphSearch}
onChange={this.onSearchChange}
placeholder="Element type or name..."
optionLabelProp="value"
style={{minWidth: 300}}
onSelect={this.selectElement}
>
<Input.Search />
</AutoComplete>
);
return (
<Tooltip
title="Search element"
onVisibleChange={this.onTooltipVisibleChange}
visible={tooltipVisible}
placement="right"
>
<Popover
content={searchControl}
placement="right"
trigger="click"
onVisibleChange={this.handleSearchControlVisible}
visible={searchControlVisible}
>
<Button
id="wdl-graph-search-button"
className={styles.wdlAppearanceButton}
shape="circle"
>
<Icon type="search" />
</Button>
</Popover>
</Tooltip>
);
};
toggleFullScreen = () => {
const {fullScreen} = this.state;
this.setState({fullScreen: !fullScreen},
() => this.onFullScreenChanged());
};
renderAppearancePanel = () => {
const {
canZoomIn,
canZoomOut,
fullScreen,
modified,
showAllLinks,
} = this.state;
return (
<div className={classNames(styles.wdlGraphSidePanel, styles.left)}>
{
this.editable
&& (
<Tooltip title="Save" placement="right">
<Button
id="wdl-graph-save-button"
className={classNames(styles.wdlAppearanceButton, styles.active, styles.noFade)}
disabled={!modified}
type="primary"
shape="circle"
onClick={this.openCommitFormDialog}
>
<Icon type="save" />
</Button>
</Tooltip>
)
}
{
this.editable
&& (
<Tooltip title="Revert changes" placement="right">
<Button
id="wdl-graph-revert-button"
className={classNames(styles.wdlAppearanceButton, styles.noFade)}
disabled={!modified}
shape="circle"
onClick={() => this.revertChanges()}
>
<Icon type="reload" />
</Button>
</Tooltip>
)
}
{
this.editable
&& <div className={styles.separator}>{'\u00A0'}</div>
}
<Tooltip title="Layout" placement="right">
<Button
className={styles.wdlAppearanceButton}
id="wdl-graph-layout-button"
shape="circle"
onClick={this.layoutGraph}
>
<Icon type="appstore-o" />
</Button>
</Tooltip>
<Tooltip title="Fit to screen" placement="right">
<Button
className={styles.wdlAppearanceButton}
id="wdl-graph-fit-button"
shape="circle"
onClick={this.fitGraph}
>
<Icon type="scan" />
</Button>
</Tooltip>
<Tooltip
title={showAllLinks ? 'Hide links' : 'Show links'}
placement="right"
>
<Button
className={classNames(styles.wdlAppearanceButton, {[styles.active]: showAllLinks})}
type={showAllLinks ? 'primary' : 'default'}
id={`wdl-graph-${showAllLinks ? 'hide-links' : 'show-links'}-button`}
shape="circle"
onClick={this.toggleLinks}
>
<Icon type="swap" />
</Button>
</Tooltip>
<Tooltip title="Zoom out" placement="right">
<Button
className={styles.wdlAppearanceButton}
id="wdl-graph-zoom-out-button"
shape="circle"
onClick={this.zoomOut}
disabled={!canZoomOut}
>
<Icon type="minus-circle-o" />
</Button>
</Tooltip>
<Tooltip title="Zoom in" placement="right">
<Button
className={styles.wdlAppearanceButton}
id="wdl-graph-zoom-in-button"
shape="circle"
onClick={this.zoomIn}
disabled={!canZoomIn}
>
<Icon type="plus-circle-o" />
</Button>
</Tooltip>
{
this.renderGraphSearch()
}
<Tooltip title="Fullscreen" placement="right">
<Button
className={styles.wdlAppearanceButton}
id="wdl-graph-fuulscreen-button"
shape="circle"
onClick={this.toggleFullScreen}
>
<Icon type={fullScreen ? 'shrink' : 'arrows-alt'} />
</Button>
</Tooltip>
</div>
);
};
renderGraph = () => {
const {workflow, wdlScript} = this.props;
const {error} = this.state;
if (!wdlScript && ((workflow && workflow.pending && !workflow.loaded)
|| (workflow && workflow.metadata.value.submittedFiles.workflowUrl && !this.mainScript
&& this.workflowUrlScriptRequest && !this.workflowUrlScriptRequest.loaded
&& !this.workflowUrlScriptRequest.error))) {
return <Loading />;
}
if ((workflow && workflow.error)
|| (this.workflowUrlScriptRequest && this.workflowUrlScriptRequest.error)) {
let errorMessage;
let errorDescription;
if (workflow && workflow.error) {
errorMessage = workflow.error;
} else if (this.workflowUrlScriptRequest && this.workflowUrlScriptRequest.error) {
errorMessage = `An error occurred while loading the script from the '${this.workflowUrlScriptRequest}'`;
errorDescription = `${this.workflowUrlScriptRequest.error}`;
}
return <Alert type="warning" message={errorMessage} description={errorDescription} />;
}
if (error) {
const errorMessage = error.message || error;
const errorContent = (
<Row>
<Row><b>Error parsing wdl script:</b></Row>
<Row>{errorMessage}</Row>
</Row>
);
return (
<Alert
type="warning"
message={errorContent}
/>
);
}
return (
<div
className={styles.wdlGraph}
onDragEnter={e => e.preventDefault()}
onDragOver={e => e.preventDefault()}
>
{this.renderAppearancePanel()}
<div className={styles.wdlGraphContainer}>
<div ref={this.initializeContainer} />
</div>
</div>
);
};
render() {
const {fullScreen} = this.state;
const {className} = this.props;
let containerClassName = fullScreen ? styles.graphContainerFullScreen : styles.graphContainer;
if (className) {
containerClassName = className;
}
return (
<div className={containerClassName}>
{this.renderGraph()}
</div>
);
}
}