diff --git a/src/components/Pipelines/PipelineExercisesList/PipelineExercisesList.js b/src/components/Pipelines/PipelineExercisesList/PipelineExercisesList.js new file mode 100644 index 000000000..7312e984f --- /dev/null +++ b/src/components/Pipelines/PipelineExercisesList/PipelineExercisesList.js @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Table } from 'react-bootstrap'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import { defaultMemoize } from 'reselect'; + +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import { DetailIcon, EditIcon, LimitsIcon, LoadingIcon, TestsIcon, WarningIcon } from '../../icons'; +import { resourceStatus } from '../../../redux/helpers/resourceManager'; +import { getLocalizedName } from '../../../helpers/localizedData'; +import UsersNameContainer from '../../../containers/UsersNameContainer'; +import withLinks from '../../../helpers/withLinks'; + +const COLLAPSE_LIMIT = 50; +const PREVIEW_SIZE = 10; + +const nameComparator = locale => (a, b) => + getLocalizedName(a, locale).localeCompare(getLocalizedName(b, locale), locale); + +const preprocessExercises = defaultMemoize((exercises, locale, offset = 0, limit = 1000) => + exercises + .toJS() + .sort(nameComparator(locale)) + .slice(offset >= exercises.size ? offset : 0, offset + Math.max(limit, 10)) +); + +const PipelineExercisesList = ({ + pipelineExercises = null, + intl: { locale }, + links: { + EXERCISE_URI_FACTORY, + EXERCISE_EDIT_URI_FACTORY, + EXERCISE_EDIT_CONFIG_URI_FACTORY, + EXERCISE_EDIT_LIMITS_URI_FACTORY, + }, +}) => { + const [fullView, setFullView] = useState(false); + + if (!pipelineExercises || pipelineExercises === resourceStatus.PENDING) { + return ( +
+ + +
+ ); + } + + if (pipelineExercises === resourceStatus.REJECTED) { + return ( +
+ + +
+ ); + } + + const exercises = preprocessExercises( + pipelineExercises, + locale, + 0, + fullView || pipelineExercises.size < COLLAPSE_LIMIT ? 1000000 : PREVIEW_SIZE + ); + return ( + + + + + + + + + + + {exercises.map(exercise => ( + + + + + + ))} + + {exercises.length === 0 && ( + + + + )} + + + {pipelineExercises.size >= COLLAPSE_LIMIT && ( + + + + + + )} +
+ + + + + {pipelineExercises.size > 5 && ( + + + + )} +
{getLocalizedName(exercise, locale)} + COLLAPSE_LIMIT} + noAutoload + /> + + {exercise.canViewDetail && ( + + + + + + + + + + + + + + + + + + )} +
+ +
+ {fullView ? ( + { + setFullView(false); + ev.preventDefault(); + }}> + + + ) : ( + { + setFullView(true); + ev.preventDefault(); + }}> + + + )} +
+ ); +}; + +PipelineExercisesList.propTypes = { + pipelineExercises: PropTypes.any, + intl: PropTypes.object.isRequired, + links: PropTypes.object, +}; + +export default withLinks(injectIntl(PipelineExercisesList)); diff --git a/src/components/Pipelines/PipelineExercisesList/index.js b/src/components/Pipelines/PipelineExercisesList/index.js new file mode 100644 index 000000000..2da7aa688 --- /dev/null +++ b/src/components/Pipelines/PipelineExercisesList/index.js @@ -0,0 +1,2 @@ +import PipelineExercisesList from './PipelineExercisesList'; +export default PipelineExercisesList; diff --git a/src/containers/UsersNameContainer/UsersNameContainer.js b/src/containers/UsersNameContainer/UsersNameContainer.js index d249b29d7..dd4e73755 100644 --- a/src/containers/UsersNameContainer/UsersNameContainer.js +++ b/src/containers/UsersNameContainer/UsersNameContainer.js @@ -12,10 +12,14 @@ import { LoadingIcon, FailureIcon } from '../../components/icons'; import './UsersNameContainer.css'; class UsersNameContainer extends Component { - componentDidMount = () => this.props.loadUserIfNeeded(this.props.userId); + componentDidMount = () => { + if (!this.props.noAutoload) { + this.props.loadUserIfNeeded(this.props.userId); + } + }; componentDidUpdate(prevProps) { - if (this.props.userId !== prevProps.userId) { + if (this.props.userId !== prevProps.userId && !this.props.noAutoload) { this.props.loadUserIfNeeded(this.props.userId); } } @@ -73,6 +77,7 @@ UsersNameContainer.propTypes = { showEmail: PropTypes.string, showExternalIdentifiers: PropTypes.bool, showRoleIcon: PropTypes.bool, + noAutoload: PropTypes.bool, loadUserIfNeeded: PropTypes.func.isRequired, }; diff --git a/src/locales/cs.json b/src/locales/cs.json index 1769f5137..07bde14a9 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1101,6 +1101,7 @@ "app.passwordStrength.somewhatOk": "Šlo by to i lépe.", "app.passwordStrength.unknown": "...", "app.passwordStrength.worst": "Nevyhovující", + "app.pipeline.associatedExercises": "Přidružené úlohy", "app.pipeline.description": "Popis", "app.pipeline.failedDetail": "Načítání detailů pipeline selhalo. Ujistěte se prosím, že jste připojeni k Internetu a zkuste to později.", "app.pipeline.forkPipeline": "Duplikovat pipeline", @@ -1135,6 +1136,10 @@ "app.pipelineEditContainer.variablesTitle": "Proměnné", "app.pipelineEditContainer.versionChanged": "Původní stuktura pipeline byla změněna v průběhu provádění vašich změn. Pokud necháte znovu načíst pipeline, zůstane současná verze v historii (takže můžete použít tlačítko zpět a vrátit se k vámi rozpracované verzi).", "app.pipelineEditContainer.versionChangedTitle": "Pipeline byla změněna", + "app.pipelineExercisessList.collapse": "Skrýt úplný seznam a zobrazit pouze náhled.", + "app.pipelineExercisessList.empty": "Žádné úlohy nepoužívají tuto pipeline v tomto okamžiku.", + "app.pipelineExercisessList.expand": "Zobrazit kompletní seznam {count} úloh.", + "app.pipelineExercisessList.totalCount": "Celkem úloh: {count}", "app.pipelineFilesTable.description": "Testovací soubory jsou soubory, které mohou být odkazované v konfiguraci pipeline.", "app.pipelineFilesTable.title": "Soubory pipeline", "app.pipelineParams.hasEntryPoint": "Testované řešení by mělo specifikovat vstupní bod aplikace", diff --git a/src/locales/en.json b/src/locales/en.json index 332ba3a37..254690fad 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1101,6 +1101,7 @@ "app.passwordStrength.somewhatOk": "You can do better.", "app.passwordStrength.unknown": "...", "app.passwordStrength.worst": "Unsatisfactory", + "app.pipeline.associatedExercises": "Associated exercises", "app.pipeline.description": "Description", "app.pipeline.failedDetail": "Loading the details of the pipeline failed. Please make sure you are connected to the Internet and try again later.", "app.pipeline.forkPipeline": "Duplicate Pipeline", @@ -1135,6 +1136,10 @@ "app.pipelineEditContainer.variablesTitle": "Variables", "app.pipelineEditContainer.versionChanged": "The pipeline structure was updated whilst you were editing it. If you load the new pipeline, it will be pushed as a new state in editor (you can use undo button to revert it).", "app.pipelineEditContainer.versionChangedTitle": "The pipeline was updated", + "app.pipelineExercisessList.collapse": "Collapse the list and show only short preview.", + "app.pipelineExercisessList.empty": "There are no exercises using this pipelines at the moment.", + "app.pipelineExercisessList.expand": "Expand the list and show all {count} exercises.", + "app.pipelineExercisessList.totalCount": "Total exercises: {count}", "app.pipelineFilesTable.description": "Supplementary files are files which can be referenced as remote file in pipeline configuration.", "app.pipelineFilesTable.title": "Pipeline files", "app.pipelineParams.hasEntryPoint": "Tested solution is expected to specify entry point of the application", diff --git a/src/pages/Pipeline/Pipeline.js b/src/pages/Pipeline/Pipeline.js index d14b5cb8c..473a839a5 100644 --- a/src/pages/Pipeline/Pipeline.js +++ b/src/pages/Pipeline/Pipeline.js @@ -15,15 +15,17 @@ import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; import Confirm from '../../components/forms/Confirm'; import Callout from '../../components/widgets/Callout'; -import { fetchPipelineIfNeeded, forkPipeline } from '../../redux/modules/pipelines'; -import { getPipeline, pipelineEnvironmentsSelector } from '../../redux/selectors/pipelines'; +import { fetchPipelineIfNeeded, fetchPipelineExercises, forkPipeline } from '../../redux/modules/pipelines'; +import { fetchByIds } from '../../redux/modules/users'; +import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; +import { getPipeline, getPipelineExercises, pipelineEnvironmentsSelector } from '../../redux/selectors/pipelines'; import { getVariablesUtilization } from '../../helpers/pipelines'; import PipelineDetail from '../../components/Pipelines/PipelineDetail'; import PipelineGraph from '../../components/Pipelines/PipelineGraph'; +import PipelineExercisesList from '../../components/Pipelines/PipelineExercisesList'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; -import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; -import { hasPermissions } from '../../helpers/common'; +import { hasPermissions, identity } from '../../helpers/common'; import withLinks from '../../helpers/withLinks'; class Pipeline extends Component { @@ -32,7 +34,15 @@ class Pipeline extends Component { }; static loadAsync = ({ pipelineId }, dispatch) => - Promise.all([dispatch(fetchPipelineIfNeeded(pipelineId)), dispatch(fetchRuntimeEnvironments())]); + Promise.all([ + dispatch(fetchPipelineIfNeeded(pipelineId)) + .then(() => dispatch(fetchPipelineExercises(pipelineId))) + .then(({ value: exercises }) => { + const ids = new Set(exercises.map(exercise => exercise && exercise.authorId).filter(identity)); + return dispatch(fetchByIds(Array.from(ids))); + }), + dispatch(fetchRuntimeEnvironments()), + ]); componentDidMount() { this.props.loadAsync(); @@ -81,6 +91,7 @@ class Pipeline extends Component { params: { pipelineId }, }, pipeline, + pipelineExercises, runtimeEnvironments, } = this.props; @@ -149,6 +160,7 @@ class Pipeline extends Component { )} + } @@ -160,6 +172,17 @@ class Pipeline extends Component { /> + + + + } + unlimitedHeight + noPadding> + + + )} @@ -179,6 +202,7 @@ Pipeline.propTypes = { replace: PropTypes.func.isRequired, }), pipeline: ImmutablePropTypes.map, + pipelineExercises: PropTypes.any, runtimeEnvironments: PropTypes.array, loadAsync: PropTypes.func.isRequired, forkPipeline: PropTypes.func.isRequired, @@ -197,6 +221,7 @@ export default withLinks( ) => { return { pipeline: getPipeline(pipelineId)(state), + pipelineExercises: getPipelineExercises(state, pipelineId), runtimeEnvironments: pipelineEnvironmentsSelector(pipelineId)(state), }; }, diff --git a/src/redux/modules/groups.js b/src/redux/modules/groups.js index 144bccea5..1bb9e5aa3 100644 --- a/src/redux/modules/groups.js +++ b/src/redux/modules/groups.js @@ -26,10 +26,6 @@ const { actions, actionTypes, reduceActions } = factory({ resourceName }); export { actionTypes }; export const additionalActionTypes = { - LOAD_USERS_GROUPS: 'recodex/groups/LOAD_USERS_GROUPS', - LOAD_USERS_GROUPS_PENDING: 'recodex/groups/LOAD_USERS_GROUPS_PENDING', - LOAD_USERS_GROUPS_FULFILLED: 'recodex/groups/LOAD_USERS_GROUPS_FULFILLED', - LOAD_USERS_GROUPS_REJECTED: 'recodex/groups/LOAD_USERS_GROUPS_REJECTED', JOIN_GROUP: 'recodex/groups/JOIN_GROUP', JOIN_GROUP_PENDING: 'recodex/groups/JOIN_GROUP_PENDING', JOIN_GROUP_FULFILLED: 'recodex/groups/JOIN_GROUP_FULFILLED', @@ -291,14 +287,6 @@ const reducer = handleActions( state ), - [additionalActionTypes.LOAD_USERS_GROUPS_FULFILLED]: (state, { payload, ...rest }) => { - const groups = [...payload.supervisor, ...payload.student]; - return reduceActions[actionTypes.FETCH_MANY_FULFILLED](state, { - ...rest, - payload: groups, - }); - }, - [assignmentsActionTypes.UPDATE_FULFILLED]: (state, { payload: { id: assignmentId, groupId } }) => state.updateIn(['resources', groupId, 'data', 'privateData', 'assignments'], assignments => assignments.push(assignmentId).toSet().toList() diff --git a/src/redux/modules/pipelines.js b/src/redux/modules/pipelines.js index 95c3170f9..3d8cd8d9e 100644 --- a/src/redux/modules/pipelines.js +++ b/src/redux/modules/pipelines.js @@ -1,5 +1,5 @@ import { handleActions } from 'redux-actions'; -import { Map, List } from 'immutable'; +import { List, fromJS } from 'immutable'; import factory, { initialState, createRecord, @@ -20,6 +20,7 @@ export const additionalActionTypes = { VALIDATE_PIPELINE: 'recodex/pipelines/VALIDATE_PIPELINE', ...createActionsWithPostfixes('FORK_PIPELINE', 'recodex/pipelines'), ...createActionsWithPostfixes('SET_ENVIRONMENTS', 'recodex/pipelines'), + ...createActionsWithPostfixes('FETCH_PIPELINE_EXERCISES', 'recodex/pipelines'), }; export const fetchPipeline = actions.fetchResource; @@ -36,13 +37,6 @@ export const fetchPipelines = () => true // force reload ); -/* TODO - awaiting modification (many-to-many relation with exercises) -export const fetchExercisePipelines = exerciseId => - actions.fetchMany({ - endpoint: `/exercises/${exerciseId}/pipelines` - }); -*/ - export const forkStatuses = { PENDING: 'PENDING', REJECTED: 'REJECTED', @@ -82,13 +76,22 @@ export const setPipelineRuntimeEnvironments = (id, environments) => body: { environments }, }); +export const fetchPipelineExercises = id => + createApiAction({ + type: additionalActionTypes.FETCH_PIPELINE_EXERCISES, + method: 'GET', + endpoint: `/pipelines/${id}/exercises`, + meta: { id }, + }); + const reducer = handleActions( Object.assign({}, reduceActions, { [pipelineFilesActionTypes.ADD_FILES_FULFILLED]: (state, { payload: files, meta: { pipelineId } }) => state.hasIn(['resources', pipelineId]) ? updateFiles(state, pipelineId, files, 'supplementaryFilesIds') : state, - [additionalActionTypes.FORK_PIPELINE_PENDING]: (state, { meta: { id, forkId } }) => - state.updateIn(['resources', id, 'data'], pipeline => { + /* + [additionalActionTypes.FORK_PIPELINE_PENDING]: (state, { meta: { id, forkId } }) => + state.updateIn(['resources', id], pipeline => { if (!pipeline.has('forks')) { pipeline = pipeline.set('forks', new Map()); } @@ -97,16 +100,16 @@ const reducer = handleActions( }), [additionalActionTypes.FORK_PIPELINE_REJECTED]: (state, { meta: { id, forkId } }) => - state.setIn(['resources', id, 'data', 'forks', forkId], { + state.setIn(['resources', id, 'forks', forkId], { status: forkStatuses.REJECTED, }), [additionalActionTypes.FORK_PIPELINE_FULFILLED]: (state, { payload: { id: pipelineId }, meta: { id, forkId } }) => - state.setIn(['resources', id, 'data', 'forks', forkId], { + state.setIn(['resources', id, 'forks', forkId], { status: forkStatuses.FULFILLED, pipelineId, }), - +*/ [additionalActionTypes.SET_ENVIRONMENTS_FULFILLED]: (state, { payload }) => state.setIn( ['resources', payload.id], @@ -136,6 +139,15 @@ const reducer = handleActions( ) ) : state, + + [additionalActionTypes.FETCH_PIPELINE_EXERCISES_PENDING]: (state, { meta: { id } }) => + state.setIn(['resources', id, 'exercises'], resourceStatus.PENDING), + + [additionalActionTypes.FETCH_PIPELINE_EXERCISES_FULFILLED]: (state, { payload, meta: { id } }) => + state.setIn(['resources', id, 'exercises'], fromJS(payload)), + + [additionalActionTypes.FETCH_PIPELINE_EXERCISES_REJECTED]: (state, { meta: { id } }) => + state.setIn(['resources', id, 'exercises'], resourceStatus.REJECTED), }), initialState ); diff --git a/src/redux/selectors/groups.js b/src/redux/selectors/groups.js index cfc37fd00..ba3b3bcab 100644 --- a/src/redux/selectors/groups.js +++ b/src/redux/selectors/groups.js @@ -12,7 +12,7 @@ import { getLocalizedResourceName } from '../../helpers/localizedData'; /** * Select groups part of the state */ -const getParam = (state, id) => id; +const getParam = (_, id) => id; const getGroups = state => state.groups; export const groupsSelector = state => state.groups.get('resources'); diff --git a/src/redux/selectors/pipelines.js b/src/redux/selectors/pipelines.js index 022d19ae8..d7a13a9e8 100644 --- a/src/redux/selectors/pipelines.js +++ b/src/redux/selectors/pipelines.js @@ -5,42 +5,16 @@ import { runtimeEnvironmentSelector } from './runtimeEnvironments'; const getPipelines = state => state.pipelines; const getResources = pipelines => pipelines.get('resources'); +const getParam = (_, id) => id; -export const fetchManyStatus = createSelector( - getPipelines, - state => state.getIn(['fetchManyStatus', fetchManyEndpoint]) +export const fetchManyStatus = createSelector(getPipelines, state => + state.getIn(['fetchManyStatus', fetchManyEndpoint]) ); -export const pipelinesSelector = createSelector( - getPipelines, - getResources -); -export const pipelineSelector = pipelineId => - createSelector( - pipelinesSelector, - pipelines => pipelines.get(pipelineId) - ); - -export const getPipeline = id => - createSelector( - getPipelines, - pipelines => pipelines.getIn(['resources', id]) - ); - -export const getFork = (id, forkId) => - createSelector( - getPipeline(id), - pipeline => pipeline.getIn(['data', 'forks', forkId]) - ); +export const pipelinesSelector = createSelector(getPipelines, getResources); +export const pipelineSelector = pipelineId => createSelector(pipelinesSelector, pipelines => pipelines.get(pipelineId)); -/* TODO - reconstruction required (pipelines will be modified to support many-to-many relation with exercises) -export const exercisePipelinesSelector = exerciseId => - createSelector([pipelinesSelector], pipelines => - pipelines - .filter(isReady) - .filter(pipeline => pipeline.toJS().data.exerciseId === exerciseId) - ); -*/ +export const getPipeline = id => createSelector(getPipelines, pipelines => pipelines.getIn(['resources', id])); export const getPipelinesEnvironmentsWhichHasEntryPoint = createSelector( pipelinesSelector, @@ -56,10 +30,11 @@ export const getPipelinesEnvironmentsWhichHasEntryPoint = createSelector( ); export const pipelineEnvironmentsSelector = id => - createSelector( - [getPipeline(id), runtimeEnvironmentSelector], - (pipeline, envSelector) => { - const envIds = pipeline && pipeline.getIn(['data', 'runtimeEnvironmentIds']); - return envIds && envSelector ? envIds.toArray().map(envSelector) : null; - } - ); + createSelector([getPipeline(id), runtimeEnvironmentSelector], (pipeline, envSelector) => { + const envIds = pipeline && pipeline.getIn(['data', 'runtimeEnvironmentIds']); + return envIds && envSelector ? envIds.toArray().map(envSelector) : null; + }); + +export const getPipelineExercises = createSelector([pipelinesSelector, getParam], (pipelines, id) => + pipelines.getIn([id, 'exercises']) +);