diff --git a/src/components/forms/EditPipelineEnvironmentsForm/EditPipelineEnvironmentsForm.js b/src/components/forms/EditPipelineEnvironmentsForm/EditPipelineEnvironmentsForm.js new file mode 100644 index 000000000..8a5d8d350 --- /dev/null +++ b/src/components/forms/EditPipelineEnvironmentsForm/EditPipelineEnvironmentsForm.js @@ -0,0 +1,70 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { reduxForm } from 'redux-form'; +import { FormattedMessage } from 'react-intl'; + +import EditEnvironmentList from '../EditEnvironmentSimpleForm/EditEnvironmentList'; +import { SaveIcon } from '../../icons'; +import Callout from '../../widgets/Callout'; +import FormBox from '../../widgets/FormBox'; +import SubmitButton from '../SubmitButton'; + +class EditPipelineEnvironmentsForm extends Component { + render() { + const { runtimeEnvironments, dirty, submitting, handleSubmit, submitFailed, submitSucceeded, invalid } = this.props; + return ( + + } + succeeded={submitSucceeded} + dirty={dirty} + footer={ +
+ } + messages={{ + submit: , + submitting: , + success: , + }} + /> +
+ }> + {submitFailed && ( + + + + )} + + +
+ ); + } +} + +EditPipelineEnvironmentsForm.propTypes = { + runtimeEnvironments: PropTypes.array.isRequired, + handleSubmit: PropTypes.func.isRequired, + dirty: PropTypes.bool, + submitting: PropTypes.bool, + submitFailed: PropTypes.bool, + submitSucceeded: PropTypes.bool, + invalid: PropTypes.bool, +}; + +export default reduxForm({ + form: 'editPipelineEnvironments', + enableReinitialize: true, + keepDirtyOnReinitialize: false, +})(EditPipelineEnvironmentsForm); diff --git a/src/components/forms/EditPipelineEnvironmentsForm/index.js b/src/components/forms/EditPipelineEnvironmentsForm/index.js new file mode 100644 index 000000000..3b5af4e6d --- /dev/null +++ b/src/components/forms/EditPipelineEnvironmentsForm/index.js @@ -0,0 +1,2 @@ +import EditPipelineEnvironmentsForm from './EditPipelineEnvironmentsForm'; +export default EditPipelineEnvironmentsForm; diff --git a/src/components/forms/EditPipelineForm/EditPipelineForm.js b/src/components/forms/EditPipelineForm/EditPipelineForm.js index 228af7fa3..99887d1a0 100644 --- a/src/components/forms/EditPipelineForm/EditPipelineForm.js +++ b/src/components/forms/EditPipelineForm/EditPipelineForm.js @@ -1,72 +1,46 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; -import { reduxForm, Field, touch, formValueSelector } from 'redux-form'; +import { reduxForm, Field } from 'redux-form'; import { FormattedMessage } from 'react-intl'; import { Container, Row, Col } from 'react-bootstrap'; -import { TextField, MarkdownTextAreaField, PipelineField, PipelineVariablesField } from '../Fields'; - +import { TextField, MarkdownTextAreaField, CheckboxField } from '../Fields'; +import { SaveIcon } from '../../icons'; import Callout from '../../widgets/Callout'; import FormBox from '../../widgets/FormBox'; import SubmitButton from '../SubmitButton'; -import { validatePipeline } from '../../../redux/modules/pipelines'; -import { extractVariables } from '../../../helpers/boxes'; -import { fetchSupplementaryFilesForPipeline } from '../../../redux/modules/pipelineFiles'; -import { createGetPipelineFiles } from '../../../redux/selectors/pipelineFiles'; class EditPipelineForm extends Component { - componentDidMount = () => this.props.loadAsync(); - - componentDidUpdate(prevProps) { - if (this.props.pipeline.id !== prevProps.pipeline.id) { - this.props.loadAsync(); - } - } - render() { const { - initialValues: pipeline, - anyTouched, + isSuperadmin = false, + dirty, submitting, handleSubmit, submitFailed, submitSucceeded, - variables = [], invalid, - asyncValidating, - supplementaryFiles, } = this.props; return ( - } + title={} succeeded={submitSucceeded} - dirty={anyTouched} + dirty={dirty} footer={
} messages={{ - submit: , - submitting: ( - - ), - success: , - validating: , + submit: , + submitting: , + success: , }} />
@@ -93,37 +67,110 @@ class EditPipelineForm extends Component { label={ } /> - - - } - /> - - - - +
+ + + + + } /> - } - supplementaryFiles={supplementaryFiles} - /> - - + +
+ +
+ + + + + } + /> + + + + } + /> + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + )}
); @@ -131,19 +178,15 @@ class EditPipelineForm extends Component { } EditPipelineForm.propTypes = { - initialValues: PropTypes.object.isRequired, + isSuperadmin: PropTypes.bool, values: PropTypes.object, handleSubmit: PropTypes.func.isRequired, - anyTouched: PropTypes.bool, + dirty: PropTypes.bool, submitting: PropTypes.bool, submitFailed: PropTypes.bool, submitSucceeded: PropTypes.bool, invalid: PropTypes.bool, - variables: PropTypes.array, asyncValidating: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), - supplementaryFiles: ImmutablePropTypes.map, - loadAsync: PropTypes.func.isRequired, - pipeline: PropTypes.object, }; const validate = ({ name, description }) => { @@ -170,42 +213,9 @@ const validate = ({ name, description }) => { return errors; }; -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)) - ); - -export default connect( - (state, { pipeline }) => ({ - variables: extractVariables(formValueSelector('editPipeline')(state, 'pipeline.boxes')), - supplementaryFiles: createGetPipelineFiles(pipeline.supplementaryFilesIds)(state), - }), - (dispatch, { pipeline }) => ({ - loadAsync: () => dispatch(fetchSupplementaryFilesForPipeline(pipeline.id)), - }) -)( - reduxForm({ - form: 'editPipeline', - validate, - asyncValidate, - })(EditPipelineForm) -); +export default reduxForm({ + form: 'editPipeline', + enableReinitialize: true, + keepDirtyOnReinitialize: false, + validate, +})(EditPipelineForm); diff --git a/src/locales/cs.json b/src/locales/cs.json index a7a95b0c6..677a36228 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -480,14 +480,18 @@ "app.editPipeline.disclaimer": "Upozornění", "app.editPipeline.disclaimerWarning": "Upravení pipeline může rozbít všechny úlohy, které pipeline používají!", "app.editPipeline.title": "Změnit nastavení a obsah pipeline", - "app.editPipelineFields.pipeline": "Pipeline:", - "app.editPipelineFields.pipelineVariables": "Pipeline proměnné:", - "app.editPipelineForm.description": "Popis pro vedoucího skupiny:", + "app.editPipelineEnvironmentsForm.title": "Běhová prostředí pipeline", + "app.editPipelineForm.description": "Podrobnější popis (pro autory úloh):", + "app.editPipelineForm.global": "Globalní pipeline spojená s konkrétními běhovými prostředími", + "app.editPipelineForm.hasEntryPoint": "Obsahuje vstupní bod", + "app.editPipelineForm.hasExtraFiles": "Obsahuje extra soubory", + "app.editPipelineForm.isCompilationPipeline": "Kompilační", + "app.editPipelineForm.isExecutionPipeline": "Spouštěcí", + "app.editPipelineForm.judgeOnlyPipeline": "Pouze sudí", "app.editPipelineForm.name": "Jméno pipeline:", - "app.editPipelineForm.submit": "Uložit změny", - "app.editPipelineForm.submitting": "Ukládání...", - "app.editPipelineForm.success": "Změny byly uloženy.", - "app.editPipelineForm.title": "Změnit pipeline {name}", + "app.editPipelineForm.producesFiles": "Produkuje výstupní soubory", + "app.editPipelineForm.producesStdout": "Produkuje std. výstup", + "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.", diff --git a/src/locales/en.json b/src/locales/en.json index 4ecd821d8..0323726c4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -480,14 +480,18 @@ "app.editPipeline.disclaimer": "Disclaimer", "app.editPipeline.disclaimerWarning": "Modifying the pipeline might break all exercises using the pipeline!", "app.editPipeline.title": "Change Pipeline Settings and Contents", - "app.editPipelineFields.pipeline": "The pipeline:", - "app.editPipelineFields.pipelineVariables": "Pipeline variables:", - "app.editPipelineForm.description": "Description for supervisors:", + "app.editPipelineEnvironmentsForm.title": "Pipeline Runtime Environments", + "app.editPipelineForm.description": "Detailed description (for exercise authors):", + "app.editPipelineForm.global": "Global pipeline associated with particular runtime environments", + "app.editPipelineForm.hasEntryPoint": "Has entry-point", + "app.editPipelineForm.hasExtraFiles": "Has extra files", + "app.editPipelineForm.isCompilationPipeline": "Compilation", + "app.editPipelineForm.isExecutionPipeline": "Execution", + "app.editPipelineForm.judgeOnlyPipeline": "Judge-Only", "app.editPipelineForm.name": "Pipeline name:", - "app.editPipelineForm.submit": "Save changes", - "app.editPipelineForm.submitting": "Saving changes...", - "app.editPipelineForm.success": "Settings were saved.", - "app.editPipelineForm.title": "Edit pipeline {name}", + "app.editPipelineForm.producesFiles": "Produces output files", + "app.editPipelineForm.producesStdout": "Produces std. out", + "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.", diff --git a/src/pages/EditPipeline/EditPipeline.js b/src/pages/EditPipeline/EditPipeline.js index 11fcb9ab9..224a9990e 100644 --- a/src/pages/EditPipeline/EditPipeline.js +++ b/src/pages/EditPipeline/EditPipeline.js @@ -4,23 +4,45 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import { Row, Col } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { reset, formValueSelector } from 'redux-form'; +import { reset } from 'redux-form'; +import { defaultMemoize } from 'reselect'; import Page from '../../components/layout/Page'; import Box from '../../components/widgets/Box'; import Callout from '../../components/widgets/Callout'; import EditPipelineForm from '../../components/forms/EditPipelineForm'; +import EditPipelineEnvironmentsForm from '../../components/forms/EditPipelineEnvironmentsForm'; import { EditIcon } from '../../components/icons'; import PipelineFilesTableContainer from '../../containers/PipelineFilesTableContainer'; import DeletePipelineButtonContainer from '../../containers/DeletePipelineButtonContainer'; +import ResourceRenderer from '../../components/helpers/ResourceRenderer'; -import { fetchPipelineIfNeeded, editPipeline } from '../../redux/modules/pipelines'; +import { fetchPipelineIfNeeded, editPipeline, setPipelineRuntimeEnvironments } from '../../redux/modules/pipelines'; +import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { getPipeline } from '../../redux/selectors/pipelines'; -import { loggedInUserIdSelector } from '../../redux/selectors/auth'; -import { getBoxTypes } from '../../redux/selectors/boxes'; +import { runtimeEnvironmentsSelector } from '../../redux/selectors/runtimeEnvironments'; +import { isLoggedAsSuperAdmin } from '../../redux/selectors/users'; import withLinks from '../../helpers/withLinks'; -import { transformPipelineDataForApi, extractVariables } from '../../helpers/boxes'; +import { arrayToObject } from '../../helpers/common'; + +// convert pipeline data into initial structure for pipeline edit metadata form +const perpareInitialPipelineData = ({ name, description, version, parameters, author }) => ({ + name, + description, + version, + parameters, + global: author === null, +}); + +// get selected runtimes and all runtimes and prepare object for environment selection form +const perpareInitialEnvironmentsData = defaultMemoize((selectedIds, runtimeEnvironments) => + arrayToObject( + runtimeEnvironments.map(rte => rte.id), + id => id, + id => selectedIds.includes(id) + ) +); class EditPipeline extends Component { componentDidMount = () => this.props.loadAsync(); @@ -32,16 +54,52 @@ class EditPipeline extends Component { } } - static loadAsync = ({ pipelineId }, dispatch) => Promise.all([dispatch(fetchPipelineIfNeeded(pipelineId))]); + static loadAsync = ({ pipelineId }, dispatch) => + Promise.all([dispatch(fetchPipelineIfNeeded(pipelineId)), dispatch(fetchRuntimeEnvironments())]); + + // save pipeline metadata (not the structure) + savePipeline = ({ + name, + description, + version, + parameters: { + isCompilationPipeline = false, + isExecutionPipeline = false, + judgeOnlyPipeline = false, + producesStdout = false, + producesFiles = false, + hasEntryPoint = false, + hasExtraFiles = false, + }, + global, + }) => { + const dataForApi = { name, description, version }; + if (this.props.isSuperadmin) { + dataForApi.parameters = { + isCompilationPipeline, + isExecutionPipeline, + judgeOnlyPipeline, + producesStdout, + producesFiles, + hasEntryPoint, + hasExtraFiles, + }; + dataForApi.global = global; + } + return this.props.editPipeline(dataForApi); + }; + + // save associations between pipeline and runtime environments + saveEnvironments = formData => + this.props.setPipelineRuntimeEnvironments(Object.keys(formData).filter(id => formData[id])); render() { const { links: { PIPELINES_URI }, history: { replace }, pipeline, - boxTypes, - editPipeline, - variables: extractedVariables, + runtimeEnvironments, + isSuperadmin, } = this.props; return ( @@ -66,31 +124,15 @@ class EditPipeline extends Component { + ({ - ...acc, - [btoa(variable.name)]: variable.value, - }), - {} - ), - }, - }} - onSubmit={({ pipeline, ...formData }) => - editPipeline(data.version, { - pipeline: transformPipelineDataForApi(boxTypes, pipeline, extractedVariables), - ...formData, - }) - } - pipeline={data} + initialValues={perpareInitialPipelineData(data)} + onSubmit={this.savePipeline} + isSuperadmin={isSuperadmin} /> @@ -99,11 +141,26 @@ class EditPipeline extends Component {
+ + + {isSuperadmin && ( + + {environments => ( + + )} + + )} + + { return { pipeline: getPipeline(pipelineId)(state), - boxTypes: getBoxTypes(state), - userId: loggedInUserIdSelector(state), - variables: extractVariables(formValueSelector('editPipeline')(state, 'pipeline.boxes')), + runtimeEnvironments: runtimeEnvironmentsSelector(state), + isSuperadmin: isLoggedAsSuperAdmin(state), }; }, ( @@ -176,7 +233,9 @@ export default withLinks( ) => ({ reset: () => dispatch(reset('editPipeline')), loadAsync: () => EditPipeline.loadAsync({ pipelineId }, dispatch), - editPipeline: (version, data) => dispatch(editPipeline(pipelineId, { ...data, version })), + editPipeline: data => dispatch(editPipeline(pipelineId, data)), + setPipelineRuntimeEnvironments: environments => + dispatch(setPipelineRuntimeEnvironments(pipelineId, environments)), }) )(EditPipeline) ); diff --git a/src/redux/helpers/resourceManager/actionTypesFactory.js b/src/redux/helpers/resourceManager/actionTypesFactory.js index 7b95b2ab8..7298099bb 100644 --- a/src/redux/helpers/resourceManager/actionTypesFactory.js +++ b/src/redux/helpers/resourceManager/actionTypesFactory.js @@ -2,14 +2,26 @@ * @module actionTypesFactory */ +import { arrayToObject } from '../../../helpers/common'; + const defaultPrefix = resourceName => `recodex/resource/${resourceName}`; const twoPhaseActions = ['ADD', 'UPDATE', 'REMOVE', 'FETCH', 'FETCH_MANY']; const onePhaseActions = ['INVALIDATE']; +export const defaultActionPostfixes = ['', '_PENDING', '_FULFILLED', '_REJECTED']; +export const createActionsWithPostfixes = (baseName, prefix, postfixes = defaultActionPostfixes) => + arrayToObject( + postfixes, + postfix => `${baseName}${postfix}`, + postfix => `${prefix}/${baseName}${postfix}` + ); + export const getActionTypes = (prefix, actions, postfixes = ['']) => actions.reduce( (acc, action) => ({ ...acc, + ...createActionsWithPostfixes(action, prefix, postfixes), + /* ...postfixes.reduce( (acc, postfix) => ({ ...acc, @@ -17,6 +29,7 @@ export const getActionTypes = (prefix, actions, postfixes = ['']) => }), {} ), + */ }), {} ); @@ -26,7 +39,7 @@ export const getActionTypes = (prefix, actions, postfixes = ['']) => * @param {string} prefix Unique prefix for the actions */ const actionTypesFactory = (resourceName, prefix = defaultPrefix(resourceName)) => ({ - ...getActionTypes(prefix, twoPhaseActions, ['', '_PENDING', '_FULFILLED', '_REJECTED']), + ...getActionTypes(prefix, twoPhaseActions, defaultActionPostfixes), ...getActionTypes(prefix, onePhaseActions), }); diff --git a/src/redux/helpers/resourceManager/index.js b/src/redux/helpers/resourceManager/index.js index f74a9000d..b91ffc771 100644 --- a/src/redux/helpers/resourceManager/index.js +++ b/src/redux/helpers/resourceManager/index.js @@ -1,7 +1,7 @@ import { createAction } from 'redux-actions'; import { createApiAction } from '../../middleware/apiMiddleware'; -import actionTypesFactory from './actionTypesFactory'; +import actionTypesFactory, { createActionsWithPostfixes } from './actionTypesFactory'; import actionCreatorsFactory from './actionCreatorsFactory'; import reducerFactory, { initialState } from './reducerFactory'; import createRecord, { getData, getJsData, getId } from './recordFactory'; @@ -43,6 +43,7 @@ export { getId, defaultNeedsRefetching, createRecord, + createActionsWithPostfixes, }; /** diff --git a/src/redux/modules/pipelines.js b/src/redux/modules/pipelines.js index 876c150a2..59c1ece15 100644 --- a/src/redux/modules/pipelines.js +++ b/src/redux/modules/pipelines.js @@ -1,6 +1,11 @@ import { handleActions } from 'redux-actions'; import { Map, List } from 'immutable'; -import factory, { initialState, createRecord, resourceStatus } from '../helpers/resourceManager'; +import factory, { + initialState, + createRecord, + resourceStatus, + createActionsWithPostfixes, +} from '../helpers/resourceManager'; import { createApiAction } from '../middleware/apiMiddleware'; import { actionTypes as pipelineFilesActionTypes } from './pipelineFiles'; @@ -13,10 +18,8 @@ const { actions, reduceActions } = factory({ resourceName }); export const additionalActionTypes = { VALIDATE_PIPELINE: 'recodex/pipelines/VALIDATE_PIPELINE', - FORK_PIPELINE: 'recodex/pipelines/FORK_PIPELINE', - FORK_PIPELINE_PENDING: 'recodex/pipelines/FORK_PIPELINE_PENDING', - FORK_PIPELINE_REJECTED: 'recodex/pipelines/FORK_PIPELINE_REJECTED', - FORK_PIPELINE_FULFILLED: 'recodex/pipelines/FORK_PIPELINE_FULFILLED', + ...createActionsWithPostfixes('FORK_PIPELINE', 'recodex/pipelines'), + ...createActionsWithPostfixes('SET_ENVIRONMENTS', 'recodex/pipelines'), }; export const fetchPipeline = actions.fetchResource; @@ -70,6 +73,14 @@ export const validatePipeline = (id, version) => body: { version }, }); +export const setPipelineRuntimeEnvironments = (id, environments) => + createApiAction({ + type: additionalActionTypes.SET_ENVIRONMENTS, + endpoint: `/pipelines/${id}/runtime-environments`, + method: 'POST', + body: { environments }, + }); + const reducer = handleActions( Object.assign({}, reduceActions, { [pipelineFilesActionTypes.ADD_FILES_FULFILLED]: (state, { payload: files, meta: { pipelineId } }) => @@ -95,6 +106,17 @@ const reducer = handleActions( pipelineId, }), + [additionalActionTypes.SET_ENVIRONMENTS_FULFILLED]: (state, { payload }) => + state.setIn( + ['resources', payload.id], + createRecord({ + data: payload, + state: resourceStatus.FULFILLED, + didInvalidate: false, + lastUpdate: Date.now(), + }) + ), + // Pagination result needs to store entity data here whilst indices are stored in pagination module [paginationActionTypes.FETCH_PAGINATED_FULFILLED]: (state, { payload: { items }, meta: { endpoint } }) => endpoint === 'pipelines'