From cf9eebf17f2c9bf8d98626e72061eeb3ec6ffa45 Mon Sep 17 00:00:00 2001 From: Martin Krulis Date: Thu, 2 Dec 2021 16:21:53 +0100 Subject: [PATCH] New interface for displaying and editing pipeline configuration. --- src/components/Pipelines/BoxForm/BoxForm.js | 471 ++++++++++------- src/components/Pipelines/BoxForm/index.js | 3 + .../Pipelines/BoxesTable/BoxesTable.js | 96 ++++ .../Pipelines/BoxesTable/BoxesTableRow.js | 308 +++++++++++ src/components/Pipelines/BoxesTable/index.js | 2 + .../Pipelines/VariableForm/VariableForm.js | 265 ++++++++++ .../Pipelines/VariableForm/index.js | 3 + .../VariablesTable/VariablesTable.js | 197 +++++++ .../Pipelines/VariablesTable/index.js | 2 + src/components/Pipelines/styles.less | 25 + .../EditSystemMessageForm.js | 46 +- .../forms/Fields/ExpandingTextField.js | 4 +- src/components/icons/index.js | 3 + src/containers/App/recodex.css | 8 + .../PipelineEditContainer.js | 479 ++++++++++++++++++ src/containers/PipelineEditContainer/index.js | 2 + .../SisSupervisorGroupsContainer.js | 15 +- src/helpers/boxes.js | 5 + src/helpers/dot.js | 5 + src/helpers/pipelines.js | 102 ++++ src/locales/cs.json | 60 ++- src/locales/en.json | 60 ++- src/locales/whitelist_cs.json | 1 + src/pages/EditPipeline/EditPipeline.js | 24 +- src/redux/modules/boxes.js | 2 +- src/redux/selectors/boxes.js | 20 +- 26 files changed, 1962 insertions(+), 246 deletions(-) create mode 100644 src/components/Pipelines/BoxForm/index.js create mode 100644 src/components/Pipelines/BoxesTable/BoxesTable.js create mode 100644 src/components/Pipelines/BoxesTable/BoxesTableRow.js create mode 100644 src/components/Pipelines/BoxesTable/index.js create mode 100644 src/components/Pipelines/VariableForm/VariableForm.js create mode 100644 src/components/Pipelines/VariableForm/index.js create mode 100644 src/components/Pipelines/VariablesTable/VariablesTable.js create mode 100644 src/components/Pipelines/VariablesTable/index.js create mode 100644 src/components/Pipelines/styles.less create mode 100644 src/containers/PipelineEditContainer/PipelineEditContainer.js create mode 100644 src/containers/PipelineEditContainer/index.js create mode 100644 src/helpers/pipelines.js diff --git a/src/components/Pipelines/BoxForm/BoxForm.js b/src/components/Pipelines/BoxForm/BoxForm.js index 3d982aa04..836439f01 100644 --- a/src/components/Pipelines/BoxForm/BoxForm.js +++ b/src/components/Pipelines/BoxForm/BoxForm.js @@ -1,135 +1,211 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; - +import { Modal, Table, Container, Row, Col } from 'react-bootstrap'; import { connect } from 'react-redux'; import { reduxForm, Field, formValueSelector } from 'redux-form'; +import { defaultMemoize } from 'reselect'; -import { TextField, SelectField, PortsField } from '../../forms/Fields'; -import { Modal } from 'react-bootstrap'; -import Button from '../../widgets/TheButton'; -import Callout from '../../widgets/Callout'; -import DeleteButton from '../../buttons/DeleteButton'; +import { TextField, SelectField } from '../../forms/Fields'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; import SubmitButton from '../../forms/SubmitButton'; -import { CloseIcon, SaveIcon } from '../../../components/icons'; +import { CloseIcon, SaveIcon, RefreshIcon, InputIcon, OutputIcon } from '../../../components/icons'; +import { encodeId, safeSet } from '../../../helpers/common'; -import { fetchBoxTypes } from '../../../redux/modules/boxes'; -import { getBoxTypes } from '../../../redux/selectors/boxes'; -import { getVariablesTypes } from '../../../helpers/boxes'; +export const newBoxInitialData = { name: '', type: '', portsIn: {}, portsOut: {} }; -class BoxForm extends Component { - componentDidMount = () => this.loadBoxTypes(); +const prepareBoxTypeOptions = defaultMemoize(boxTypes => + Object.values(boxTypes) + .map(({ name, type }) => ({ key: type, name: `${name} (${type})` })) + .sort((a, b) => a.name.localeCompare(b.name, 'en')) +); - loadBoxTypes() { - const { fetchBoxTypes } = this.props; - fetchBoxTypes(); - } +const getSortedPorts = ports => + ports && + Object.keys(ports) + .sort() + .map(name => ({ name, ...ports[name] })); +const preparePortsOfSelectedBoxType = defaultMemoize(boxType => { + const portsIn = (boxType && getSortedPorts(boxType.portsIn)) || []; + const portsOut = (boxType && getSortedPorts(boxType.portsOut)) || []; + return { portsIn, portsOut }; +}); + +class BoxForm extends Component { render() { const { show, + editting = null, boxTypes, + variables, selectedType, - title, handleSubmit, submitSucceeded = false, - submitFailed = false, - anyTouched = false, - asyncValidating = false, invalid = false, + dirty = false, submitting = false, reset, onHide, - onDelete, } = this.props; - const currentBoxType = boxTypes.find(box => box.type === selectedType); - const getPortsArray = ports => Object.keys(ports).map(port => ({ name: port, ...ports[port] })); + const { portsIn, portsOut } = preparePortsOfSelectedBoxType(selectedType && boxTypes[selectedType]); return ( - - {title} - - {submitFailed && ( - + + +
+ {editting ? ( {content} }} /> - - )} + ) : ( + + )} +
+
- - : - - } - /> + + + {variables.map(({ name }) => ( + + ))} + + + + + + : + + } + /> + - ({ key: type, name }))]} - required - label={} - /> + + } + /> + + - {currentBoxType && ( - } - /> - )} + {((portsIn && portsIn.length > 0) || (portsOut && portsOut.length > 0)) &&
} - {currentBoxType && ( - } - /> - )} + + {portsIn && portsIn.length > 0 && ( + 0 ? 6 : 12}> +
+ + +
+ + + {portsIn.map(port => ( + + + + + + ))} + +
+ {port.name} + + {port.type} + + +
+ + )} + + {portsOut && portsOut.length > 0 && ( + 0 ? 6 : 12}> +
+ + +
+ + + {portsOut.map(port => ( + + + + + + ))} + +
+ {port.name} + + {port.type} + + +
+ + )} +
+
+ -

- { - handleSubmit(); - return Promise.resolve(); - }} - submitting={submitting} - invalid={invalid} - dirty={anyTouched} - hasFailed={submitFailed} - hasSuceeded={submitSucceeded} - asyncValidating={asyncValidating} - reset={reset} - defaultIcon={} - messages={{ - success: , - submit: , - submitting: , - }} - /> - - - -

+
+ + {(dirty || editting) && ( + { + handleSubmit(); + return Promise.resolve(); + }} + submitting={submitting} + invalid={invalid} + dirty={dirty} + hasSuceeded={submitSucceeded} + reset={reset} + defaultIcon={} + messages={{ + success: , + submit: , + submitting: , + }} + /> + )} + {dirty && ( + + )} + + +
); @@ -138,120 +214,157 @@ class BoxForm extends Component { BoxForm.propTypes = { show: PropTypes.bool, - title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired, - selectedType: PropTypes.string, - boxTypes: PropTypes.array.isRequired, + editting: PropTypes.string, + boxTypes: PropTypes.object.isRequired, + boxes: PropTypes.array.isRequired, + variables: PropTypes.array.isRequired, + variablesUtilization: PropTypes.object.isRequired, onSubmit: PropTypes.func.isRequired, + onHide: PropTypes.func.isRequired, + selectedType: PropTypes.string, handleSubmit: PropTypes.func.isRequired, reset: PropTypes.func.isRequired, submitFailed: PropTypes.bool, submitSucceeded: PropTypes.bool, - anyTouched: PropTypes.bool, - asyncValidating: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), invalid: PropTypes.bool, - existingBoxes: PropTypes.array.isRequired, + dirty: PropTypes.bool, submitting: PropTypes.bool, - fetchBoxTypes: PropTypes.func.isRequired, - onHide: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, }; -const validate = ({ name, type, portsIn = {}, portsOut = {} }, { boxTypes, existingBoxes }) => { +const validate = ( + { name, type, portsIn = {}, portsOut = {} }, + { boxes, boxTypes, variables, variablesUtilization, editting, dirty } +) => { const errors = {}; + if (!dirty && !editting) { + return errors; + } - if (!name || name.length === 0) { - errors.name = ; + if (!name || name.trim() === '') { + errors.name = ; } - if (!type) { + if (name && name.trim() !== editting && boxes && boxes.find(v => v.name === name.trim())) { + errors.name = ( + + ); + } + + const boxType = type && boxTypes[type]; + if (!boxType) { errors.type = ( - + ); } else { - const boxType = boxTypes.find(box => box.type === type); - if (boxType) { - const portsInNames = Object.keys(boxType.portsIn); - const portsOutNames = Object.keys(boxType.portsOut); - const portsInErrors = {}; - - const existingVariablesTypes = getVariablesTypes( - boxTypes, - existingBoxes.filter(box => box.name !== name && box.type !== type) - ); + // Check that associated variables match the prescribed port types + const formDataPorts = { portsIn, portsOut }; + Object.keys(formDataPorts).forEach(ports => + Object.keys(boxType[ports]).forEach(portName => { + const variableName = (formDataPorts[ports][encodeId(portName)] || '').trim(); + const variable = variableName && variables.find(v => v.name === variableName); + if (variable && boxType[ports][portName].type !== variable.type) { + safeSet( + errors, + [ports, encodeId(portName)], + {content}, + }} + /> + ); + } + }) + ); - for (const portName of portsInNames) { - if (portsIn[portName] && portsIn[portName].length > 0) { - const intendedVariableName = portsIn[portName].value; - const portType = boxType.portsIn[portName].type; - const existingVariableType = existingVariablesTypes[intendedVariableName]; - if (existingVariableType && existingVariableType.type !== portType) { - portsInErrors[portName] = { - value: ( - {text}, - }} - /> - ), - }; - } + // Check that variables are not associated with too many output ports + const utilizations = {}; + Object.keys(boxType.portsOut) + .map(portName => (portsOut[encodeId(portName)] || '').trim()) + .filter(varName => varName && variablesUtilization[varName]) + .forEach(varName => { + if (!utilizations[varName]) { + utilizations[varName] = + variablesUtilization[varName].portsOut.length - // number of boxes, where the var is used in output + variablesUtilization[varName].portsOut.filter(box => box.name === editting).length; // -1 if this box is on the list } - } + ++utilizations[varName]; // increment utilization since this one variable will be present + }); - // check that the variable in a certain port has the correct port - if (Object.keys(portsInErrors).length > 0) { - errors.portsIn = portsInErrors; + Object.keys(boxType.portsOut).forEach(portName => { + const varName = portsOut[encodeId(portName)].trim(); + if (utilizations[varName] > 1) { + safeSet( + errors, + ['portsOut', encodeId(portName)], + + ); } + }); + } + + return errors; +}; - const portsOutErrors = {}; +const warn = ({ name, type, portsIn = {}, portsOut = {} }, { boxTypes, variables, editting, dirty }) => { + const warnings = {}; + if (!dirty && !editting) { + return warnings; + } - // check that one box does not have the same var as input and output - for (const portIn of portsInNames) { - for (const portOut of portsOutNames) { - if (portsIn[portIn] && portsOut[portOut] && portsIn[portIn].value === portsOut[portOut].value) { - portsOutErrors[portOut] = { - value: ( - - ), - }; - } - } - } + if (name && !name.trim().match(/^[-a-zA-Z0-9_]+$/)) { + warnings.name = ( + + ); + } - if (Object.keys(portsOutErrors).length > 0) { - errors.portsOut = portsOutErrors; - } - } + const boxType = type && boxTypes[type]; + if (boxType) { + // Check that associated variables match the prescribed port types + const formDataPorts = { portsIn, portsOut }; + Object.keys(formDataPorts).forEach(ports => + Object.keys(boxType[ports]).forEach(portName => { + const variableName = (formDataPorts[ports][encodeId(portName)] || '').trim(); + const variable = variableName && variables.find(v => v.name === variableName); + if (variableName && !variable) { + safeSet( + warnings, + [ports, encodeId(portName)], + + ); + } + }) + ); } - return errors; + return warnings; }; const mapStateToProps = state => ({ - boxTypes: getBoxTypes(state), - existingBoxes: formValueSelector('editPipeline')(state, 'pipeline.boxes'), selectedType: formValueSelector('boxForm')(state, 'type'), }); -const mapDispatchToProps = dispatch => ({ - fetchBoxTypes: () => dispatch(fetchBoxTypes()), -}); - -export default connect( - mapStateToProps, - mapDispatchToProps -)( +export default connect(mapStateToProps)( reduxForm({ form: 'boxForm', + enableReinitialize: true, + keepDirtyOnReinitialize: false, validate, + warn, })(BoxForm) ); diff --git a/src/components/Pipelines/BoxForm/index.js b/src/components/Pipelines/BoxForm/index.js new file mode 100644 index 000000000..a94bc3bf9 --- /dev/null +++ b/src/components/Pipelines/BoxForm/index.js @@ -0,0 +1,3 @@ +import BoxForm, { newBoxInitialData } from './BoxForm'; +export { newBoxInitialData }; +export default BoxForm; diff --git a/src/components/Pipelines/BoxesTable/BoxesTable.js b/src/components/Pipelines/BoxesTable/BoxesTable.js new file mode 100644 index 000000000..a4641bc84 --- /dev/null +++ b/src/components/Pipelines/BoxesTable/BoxesTable.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { Table } from 'react-bootstrap'; +import { defaultMemoize } from 'reselect'; + +import BoxesTableRow from './BoxesTableRow'; +import { arrayToObject } from '../../../helpers/common'; + +const prepareSelectionIndex = defaultMemoize( + selections => + selections && + arrayToObject( + selections, + x => x, + () => true + ) +); + +const BoxesTable = ({ + boxes, + variables = null, + secondarySelections = null, + selectedVariable = null, + removeBox = null, + intl: { locale }, + ...rowProps +}) => { + const selectionIndex = secondarySelections && prepareSelectionIndex(secondarySelections); + const variable = selectedVariable && variables && variables.find(v => v.name === selectedVariable); + return ( + 0 ? 'tbody-hover' : ''} size="sm"> + + + + + + + + + + + {boxes + .sort((a, b) => a.name.localeCompare(b.name, locale)) + .map(box => ( + + ))} + + {boxes.length === 0 && ( + + + + + + )} +
+ + + + + + + + + + + {removeBox && } +
+ + + +
+ ); +}; + +BoxesTable.propTypes = { + boxes: PropTypes.array.isRequired, + variables: PropTypes.array, + removeBox: PropTypes.func, + secondarySelections: PropTypes.array, + selectedVariable: PropTypes.string, + intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, +}; + +export default injectIntl(BoxesTable); diff --git a/src/components/Pipelines/BoxesTable/BoxesTableRow.js b/src/components/Pipelines/BoxesTable/BoxesTableRow.js new file mode 100644 index 000000000..eac0b04e5 --- /dev/null +++ b/src/components/Pipelines/BoxesTable/BoxesTableRow.js @@ -0,0 +1,308 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import classnames from 'classnames'; + +import { AddIcon, BindIcon, UnbindIcon, InputIcon, OutputIcon, RemoveIcon, WarningIcon } from '../../icons'; +import { getVariablesTypes } from '../../../helpers/pipelines'; +import styles from '../styles.less'; + +/** + * Prepare ports obect with all data required for rendering. + * @param {Object} box of which the ports are being prepared + * @param {string} direction portsIn or portsOut + * @param {Object} boxTypes all box type descriptors + * @returns {Array} + */ +const transformPorts = (box, direction, boxTypes) => { + const ports = box[direction]; + const portsDescriptors = boxTypes && boxTypes[box.type] && boxTypes[box.type][direction]; + + return Object.keys(ports) + .sort() + .map(name => ({ + name, + direction, + ...ports[name], + prescribedType: portsDescriptors && portsDescriptors[name] && portsDescriptors[name].type, + })); +}; + +const getMissingPorts = (box, direction, boxTypes) => { + const ports = box[direction]; + const portsDescriptors = (boxTypes && boxTypes[box.type] && boxTypes[box.type][direction]) || {}; + + return Object.keys(portsDescriptors) + .filter(name => !ports[name]) + .sort() + .map(name => ({ + name, + direction, + ...portsDescriptors[name], + isMissing: true, + })); +}; + +// Fragment of table row contaning the port values +const BoxesTablePortsFragment = ({ box, port, variables, selectedVariable, assignVariable }) => { + const tipId = `${box.name}-${port.direction}-${port.name}`; + const portTypeWrong = + port.value && variables && variables[port.value] && variables[port.value] !== port.prescribedType; + return ( + <> + + {port.direction === 'portsIn' ? ( + + ) : ( + + )} + + + + {port.name} + {!port.isMissing && !port.prescribedType && ( + + + + }> + + + )} + + + + {port.type} + {!port.isMissing && port.prescribedType && port.prescribedType !== port.type && ( + + {content}, + }} + /> + + }> + + + )} + + + + {port.isMissing ? ( + + + + {content}, + }} + /> + + + ) : ( + <> + + {port.value} + {portTypeWrong && ( + + {content}, + }} + /> + + }> + + + )} + + + {assignVariable && !selectedVariable && !port.value && !portTypeWrong && ( + + + + }> + { + ev.stopPropagation(); + assignVariable(box.name, port.direction, port.name); // create new variable + }} + /> + + )} + + )} + + + + {selectedVariable && + assignVariable && + !portTypeWrong && + selectedVariable.type === port.prescribedType && + selectedVariable.name !== port.value && ( + { + ev.stopPropagation(); + assignVariable(box.name, port.direction, port.name, selectedVariable.name); + }} + /> + )} + {selectedVariable && assignVariable && selectedVariable.name === port.value && ( + { + ev.stopPropagation(); + assignVariable(box.name, port.direction, port.name, null); // remove + }} + /> + )} + + + ); +}; + +BoxesTablePortsFragment.propTypes = { + box: PropTypes.object.isRequired, + port: PropTypes.object.isRequired, + variables: PropTypes.object, + selectedVariable: PropTypes.object, + assignVariable: PropTypes.func, +}; + +const BoxesTableRow = ({ + box, + boxTypes, + variables = null, + selectedVariable = null, + primarySelection = null, + secondarySelections = null, + selectBox = null, + editBox = null, + removeBox = null, + assignVariable = null, +}) => { + const [firstPort, ...ports] = [ + ...transformPorts(box, 'portsIn', boxTypes), + ...transformPorts(box, 'portsOut', boxTypes), + ...getMissingPorts(box, 'portsIn', boxTypes), + ...getMissingPorts(box, 'portsOut', boxTypes), + ]; + + return ( + selectBox(box.name) : null} + onDoubleClick={editBox ? () => editBox(box.name) : null} + className={classnames({ + clickable: editBox || selectBox, + [styles.primarySelection]: primarySelection === box.name, + [styles.secondarySelection]: secondarySelections && secondarySelections[box.name], + })}> + + {box.name} + + + {boxTypes[box.type] ? ( + boxTypes[box.type].name + ) : ( + + )} + + }> + + {box.type} + {!boxTypes[box.type] && } + + + + + + + {removeBox && ( + + { + ev.stopPropagation(); + removeBox(box.name); + }} + /> + + )} + + {ports.map(port => ( + + + + ))} + + ); +}; + +BoxesTableRow.propTypes = { + box: PropTypes.object.isRequired, + boxTypes: PropTypes.object.isRequired, + variables: PropTypes.array, + selectedVariable: PropTypes.object, + primarySelection: PropTypes.string, + secondarySelections: PropTypes.object, + selectBox: PropTypes.func, + editBox: PropTypes.func, + removeBox: PropTypes.func, + assignVariable: PropTypes.func, +}; + +export default BoxesTableRow; diff --git a/src/components/Pipelines/BoxesTable/index.js b/src/components/Pipelines/BoxesTable/index.js new file mode 100644 index 000000000..a3cbfe6d1 --- /dev/null +++ b/src/components/Pipelines/BoxesTable/index.js @@ -0,0 +1,2 @@ +import BoxesTable from './BoxesTable'; +export default BoxesTable; diff --git a/src/components/Pipelines/VariableForm/VariableForm.js b/src/components/Pipelines/VariableForm/VariableForm.js new file mode 100644 index 000000000..b13fc26c3 --- /dev/null +++ b/src/components/Pipelines/VariableForm/VariableForm.js @@ -0,0 +1,265 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import { connect } from 'react-redux'; +import { reduxForm, Field, FieldArray, formValueSelector } from 'redux-form'; + +import { TextField, SelectField, ExpandingTextField, CheckboxField } from '../../forms/Fields'; +import { Modal } from 'react-bootstrap'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import Callout from '../../widgets/Callout'; +import SubmitButton from '../../forms/SubmitButton'; +import { CloseIcon, SaveIcon, RefreshIcon } from '../../../components/icons'; +import { isArrayType } from '../../../helpers/pipelines'; + +export const newVariableInitialData = { + name: '', + type: '', + value: '', + values: [], + external: false, +}; + +const variableTypeOptions = ['file', 'remote-file', 'string'] + .reduce((acc, type) => [...acc, type, type + '[]'], []) + .sort() + .map(type => ({ key: type, name: type })); + +class VariableForm extends Component { + render() { + const { + show, + editting = null, + isExternal = false, + selectedType = null, + handleSubmit, + submitSucceeded = false, + dirty = false, + invalid = false, + submitting = false, + reset, + onHide, + } = this.props; + + // const currentBoxType = boxTypes.find(box => box.type === selectedType); + // const getPortsArray = ports => Object.keys(ports).map(port => ({ name: port, ...ports[port] })); + + return ( + + +
+ {editting ? ( + {content} }} + /> + ) : ( + + )} +
+
+ + + + : + + } + /> + + } + /> + + + } + /> + + {!selectedType && !isExternal ? ( + + + + ) : isArrayType(selectedType) && !isExternal ? ( + } + /> + ) : ( + + ) : ( + + ) + } + /> + )} + + + + + {(dirty || editting) && ( + { + handleSubmit(); + return Promise.resolve(); // make sure the submit button always gets a promise + }} + submitting={submitting} + invalid={invalid} + dirty={dirty} + hasSuceeded={submitSucceeded} + reset={reset} + defaultIcon={} + messages={{ + success: , + submit: , + submitting: , + }} + /> + )} + {dirty && ( + + )} + + + +
+ ); + } +} + +VariableForm.propTypes = { + variables: PropTypes.array.isRequired, + show: PropTypes.bool, + editting: PropTypes.string, + onSubmit: PropTypes.func.isRequired, + onHide: PropTypes.func.isRequired, + isExternal: PropTypes.bool, + selectedType: PropTypes.string, + handleSubmit: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + submitSucceeded: PropTypes.bool, + dirty: PropTypes.bool, + invalid: PropTypes.bool, + submitting: PropTypes.bool, +}; + +const validate = ({ name, type, external, value }, { variables, editting, dirty }) => { + const errors = {}; + if (!dirty && !editting) { + return errors; + } + + if (!name || name.trim() === '') { + errors.name = ( + + ); + } + + if (name && name.trim() !== editting && variables && variables.find(v => v.name === name.trim())) { + errors.name = ( + + ); + } + + if (!type) { + errors.type = ( + + ); + } + + if (external) { + if (!value || value.trim() === '') { + errors.value = ( + + ); + } + } + + return errors; +}; + +const warn = ({ name, external, value }) => { + const warnings = {}; + + if (name && !name.trim().match(/^[-a-zA-Z0-9_]+$/)) { + warnings.name = ( + + ); + } + + if (external) { + if (value && value.trim().startsWith('$')) { + warnings.value = ( + + ); + } + } + + return warnings; +}; + +const mapStateToProps = state => ({ + isExternal: formValueSelector('variableForm')(state, 'external'), + selectedType: formValueSelector('variableForm')(state, 'type'), +}); + +export default connect(mapStateToProps)( + reduxForm({ + form: 'variableForm', + enableReinitialize: true, + keepDirtyOnReinitialize: false, + validate, + warn, + })(VariableForm) +); diff --git a/src/components/Pipelines/VariableForm/index.js b/src/components/Pipelines/VariableForm/index.js new file mode 100644 index 000000000..3badeb91c --- /dev/null +++ b/src/components/Pipelines/VariableForm/index.js @@ -0,0 +1,3 @@ +import VariableForm, { newVariableInitialData } from './VariableForm'; +export { newVariableInitialData }; +export default VariableForm; diff --git a/src/components/Pipelines/VariablesTable/VariablesTable.js b/src/components/Pipelines/VariablesTable/VariablesTable.js new file mode 100644 index 000000000..abe895f70 --- /dev/null +++ b/src/components/Pipelines/VariablesTable/VariablesTable.js @@ -0,0 +1,197 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { Table, OverlayTrigger, Tooltip } from 'react-bootstrap'; +import { defaultMemoize } from 'reselect'; +import classnames from 'classnames'; + +import { InputIcon, OutputIcon, TransferIcon, RemoveIcon, WarningIcon } from '../../icons'; +import { isVariableValueValid } from '../../../helpers/pipelines'; +import { safeGet, arrayToObject } from '../../../helpers/common'; +import styles from '../styles.less'; + +const prepareSelectionIndex = defaultMemoize( + selections => + selections && + arrayToObject( + selections, + x => x, + () => true + ) +); + +const VariablesTable = ({ + variables, + utilization = null, + primarySelection = null, + secondarySelections = null, + selectVariable = null, + editVariable = null, + removeVariable = null, + intl: { locale }, +}) => { + const secondarySelectionsIndexed = secondarySelections && prepareSelectionIndex(secondarySelections); + + return ( + 0} size="sm"> + + + {utilization && + + + {removeVariable && + + + {variables + .sort((a, b) => a.name.localeCompare(b.name, locale)) + .map(variable => { + const portsIn = safeGet(utilization, [variable.name, 'portsIn', 'length'], 0); + const portsOut = safeGet(utilization, [variable.name, 'portsOut', 'length'], 0); + return ( + selectVariable(variable.name) : null} + onDoubleClick={editVariable ? () => editVariable(variable.name) : null} + className={classnames({ + clickable: selectVariable || editVariable, + [styles.primarySelection]: primarySelection === variable.name, + [styles.secondarySelection]: secondarySelectionsIndexed && secondarySelectionsIndexed[variable.name], + })}> + {utilization && ( + + )} + + + + + {removeVariable && ( + + )} + + ); + })} + + {variables.length === 0 && ( + + + + )} + +
} + + + + + + + } +
+ + {portsOut > 1 ? ( + + ) : portsIn && portsOut ? ( + + ) : portsIn ? ( + + ) : portsOut ? ( + + ) : ( + + )} + + }> + {portsOut > 1 ? ( + + ) : portsIn && portsOut ? ( + + ) : portsIn ? ( + + ) : portsOut ? ( + + ) : ( + + )} + + 1, + 'text-bold': secondarySelectionsIndexed && secondarySelectionsIndexed[variable.name], + })}> + {variable.name} + + {variable.type} + + {!isVariableValueValid(variable) && ( + + + + }> + + + )} + + {Array.isArray(variable.value) ? ( + variable.value.map((row, idx) => ( + + {row} + + )) + ) : ( + {variable.value} + )} + + { + ev.stopPropagation(); + removeVariable(variable.name); + }} + /> +
+ + + +
+ ); +}; + +VariablesTable.propTypes = { + variables: PropTypes.array.isRequired, + utilization: PropTypes.object, + primarySelection: PropTypes.string, + secondarySelections: PropTypes.array, + selectVariable: PropTypes.func, + editVariable: PropTypes.func, + removeVariable: PropTypes.func, + intl: PropTypes.shape({ locale: PropTypes.string.isRequired }).isRequired, +}; + +export default injectIntl(VariablesTable); diff --git a/src/components/Pipelines/VariablesTable/index.js b/src/components/Pipelines/VariablesTable/index.js new file mode 100644 index 000000000..ea1bc2ba9 --- /dev/null +++ b/src/components/Pipelines/VariablesTable/index.js @@ -0,0 +1,2 @@ +import VariablesTable from './VariablesTable'; +export default VariablesTable; diff --git a/src/components/Pipelines/styles.less b/src/components/Pipelines/styles.less new file mode 100644 index 000000000..8e43507f1 --- /dev/null +++ b/src/components/Pipelines/styles.less @@ -0,0 +1,25 @@ +.primarySelection td, .primarySelection th { + background-color: #ddf; +} + +.primarySelection:hover td, .primarySelection:hover th { + background-color: #ccf !important; +} + +.secondarySelection td, .secondarySelection th { + background-color: #ffd; +} + +.secondarySelection:hover td, .secondarySelection:hover th { + background-color: #ffa !important; +} + +.variableValue, .variableValueRow { + white-space: pre; + display: inline-block; + font-size: 80%; +} + +.variableValueRow { + display: block; +} diff --git a/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js b/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js index c9c3dd609..05dd328b9 100644 --- a/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js +++ b/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js @@ -105,31 +105,29 @@ const EditSystemMessageForm = ({ {error && dirty && {error}}
-
- - } - messages={{ - submit: , - submitting: , - success: , - validating: , - }} - /> + + } + messages={{ + submit: , + submitting: , + success: , + validating: , + }} + /> - - -
+ +
); diff --git a/src/components/forms/Fields/ExpandingTextField.js b/src/components/forms/Fields/ExpandingTextField.js index 91ed50e9a..9d95afe96 100644 --- a/src/components/forms/Fields/ExpandingTextField.js +++ b/src/components/forms/Fields/ExpandingTextField.js @@ -9,7 +9,7 @@ import TextField from './TextField'; import Icon, { AddIcon, CloseIcon } from '../../icons'; const ExpandingTextField = ({ - fields, + fields = [], meta: { active, dirty, error, warning }, label = null, noItems = null, @@ -109,7 +109,7 @@ const ExpandingTextField = ({ ); ExpandingTextField.propTypes = { - fields: PropTypes.object.isRequired, + fields: PropTypes.object, meta: PropTypes.shape({ active: PropTypes.bool, dirty: PropTypes.bool, diff --git a/src/components/icons/index.js b/src/components/icons/index.js index b4157562d..e3c489471 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -57,6 +57,7 @@ export const GroupIcon = ({ organizational = false, archived = false, ...props } ); export const HomeIcon = props => ; export const InfoIcon = props => ; +export const InputIcon = props => ; export const InstanceIcon = props => ; export const LimitsIcon = props => ; export const LinkIcon = props => ; @@ -67,6 +68,7 @@ export const MailIcon = props => ; export { NeedFixingIcon }; export const NoteIcon = props => ; export const ObserverIcon = props => ; +export const OutputIcon = props => ; export const PipelineIcon = props => ; export const PointsDecreasedIcon = props => ; export const PointsGraphIcon = props => ; @@ -120,6 +122,7 @@ export const TypedMessageIcon = ({ type, ...props }) => ( ); +export const UnbindIcon = props => ; export const UndoIcon = props => ; export const UnlockIcon = props => ; export const UploadIcon = props => ; diff --git a/src/containers/App/recodex.css b/src/containers/App/recodex.css index 766742500..f2f141b95 100644 --- a/src/containers/App/recodex.css +++ b/src/containers/App/recodex.css @@ -209,6 +209,10 @@ table.table-hover td.clickable:hover, table.table-hover th.clickable:hover { background-color: #eeeeee; } +table.table.tbody-hover tbody:hover td { + background-color: #eeeeee; +} + .timid { opacity: 0.2; transition: opacity 0.5s ease; @@ -299,6 +303,10 @@ code { max-width: 300px; } +.tooltip-inner code { + color: #bfb; +} + .btn.active { font-weight: bold; } diff --git a/src/containers/PipelineEditContainer/PipelineEditContainer.js b/src/containers/PipelineEditContainer/PipelineEditContainer.js new file mode 100644 index 000000000..de590e9ea --- /dev/null +++ b/src/containers/PipelineEditContainer/PipelineEditContainer.js @@ -0,0 +1,479 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import { Container, Row, Col } from 'react-bootstrap'; + +import Box from '../../components/widgets/Box'; +import BoxesTable from '../../components/Pipelines/BoxesTable'; +import VariablesTable from '../../components/Pipelines/VariablesTable'; +import VariableForm, { newVariableInitialData } from '../../components/Pipelines/VariableForm'; +import BoxForm, { newBoxInitialData } from '../../components/Pipelines/BoxForm'; +import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; +import Icon, { SaveIcon } from '../../components/icons'; + +import { fetchSupplementaryFilesForPipeline } from '../../redux/modules/pipelineFiles'; + +import { + getVariablesUtilization, + isArrayType, + coerceVariableValue, + isExternalReference, + getReferenceIdentifier, + makeExternalReference, + getVariablesTypes, +} from '../../helpers/pipelines'; +import { getBoxTypes } from '../../redux/selectors/boxes'; +import { objectMap, encodeId, deepCompare, identity } from '../../helpers/common'; + +// TODO + +/* +const asyncValidate = (values, dispatch, { initialValues: { id, version } }) => + new Promise((resolve, reject) => + dispatch(validatePipeline(id, version)) + .then(res => res.value) + .then(({ versionIsUpToDate }) => { + const errors = {}; + if (versionIsUpToDate === false) { + errors.name = ( + + ); + dispatch(touch('editPipeline', 'name')); + } + + if (Object.keys(errors).length > 0) { + throw errors; + } + }) + .then(resolve()) + .catch(errors => reject(errors)) + ); +*/ + +class PipelineEditContainer extends Component { + state = { + pipelineId: null, + version: null, + boxes: null, + variables: null, + boxFormOpen: false, + boxEditName: null, + variableFormOpen: false, + variableEditName: null, + selectedBox: null, + selectedBoxVariables: null, // vars associated with selected box + selectedVariable: null, + selectedVariableBoxes: null, // boxes associated with selected var + }; + + static getDerivedStateFromProps(nextProps, prevState) { + if (prevState.pipelineId !== nextProps.pipeline.id) { + return { + pipelineId: nextProps.pipeline.id, + version: nextProps.pipeline.version, + boxes: nextProps.pipeline.pipeline.boxes, + variables: nextProps.pipeline.pipeline.variables, + boxFormOpen: false, // whether dialog is visible + boxEditName: null, // if dialog is used for editting, name of the editted box + variableFormOpen: false, // analogical to boxForm... + variableEditName: null, + selectedBox: null, + selectedBoxVariables: null, + selectedVariable: null, + selectedVariableBoxes: null, + }; + } + + if (prevState.version < nextProps.pipeline.version) { + // TODO -- deal with mergin issues + return { version: nextProps.pipeline.version }; + } + + return null; + } + + /* + * Dialog handling + */ + + openBoxForm = (boxEditName = null) => { + this.setState({ boxFormOpen: true, boxEditName }); + }; + + openVariableForm = (variableEditName = null) => { + this.setState({ variableFormOpen: true, variableEditName }); + }; + + closeForms = () => { + this.setState({ boxFormOpen: false, boxEditName: null, variableFormOpen: false, variableEditName: null }); + }; + + getBoxFormInitialData = () => { + const box = this.state.boxEditName && this.state.boxes.find(box => box.name === this.state.boxEditName); + if (box) { + const data = { name: box.name, type: box.type, portsIn: {}, portsOut: {} }; + ['portsIn', 'portsOut'].forEach(ports => + Object.keys(box[ports]).forEach(port => (data[ports][encodeId(port)] = box[ports][port].value)) + ); + return data; + } else { + return newBoxInitialData; + } + }; + + getVariableFormInitialData = () => { + const variable = + this.state.variableEditName && + this.state.variables.find(variable => variable.name === this.state.variableEditName); + + if (variable) { + const data = { ...variable, external: false, values: [] }; + coerceVariableValue(data); + + if (isExternalReference(data.value)) { + data.external = true; + data.value = getReferenceIdentifier(data.value); + } else if (isArrayType(data.type)) { + data.values = data.value; + data.value = ''; + } + + return data; + } else { + return newVariableInitialData; + } + }; + + /* + * Selection handling + */ + + selectBox = (selectedBox = null) => { + if (selectedBox === this.state.selectedBox) { + selectedBox = null; // repeated select = unselect + } + + if (selectedBox) { + this.selectVariable(); // unselect variable, so the box holds primary selection + } + + const box = selectedBox && this.state.boxes.find(b => b.name === selectedBox); + const selectedBoxVariables = + box && [...Object.values(box.portsIn), ...Object.values(box.portsOut)].map(({ value }) => value).filter(identity); + + this.setState({ + selectedBox, + selectedBoxVariables, + }); + }; + + selectVariable = (selectedVariable = null) => { + if (selectedVariable === this.state.selectedVariable) { + selectedVariable = null; // repeated select = unselect + } + + if (selectedVariable) { + this.selectBox(); // unselect box, so the variable holds primary selection + } + + const selectedVariableBoxes = + selectedVariable && + this.state.boxes + .filter( + ({ portsIn, portsOut }) => + Object.values(portsIn).find(p => p.value === selectedVariable) || + Object.values(portsOut).find(p => p.value === selectedVariable) + ) + .map(({ name }) => name); + + this.setState({ + selectedVariable, + selectedVariableBoxes, + }); + }; + + /* + * Methods that modify the pipeline + */ + + transformState = (transformBoxes, transformVariables = null) => { + const stateUpdate = {}; + + const boxes = transformBoxes && transformBoxes(this.state.boxes, this.state.variables); + if (boxes && !deepCompare(boxes, this.state.boxes)) { + stateUpdate.boxes = boxes; + } + + const variables = transformVariables && transformVariables(this.state.variables, this.state.boxes); + if (variables && !deepCompare(variables, this.state.variables)) { + stateUpdate.variables = variables; + } + + if (Object.keys(stateUpdate).length > 0) { + this.setState(stateUpdate); + } + }; + + submitBoxForm = ({ name, type, portsIn, portsOut }) => { + const oldBoxName = this.state.boxEditName; + const boxType = this.props.boxTypes[type]; + this.closeForms(); + name = name.trim(); + if (!name || !boxType) { + return; + } + + // prepare new box object + const newBox = { + name, + type, + portsIn: objectMap(boxType.portsIn, (port, name) => ({ ...port, value: portsIn[encodeId(name)].trim() })), + portsOut: objectMap(boxType.portsOut, (port, name) => ({ ...port, value: portsOut[encodeId(name)].trim() })), + }; + + // extract all assigned variables, find which of them do not exist yet, and prepare their new objects + const newVariables = [...Object.values(newBox.portsIn), ...Object.values(newBox.portsOut)] + .filter(port => port.value && !this.state.variables.find(v => v.name === port.value)) + .map(port => ({ name: port.value, type: port.type, value: isArrayType(port.type) ? [] : '' })); + + if (oldBoxName) { + // replace old box with new one + this.transformState( + boxes => boxes.map(box => (box.name === oldBoxName ? newBox : box)), + newVariables.length > 0 ? variables => [...variables, ...newVariables] : null + ); + } else { + this.transformState( + boxes => [...boxes, newBox], // append new box + newVariables.length > 0 ? variables => [...variables, ...newVariables] : null + ); + } + }; + + submitVariableForm = ({ name, type, value, values, external }) => { + const oldVarName = this.state.variableEditName; + this.closeForms(); + name = name.trim(); + if (!name || !type) { + return; + } + + const newVariable = { name, type, value: isArrayType(type) ? values : value }; + if (external) { + newVariable.value = makeExternalReference(value); + } + + if (oldVarName) { + // update in ports: if the type still matches, update the variable name; remove it otherwise + const updateVarInPorts = ports => + objectMap(ports, port => + port.value === oldVarName ? { ...port, value: port.type === type ? name : '' } : port + ); + this.transformState( + boxes => + boxes.map(({ portsIn, portsOut, ...rest }) => { + return { + portsIn: updateVarInPorts(portsIn), + portsOut: updateVarInPorts(portsOut), + ...rest, + }; + }), + variables => variables.map(variable => (variable.name === oldVarName ? newVariable : variable)) + ); + } else { + this.transformState(null, variables => [...variables, newVariable]); + } + }; + + removeBox = name => { + this.transformState(boxes => boxes.filter(box => box.name !== name)); + }; + + removeVariable = name => { + const removeVarFromPorts = ports => objectMap(ports, port => (port.value === name ? { ...port, value: '' } : port)); + + this.transformState( + boxes => + boxes.map(({ portsIn, portsOut, ...rest }) => { + return { + portsIn: removeVarFromPorts(portsIn), + portsOut: removeVarFromPorts(portsOut), + ...rest, + }; + }), + variables => variables.filter(variable => variable.name !== name) + ); + }; + + getUnusedVariableName = prefix => { + const varIndex = getVariablesTypes(this.state.variables); + let suffix = ''; + while (varIndex[prefix + suffix]) { + ++suffix; + } + return prefix + suffix; + }; + + /** + * Change association of one variable in one port. + * @param {string} boxName + * @param {string} portsKey portsIn or portsOut + * @param {string} portName + * @param {string|null} variableName to be assigned, null = remove current variable, if missing => create new variable + */ + assignVariable = (boxName, portsKey, portName, variableName) => { + if (variableName === undefined) { + variableName = this.getUnusedVariableName(portName); + } + + const box = this.state.boxes.find(b => b.name === boxName); + const port = box && box[portsKey] && box[portsKey][portName]; + if (!port) { + return; + } + + const variable = variableName && this.state.variables.find(v => v.name === variableName); + if (variableName && !variable) { + // variable needs to be created first + this.transformState(null, variables => [ + ...variables, + { name: variableName, type: port.type, value: isArrayType(port.type) ? [] : '' }, + ]); + } else if (variable && variable.type !== port.type) { + return; + } + + this.transformState(boxes => + boxes.map( + b => + b.name === boxName + ? { + ...b, // clone the box and replace corresponding port + [portsKey]: { ...b[portsKey], [portName]: { ...b[portsKey][portName], value: variableName || '' } }, + } + : b // other boxes are just passed through + ) + ); + }; + + render() { + const { boxTypes } = this.props; + const utilization = getVariablesUtilization(this.state.boxes); + return ( + } + unlimitedHeight + footer={ +
+ + + + + +
+ }> + <> + + + +

+ +

+ {this.state.boxes && ( + + )} + + + +

+ +

+ {this.state.variables && ( + + )} + +
+
+ + + + + +
+ ); + } +} + +PipelineEditContainer.propTypes = { + pipeline: PropTypes.shape({ + id: PropTypes.string.isRequired, + version: PropTypes.number.isRequired, + pipeline: PropTypes.shape({ + boxes: PropTypes.array.isRequired, + variables: PropTypes.array.isRequired, + }), + }).isRequired, + supplementaryFiles: ImmutablePropTypes.map, + boxTypes: PropTypes.object.isRequired, +}; + +export default connect( + (state, { pipeline }) => { + return { + boxTypes: getBoxTypes(state), + }; + }, + (dispatch, { pipeline }) => ({ + loadFiles: () => dispatch(fetchSupplementaryFilesForPipeline(pipeline.id)), + }) +)(PipelineEditContainer); diff --git a/src/containers/PipelineEditContainer/index.js b/src/containers/PipelineEditContainer/index.js new file mode 100644 index 000000000..62f3167fa --- /dev/null +++ b/src/containers/PipelineEditContainer/index.js @@ -0,0 +1,2 @@ +import PipelineEditContainer from './PipelineEditContainer'; +export default PipelineEditContainer; diff --git a/src/containers/SisSupervisorGroupsContainer/SisSupervisorGroupsContainer.js b/src/containers/SisSupervisorGroupsContainer/SisSupervisorGroupsContainer.js index d39daa69e..5cc3735a7 100644 --- a/src/containers/SisSupervisorGroupsContainer/SisSupervisorGroupsContainer.js +++ b/src/containers/SisSupervisorGroupsContainer/SisSupervisorGroupsContainer.js @@ -16,7 +16,15 @@ import SisBindGroupForm from '../../components/forms/SisBindGroupForm'; import Confirm from '../../components/forms/Confirm'; import CourseLabel, { getLocalizedData } from '../../components/SisIntegration/CourseLabel'; import DeleteGroupButtonContainer from '../../containers/DeleteGroupButtonContainer'; -import Icon, { AddIcon, BindIcon, EditIcon, GroupIcon, AssignmentsIcon, LoadingIcon } from '../../components/icons'; +import Icon, { + AddIcon, + BindIcon, + UnbindIcon, + EditIcon, + GroupIcon, + AssignmentsIcon, + LoadingIcon, +} from '../../components/icons'; import { fetchAllGroups, fetchGroupIfNeeded } from '../../redux/modules/groups'; import { fetchSisStatusIfNeeded } from '../../redux/modules/sisStatus'; @@ -399,10 +407,7 @@ class SisSupervisorGroupsContainer extends Component { ) ? ( ) : ( - + )} type.indexOf('[]') > 0; export const getVariablesTypes = (boxTypes, boxes) => { diff --git a/src/helpers/dot.js b/src/helpers/dot.js index b7e1a047d..17d69ee4e 100644 --- a/src/helpers/dot.js +++ b/src/helpers/dot.js @@ -1,3 +1,8 @@ +/* + * DEPRECATED - all relevant stuff moved to pipelines.js + * TODO DELETE + */ + import Viz from 'viz.js'; const { Module, render } = require('viz.js/lite.render.js'); diff --git a/src/helpers/pipelines.js b/src/helpers/pipelines.js new file mode 100644 index 000000000..c0e5b4d6e --- /dev/null +++ b/src/helpers/pipelines.js @@ -0,0 +1,102 @@ +/* + * Functions related to pipeline editting and visualization. + */ + +import { defaultMemoize } from 'reselect'; +import { arrayToObject } from './common'; + +/** + * Check whether given data type is a list of values. + * @param {string} type descriptor + * @returns {boolean} true if the type represents a list + */ +export const isArrayType = type => type.endsWith('[]'); + +/** + * Check whether given variable value is a reference to external variable (starts with $). + * @param {string|Array} value + * @returns {boolean} + */ +export const isExternalReference = value => typeof value === 'string' && value.startsWith('$'); + +/** + * Get the identifier if an external reference + * @param {*} value + * @returns {string|null} null if the value is not a reference + */ +export const getReferenceIdentifier = value => (isExternalReference(value) ? value.substr(1) : null); + +/** + * Create a value that is an external reference to a variable of given name + * @param {string} name of the variable + * @returns {string} + */ +export const makeExternalReference = name => '$' + name; + +/** + * Verify that variable value is present and matches declared type. + * @param {Object} variable + * @returns {boolean} + */ +export const isVariableValueValid = variable => + 'value' in variable && + 'type' in variable && + (Array.isArray(variable.value) === isArrayType(variable.type) || isExternalReference(variable.value)); + +/** + * Make sure the variable value is of the right type, cast it if necessary. + * @param {*} variable + */ +export const coerceVariableValue = variable => { + if (!('value' in variable)) { + variable.value = isArrayType(variable.type) ? [] : ''; + } + + if (typeof variable.value === 'object' && !Array.isArray(variable.value)) { + variable.value = Object.values(variable.value); + } + + if (!isVariableValueValid(variable)) { + if (isArrayType(variable.type)) { + variable.value = variable.value ? [variable.value] : []; + } else { + variable.value = variable.value.join(' '); + } + } +}; + +/** + * Get info about variables utilization from boxes specification (where the variables are used) + * @param {Object[]} boxes part of the pipeline specification + * @return {Object} keys are variable names, values are objects holding { portsIn, portsOut } values, + * both are arrays of boxes where the variable is present + */ +export const getVariablesUtilization = defaultMemoize(boxes => { + const utils = {}; + boxes.forEach(box => + ['portsIn', 'portsOut'].forEach(ports => + Object.values(box[ports]) + .filter(({ value }) => value) + .forEach(({ value }) => { + if (!utils[value]) { + utils[value] = { portsIn: [], portsOut: [] }; + } + utils[value][ports].push(box); + }) + ) + ); + return utils; +}); + +/** + * Transform array of variables into oject (dictionary), that translates name to type. + * @param {Object[]} variables + * @return {Object} keys are names, values are types + */ +export const getVariablesTypes = defaultMemoize(variables => + arrayToObject( + variables, + ({ name }) => name, + ({ type }) => type + ) +); diff --git a/src/locales/cs.json b/src/locales/cs.json index 677a36228..fb604d55f 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -494,7 +494,6 @@ "app.editPipelineForm.title": "Metadata pipeline", "app.editPipelineForm.validation.description": "Prosíme vyplňte popis této pipeline.", "app.editPipelineForm.validation.emptyName": "Prosíme vyplňte název této pipeline.", - "app.editPipelineForm.validation.versionDiffers": "Někdo změnil nastavení této pipeline v průběhu její editace. Prosíme obnovte tuto stránku a proveďte své změny znovu.", "app.editShadowAssignment.deleteAssignment": "Smazat stínovou úlohu", "app.editShadowAssignment.deleteAssignmentWarning": "Smazání stínové úlohy rovněž odstraní body přidělené studentům.", "app.editShadowAssignment.title": "Změnit nastavení stínové úlohy", @@ -1113,15 +1112,12 @@ "app.pipeline.title": "Podrobnosti pipeline", "app.pipeline.version": "Verze:", "app.pipeline.visualization": "Vizualizace", + "app.pipelineEditContainer.addBoxButton": "Přidat krabičku", + "app.pipelineEditContainer.addVariableButton": "Přidat proměnnou", + "app.pipelineEditContainer.boxesTitle": "Krabičky", + "app.pipelineEditContainer.title": "Struktura pipeline", + "app.pipelineEditContainer.variablesTitle": "Proměnné", "app.pipelineEditor.AddBoxForm.title": "Přidat krabičku", - "app.pipelineEditor.BoxForm.conflictingPortType": "Tato proměnná nemůže být nastavena tomuto portu - typ portu je {portType}, zatímco proměnná {variable} je už přiřazena portu, který má typ {variableType} (např., v krabičce {exampleBox}).", - "app.pipelineEditor.BoxForm.emptyName": "Název nesmí být prázdný.", - "app.pipelineEditor.BoxForm.failed": "Omlouváme se, ale nebyli jsme schopni uložit krabičku.", - "app.pipelineEditor.BoxForm.loop": "Krabička nemůže použít svůj výstup jako svůj vstup.", - "app.pipelineEditor.BoxForm.missingType": "Musíte vybrat typ.", - "app.pipelineEditor.BoxForm.portsIn": "Vstupy:", - "app.pipelineEditor.BoxForm.portsOut": "Výstupy:", - "app.pipelineEditor.BoxForm.type": "Typ:", "app.pipelineEditor.EditBoxForm.title": "Upravit krabičku \"{name}\"", "app.pipelineFilesTable.description": "Testovací soubory jsou soubory, které mohou být odkazované v konfiguraci pipeline.", "app.pipelineFilesTable.title": "Soubory pipeline", @@ -1133,9 +1129,55 @@ "app.pipelineParams.producesFiles": "Testované řešení by mělo vypsat svoje výsledky do daného souboru", "app.pipelineParams.producesStdout": "Testované řešení by mělo vypsat svoje výsledky na standardní výstup", "app.pipelineVisualEditor.addBoxButton": "Přidat krabičku", + "app.pipelines.boxForm.duplicitName": "Toto jméno již bylo přiděleno pro jinou krabičku.", + "app.pipelines.boxForm.emptyName": "Jméno krabičky nemůže zůstat prázdné.", + "app.pipelines.boxForm.inputPorts": "Vstupní porty", + "app.pipelines.boxForm.missingType": "Musí být vybrán typ krabičky.", + "app.pipelines.boxForm.outputPorts": "Výstupní porty", + "app.pipelines.boxForm.titleEditting": "Editace krabičky {editting}", + "app.pipelines.boxForm.titleNew": "Přidat novou krabičku", + "app.pipelines.boxForm.type": "Typ:", + "app.pipelines.boxForm.variableNotExistYet": "Vybraná proměnná zatím neexistuje a bude vytvořena.", + "app.pipelines.boxForm.variableUsedInMultipleOutputs": "Tato proměnná je použita ve více než jednom výstupním portu.", + "app.pipelines.boxesTable.boxType": "Typ krabičky", + "app.pipelines.boxesTable.createNewVariable": "Vytvořit novou proměnnou, která bude asociována s tímto portem.", + "app.pipelines.boxesTable.name": "Název", + "app.pipelines.boxesTable.noBoxes": "V pipeline nejsou žádné krabičky.", + "app.pipelines.boxesTable.port": "Port", + "app.pipelines.boxesTable.portMissing": "chybí!", + "app.pipelines.boxesTable.portType": "Datový typ", + "app.pipelines.boxesTable.unknownPort": "Tento port není definován v deskriptoru krabičky.", + "app.pipelines.boxesTable.unknownType": "Neznámý typ krabičky!", + "app.pipelines.boxesTable.variable": "Proměnná", + "app.pipelines.boxesTable.wrongPortType": "Typ tohoto portu je {type}, ale deskriptor krabičky předepisuje typ {descType}.", + "app.pipelines.boxesTable.wrongVariableType": "Asociovaná proměnná je typu {type}, ale port předepisuje typ {descType}.", "app.pipelines.createNew": "Vytvořit novou pipeline", "app.pipelines.listTitle": "Pipeline", "app.pipelines.title": "Seznam všech pipeline", + "app.pipelines.variableForm.duplicitName": "Toto jméno již bylo přiděleno pro jinou proměnnou.", + "app.pipelines.variableForm.emptyName": "Jméno proměnné nemůže zůstat prázdné.", + "app.pipelines.variableForm.external": "Reference na externí proměnnou ($)", + "app.pipelines.variableForm.externalIdentifierDuplicitDollar": "Znak dolaru $ je na začátek reference přidán automaticky.", + "app.pipelines.variableForm.externalValue": "Externí jméno:", + "app.pipelines.variableForm.missingExternalIdentifier": "Jméno externí proměnné musí být nastaveno.", + "app.pipelines.variableForm.missingType": "Musí být vybrán typ.", + "app.pipelines.variableForm.selectTypeFirst": "Nejprve je třeba vybrat datový typ proměnné.", + "app.pipelines.variableForm.titleEditting": "Editace proměnné {editting}", + "app.pipelines.variableForm.titleNew": "Přidat novou proměnnou", + "app.pipelines.variableForm.type": "Datový typ:", + "app.pipelines.variableForm.value": "Hodnota:", + "app.pipelines.variableForm.values": "Hodnoty:", + "app.pipelines.variableForm.warningNameChars": "Je doporučeno používat pouze bezpečné znaky uvnitř identifikátorů (písmena, čísla, pomlčku a podtržítko).", + "app.pipelines.variablesTable.inputVariable": "Vstupní proměnná", + "app.pipelines.variablesTable.interconnectingVariable": "Propojovací proměnná", + "app.pipelines.variablesTable.name": "Proměnná", + "app.pipelines.variablesTable.noVariables": "V pipeline nejsou žádné proměnné.", + "app.pipelines.variablesTable.outputVariable": "Výstupní proměnná", + "app.pipelines.variablesTable.tooManyOutputsAttached": "Proměnná je asociována s více než jedním výstupním portem!", + "app.pipelines.variablesTable.type": "Datový typ", + "app.pipelines.variablesTable.unused": "Tato proměnná není použita v žádné krabičce", + "app.pipelines.variablesTable.value": "Hodnota", + "app.pipelines.variablesTable.wrongValueType": "Hodnota proměnné má jiný typ než je deklarováno.", "app.pipelinesList.authoredPipelineIconTooltip": "Autorská pipeline, kterou je možné použít ve specializovaných konfiguracích úloh.", "app.pipelinesList.compilationIconTooltip": "Kompilační pipeline", "app.pipelinesList.empty": "Nyní v tomto seznamu nejsou žádné pipeline.", diff --git a/src/locales/en.json b/src/locales/en.json index 0323726c4..59a5dc326 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -494,7 +494,6 @@ "app.editPipelineForm.title": "Pipeline Metadata", "app.editPipelineForm.validation.description": "Please fill the description of the pipeline.", "app.editPipelineForm.validation.emptyName": "Please fill the name of the pipeline.", - "app.editPipelineForm.validation.versionDiffers": "Somebody has changed the pipeline while you have been editing it. Please reload the page and apply your changes once more.", "app.editShadowAssignment.deleteAssignment": "Delete the shadow assignment", "app.editShadowAssignment.deleteAssignmentWarning": "Deleting shadow assignment will remove all student points as well.", "app.editShadowAssignment.title": "Change Shadow Assignment Settings", @@ -1113,15 +1112,12 @@ "app.pipeline.title": "Pipeline Detail", "app.pipeline.version": "Version:", "app.pipeline.visualization": "Visualization", + "app.pipelineEditContainer.addBoxButton": "Add Box", + "app.pipelineEditContainer.addVariableButton": "Add Variable", + "app.pipelineEditContainer.boxesTitle": "Boxes", + "app.pipelineEditContainer.title": "Edit Pipeline Structure", + "app.pipelineEditContainer.variablesTitle": "Variables", "app.pipelineEditor.AddBoxForm.title": "Add a box", - "app.pipelineEditor.BoxForm.conflictingPortType": "You cannot set this variable to the port - the type of this port is {portType}, but the variable {variable} is already associated with port of type {variableType} (e.g., in box {exampleBox}).", - "app.pipelineEditor.BoxForm.emptyName": "Name cannot be empty.", - "app.pipelineEditor.BoxForm.failed": "We are sorry but we weren't able to save the box.", - "app.pipelineEditor.BoxForm.loop": "Box can't use its own output as its input.", - "app.pipelineEditor.BoxForm.missingType": "You must select some type.", - "app.pipelineEditor.BoxForm.portsIn": "Inputs:", - "app.pipelineEditor.BoxForm.portsOut": "Outputs:", - "app.pipelineEditor.BoxForm.type": "Type:", "app.pipelineEditor.EditBoxForm.title": "Edit the box \"{name}\"", "app.pipelineFilesTable.description": "Supplementary files are files which can be referenced as remote file in pipeline configuration.", "app.pipelineFilesTable.title": "Pipeline files", @@ -1133,9 +1129,55 @@ "app.pipelineParams.producesFiles": "Tested solution is expected to yield results into a specific file", "app.pipelineParams.producesStdout": "Tested solution is expected to yield results to standard output", "app.pipelineVisualEditor.addBoxButton": "Add box", + "app.pipelines.boxForm.duplicitName": "This name is already taken by another box.", + "app.pipelines.boxForm.emptyName": "Box name cannot be empty.", + "app.pipelines.boxForm.inputPorts": "Input ports", + "app.pipelines.boxForm.missingType": "You must select type of the box.", + "app.pipelines.boxForm.outputPorts": "Output ports", + "app.pipelines.boxForm.titleEditting": "Editting Box {editting}", + "app.pipelines.boxForm.titleNew": "Add New Box", + "app.pipelines.boxForm.type": "Type:", + "app.pipelines.boxForm.variableNotExistYet": "Selected variable does not exist yet and will be created.", + "app.pipelines.boxForm.variableUsedInMultipleOutputs": "This variable is being used in multiple output ports.", + "app.pipelines.boxesTable.boxType": "Box Type", + "app.pipelines.boxesTable.createNewVariable": "Create new variable and associate it with this port.", + "app.pipelines.boxesTable.name": "Name", + "app.pipelines.boxesTable.noBoxes": "There are no boxes in the pipeline.", + "app.pipelines.boxesTable.port": "Port", + "app.pipelines.boxesTable.portMissing": "missing!", + "app.pipelines.boxesTable.portType": "Data Type", + "app.pipelines.boxesTable.unknownPort": "This port is not present in the box descriptor.", + "app.pipelines.boxesTable.unknownType": "Unknown box type!", + "app.pipelines.boxesTable.variable": "Variable", + "app.pipelines.boxesTable.wrongPortType": "The type of this port is {type}, but {descType} is prescribed by the descriptor.", + "app.pipelines.boxesTable.wrongVariableType": "Associated variable is of {type}, but {descType} type is required.", "app.pipelines.createNew": "Create New Pipeline", "app.pipelines.listTitle": "Pipelines", "app.pipelines.title": "List of All Pipelines", + "app.pipelines.variableForm.duplicitName": "This name is already taken by another variable.", + "app.pipelines.variableForm.emptyName": "Variable name cannot be empty.", + "app.pipelines.variableForm.external": "External variable reference ($)", + "app.pipelines.variableForm.externalIdentifierDuplicitDollar": "The dollar sign $ is added automatically to external references.", + "app.pipelines.variableForm.externalValue": "External name:", + "app.pipelines.variableForm.missingExternalIdentifier": "Name of the external variable must be set.", + "app.pipelines.variableForm.missingType": "The type must be selected.", + "app.pipelines.variableForm.selectTypeFirst": "You need to select the data type of the variable first.", + "app.pipelines.variableForm.titleEditting": "Editting Variable {editting}", + "app.pipelines.variableForm.titleNew": "Add New Variable", + "app.pipelines.variableForm.type": "Data type:", + "app.pipelines.variableForm.value": "Value:", + "app.pipelines.variableForm.values": "Values:", + "app.pipelines.variableForm.warningNameChars": "It is recommended to use safe chars only for identifiers (letters, numbers, dash, and underscore).", + "app.pipelines.variablesTable.inputVariable": "An input variable", + "app.pipelines.variablesTable.interconnectingVariable": "An interconnecting variable", + "app.pipelines.variablesTable.name": "Variable", + "app.pipelines.variablesTable.noVariables": "There are no variables in the pipeline.", + "app.pipelines.variablesTable.outputVariable": "An output variable", + "app.pipelines.variablesTable.tooManyOutputsAttached": "Variable is attached to more than one output port!", + "app.pipelines.variablesTable.type": "Data Type", + "app.pipelines.variablesTable.unused": "This variable is not used in any box", + "app.pipelines.variablesTable.value": "Value", + "app.pipelines.variablesTable.wrongValueType": "Variable value has a different type than declared.", "app.pipelinesList.authoredPipelineIconTooltip": "Authored pipeline which can be used in custom exercise configurations.", "app.pipelinesList.compilationIconTooltip": "Compilation pipeline", "app.pipelinesList.empty": "There are no pipelines in this list.", diff --git a/src/locales/whitelist_cs.json b/src/locales/whitelist_cs.json index af5a6bab9..e85b1b356 100644 --- a/src/locales/whitelist_cs.json +++ b/src/locales/whitelist_cs.json @@ -114,6 +114,7 @@ "app.instancesTable.admin", "app.passwordStrength.ok", "app.passwordStrength.unknown", + "app.pipelines.boxesTable.port", "app.randomMessages.error", "app.randomMessages.last", "app.randomMessages.m1", diff --git a/src/pages/EditPipeline/EditPipeline.js b/src/pages/EditPipeline/EditPipeline.js index 224a9990e..f83e0260b 100644 --- a/src/pages/EditPipeline/EditPipeline.js +++ b/src/pages/EditPipeline/EditPipeline.js @@ -15,9 +15,11 @@ import EditPipelineEnvironmentsForm from '../../components/forms/EditPipelineEnv import { EditIcon } from '../../components/icons'; import PipelineFilesTableContainer from '../../containers/PipelineFilesTableContainer'; import DeletePipelineButtonContainer from '../../containers/DeletePipelineButtonContainer'; +import PipelineEditContainer from '../../containers/PipelineEditContainer'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import { fetchPipelineIfNeeded, editPipeline, setPipelineRuntimeEnvironments } from '../../redux/modules/pipelines'; +import { fetchBoxTypes } from '../../redux/modules/boxes'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { getPipeline } from '../../redux/selectors/pipelines'; import { runtimeEnvironmentsSelector } from '../../redux/selectors/runtimeEnvironments'; @@ -55,7 +57,11 @@ class EditPipeline extends Component { } static loadAsync = ({ pipelineId }, dispatch) => - Promise.all([dispatch(fetchPipelineIfNeeded(pipelineId)), dispatch(fetchRuntimeEnvironments())]); + Promise.all([ + dispatch(fetchPipelineIfNeeded(pipelineId)), + dispatch(fetchRuntimeEnvironments()), + dispatch(fetchBoxTypes()), + ]); // save pipeline metadata (not the structure) savePipeline = ({ @@ -107,7 +113,7 @@ class EditPipeline extends Component { resource={pipeline} icon={} title={}> - {({ pipeline: { boxes, variables }, ...data }) => ( + {pipeline => (
@@ -130,7 +136,7 @@ class EditPipeline extends Component { @@ -144,14 +150,14 @@ class EditPipeline extends Component { - + {isSuperadmin && ( {environments => ( @@ -161,6 +167,12 @@ class EditPipeline extends Component { + + + + + +

- replace(PIPELINES_URI)} /> + replace(PIPELINES_URI)} />

diff --git a/src/redux/modules/boxes.js b/src/redux/modules/boxes.js index 337a3bcdd..9b5aa3391 100644 --- a/src/redux/modules/boxes.js +++ b/src/redux/modules/boxes.js @@ -8,7 +8,7 @@ import factory, { initialState } from '../helpers/resourceManager'; const resourceName = 'boxes'; const { actions, reduceActions } = factory({ resourceName, - idFieldName: 'name', + idFieldName: 'type', apiEndpointFactory: (name = '') => `/pipelines/boxes/${name}`, }); diff --git a/src/redux/selectors/boxes.js b/src/redux/selectors/boxes.js index 00781508b..31366c61f 100644 --- a/src/redux/selectors/boxes.js +++ b/src/redux/selectors/boxes.js @@ -1,18 +1,16 @@ import { createSelector } from 'reselect'; import { isReady } from '../helpers/resourceManager'; +import { objectMap } from '../../helpers/common'; const getResources = state => state.boxes.get('resources'); -export const getBoxTypesNames = createSelector( - getResources, - resources => Object.keys(resources.toJS()) +export const getBoxTypesNames = createSelector(getResources, resources => + resources + .toList() + .toJS() + .map(obj => obj.data.name) ); -export const getBoxTypes = createSelector( - getResources, - resources => - resources - .filter(isReady) - .toList() - .toJS() - .map(item => item.data) + +export const getBoxTypes = createSelector(getResources, resources => + objectMap(resources.filter(isReady).toJS(), obj => obj.data) );