diff --git a/src/components/Exercises/EditExerciseUsers/EditExerciseUsers.js b/src/components/Exercises/EditExerciseUsers/EditExerciseUsers.js new file mode 100644 index 000000000..92efd1737 --- /dev/null +++ b/src/components/Exercises/EditExerciseUsers/EditExerciseUsers.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Table } from 'react-bootstrap'; + +import ExerciseUserButtonsContainer from '../../../containers/ExerciseUserButtonsContainer'; +import AddUserContainer from '../../../containers/AddUserContainer'; +import UsersNameContainer from '../../../containers/UsersNameContainer'; +import Box from '../../widgets/Box'; +import Explanation from '../../widgets/Explanation'; +import { AdminIcon, AuthorIcon } from '../../icons'; + +import { knownRoles, isSupervisorRole } from '../../helpers/usersRoles'; + +const ROLES_FILTER = knownRoles.filter(isSupervisorRole); + +const EditExerciseUsers = ({ exercise, instanceId }) => { + return ( + } + noPadding> + <> + + + + + + + + + + + + + +
+ + + : + + +
+ + + : + + + + + {exercise.adminsIds.map(id => ( +
+ + + + +
+ ))} + {exercise.adminsIds.length === 0 && ( + + + + )} +
+ + {(exercise.permissionHints.changeAuthor || exercise.permissionHints.updateAdmins) && ( +
+ } + /> +
+ )} + +
+ ); +}; + +EditExerciseUsers.propTypes = { + instanceId: PropTypes.string.isRequired, + exercise: PropTypes.object.isRequired, +}; + +export default EditExerciseUsers; diff --git a/src/components/Exercises/EditExerciseUsers/index.js b/src/components/Exercises/EditExerciseUsers/index.js new file mode 100644 index 000000000..0d3963324 --- /dev/null +++ b/src/components/Exercises/EditExerciseUsers/index.js @@ -0,0 +1,2 @@ +import EditExerciseUsers from './EditExerciseUsers'; +export default EditExerciseUsers; diff --git a/src/components/Exercises/ExerciseDetail/ExerciseDetail.js b/src/components/Exercises/ExerciseDetail/ExerciseDetail.js index d021e7075..b4cc9de2b 100644 --- a/src/components/Exercises/ExerciseDetail/ExerciseDetail.js +++ b/src/components/Exercises/ExerciseDetail/ExerciseDetail.js @@ -11,7 +11,15 @@ import DifficultyIcon from '../DifficultyIcon'; import ResourceRenderer from '../../helpers/ResourceRenderer'; import withLinks from '../../../helpers/withLinks'; import UsersNameContainer from '../../../containers/UsersNameContainer'; -import Icon, { SuccessOrFailureIcon, UserIcon, VisibleIcon, CodeIcon, TagIcon, ForkIcon } from '../../icons'; +import Icon, { + AdminIcon, + SuccessOrFailureIcon, + AuthorIcon, + VisibleIcon, + CodeIcon, + TagIcon, + ForkIcon, +} from '../../icons'; import { getLocalizedDescription } from '../../../helpers/localizedData'; import { LocalizedExerciseName } from '../../helpers/LocalizedNames'; import EnvironmentsList from '../../helpers/EnvironmentsList'; @@ -21,6 +29,7 @@ import { getTagStyle } from '../../../helpers/exercise/tags'; const ExerciseDetail = ({ authorId, + adminsIds = [], description = '', difficulty, createdAt, @@ -39,11 +48,11 @@ const ExerciseDetail = ({ links: { EXERCISE_URI_FACTORY }, }) => ( } noPadding className={className}> - +
+ {adminsIds.length > 0 && ( + + + + + + )} + + }> + + {exercise.permissionHints.changeAuthor && safeGet(loggedUser, ['privateData', 'instancesIds', 0]) && ( + + )} -
+ {exercise.permissionHints.remove && (
@@ -138,20 +149,22 @@ class EditExercise extends Component { title={ }> -
-

- -

-

+ +

navigate(EXERCISES_URI, { replace: true })} /> -

- + +
+ + + @@ -166,6 +179,7 @@ class EditExercise extends Component { EditExercise.propTypes = { exercise: ImmutablePropTypes.map, + loggedUser: ImmutablePropTypes.map, loadAsync: PropTypes.func.isRequired, reset: PropTypes.func.isRequired, editExercise: PropTypes.func.isRequired, @@ -178,13 +192,11 @@ EditExercise.propTypes = { export default withLinks( connect( - (state, { params: { exerciseId } }) => { - return { - exercise: getExercise(exerciseId)(state), - submitting: isSubmitting(state), - userId: loggedInUserIdSelector(state), - }; - }, + (state, { params: { exerciseId } }) => ({ + exercise: getExercise(exerciseId)(state), + submitting: isSubmitting(state), + loggedUser: loggedInUserSelector(state), + }), (dispatch, { params: { exerciseId } }) => ({ reset: () => dispatch(reset('editExercise')), loadAsync: () => EditExercise.loadAsync({ exerciseId }, dispatch), diff --git a/src/redux/modules/exercises.js b/src/redux/modules/exercises.js index 86adbba3e..5aeee9a2f 100644 --- a/src/redux/modules/exercises.js +++ b/src/redux/modules/exercises.js @@ -1,6 +1,11 @@ import { handleActions } from 'redux-actions'; import { Map, List, fromJS } 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 supplementaryFilesActionTypes } from './supplementaryFiles'; @@ -22,33 +27,18 @@ export { actionTypes }; export const additionalActionTypes = { VALIDATE_EXERCISE: 'recodex/exercises/VALIDATE_EXERCISE', - FORK_EXERCISE: 'recodex/exercises/FORK_EXERCISE', - FORK_EXERCISE_PENDING: 'recodex/exercises/FORK_EXERCISE_PENDING', - FORK_EXERCISE_REJECTED: 'recodex/exercises/FORK_EXERCISE_REJECTED', - FORK_EXERCISE_FULFILLED: 'recodex/exercises/FORK_EXERCISE_FULFILLED', GET_PIPELINE_VARIABLES: 'recodex/exercises/GET_PIPELINE_VARIABLES', SET_HARDWARE_GROUPS: 'recodex/exercises/SET_HARDWARE_GROUPS', SET_HARDWARE_GROUPS_FULFILLED: 'recodex/exercises/SET_HARDWARE_GROUPS_FULFILLED', - ATTACH_EXERCISE_GROUP: 'recodex/exercises/ATTACH_EXERCISE_GROUP', - ATTACH_EXERCISE_GROUP_PENDING: 'recodex/exercises/ATTACH_EXERCISE_GROUP_PENDING', - ATTACH_EXERCISE_GROUP_REJECTED: 'recodex/exercises/ATTACH_EXERCISE_GROUP_REJECTED', - ATTACH_EXERCISE_GROUP_FULFILLED: 'recodex/exercises/ATTACH_EXERCISE_GROUP_FULFILLED', - DETACH_EXERCISE_GROUP: 'recodex/exercises/DETACH_EXERCISE_GROUP', - DETACH_EXERCISE_GROUP_PENDING: 'recodex/exercises/DETACH_EXERCISE_GROUP_PENDING', - DETACH_EXERCISE_GROUP_REJECTED: 'recodex/exercises/DETACH_EXERCISE_GROUP_REJECTED', - DETACH_EXERCISE_GROUP_FULFILLED: 'recodex/exercises/DETACH_EXERCISE_GROUP_FULFILLED', - FETCH_TAGS: 'recodex/exercises/FETCH_TAGS', - FETCH_TAGS_PENDING: 'recodex/exercises/FETCH_TAGS_PENDING', - FETCH_TAGS_REJECTED: 'recodex/exercises/FETCH_TAGS_REJECTED', - FETCH_TAGS_FULFILLED: 'recodex/exercises/FETCH_TAGS_FULFILLED', - ADD_TAG: 'recodex/exercises/ADD_TAG', - ADD_TAG_PENDING: 'recodex/exercises/ADD_TAG_PENDING', - ADD_TAG_REJECTED: 'recodex/exercises/ADD_TAG_REJECTED', - ADD_TAG_FULFILLED: 'recodex/exercises/ADD_TAG_FULFILLED', - REMOVE_TAG: 'recodex/exercises/REMOVE_TAG', - REMOVE_TAG_PENDING: 'recodex/exercises/REMOVE_TAG_PENDING', - REMOVE_TAG_REJECTED: 'recodex/exercises/REMOVE_TAG_REJECTED', - REMOVE_TAG_FULFILLED: 'recodex/exercises/REMOVE_TAG_FULFILLED', + ...createActionsWithPostfixes('FORK_EXERCISE', 'recodex/exercises'), + ...createActionsWithPostfixes('ATTACH_EXERCISE_GROUP', 'recodex/exercises'), + ...createActionsWithPostfixes('DETACH_EXERCISE_GROUP', 'recodex/exercises'), + ...createActionsWithPostfixes('FETCH_TAGS', 'recodex/exercises'), + ...createActionsWithPostfixes('ADD_TAG', 'recodex/exercises'), + ...createActionsWithPostfixes('REMOVE_TAG', 'recodex/exercises'), + ...createActionsWithPostfixes('SET_ARCHIVED', 'recodex/exercises'), + ...createActionsWithPostfixes('SET_AUTHOR', 'recodex/exercises'), + ...createActionsWithPostfixes('SET_ADMINS', 'recodex/exercises'), }; export const loadExercise = actions.pushResource; @@ -161,6 +151,37 @@ export const removeTag = (exerciseId, tagName) => meta: { exerciseId, tagName }, }); +/* + * Others + */ + +export const setArchived = (exerciseId, archived) => + createApiAction({ + type: additionalActionTypes.SET_ARCHIVED, + method: 'POST', + endpoint: `/exercises/${exerciseId}/archived`, + meta: { exerciseId }, + body: { archived }, + }); + +export const setAuthor = (exerciseId, author) => + createApiAction({ + type: additionalActionTypes.SET_AUTHOR, + method: 'POST', + endpoint: `/exercises/${exerciseId}/author`, + meta: { exerciseId }, + body: { author }, + }); + +export const setAdmins = (exerciseId, admins) => + createApiAction({ + type: additionalActionTypes.SET_ADMINS, + method: 'POST', + endpoint: `/exercises/${exerciseId}/admins`, + meta: { exerciseId }, + body: { admins }, + }); + /* * Reducer */ @@ -304,6 +325,51 @@ const reducer = handleActions( lastUpdate: Date.now(), }) ), + + [additionalActionTypes.SET_ARCHIVED_PENDING]: (state, { meta: { exerciseId } }) => + state.setIn(['resources', exerciseId, 'archived'], true), + [additionalActionTypes.SET_ARCHIVED_REJECTED]: (state, { meta: { exerciseId } }) => + state.setIn(['resources', exerciseId, 'archived'], false), + [additionalActionTypes.SET_ARCHIVED_FULFILLED]: (state, { payload: data }) => + state.setIn( + ['resources', data.id], + createRecord({ + data, + state: resourceStatus.FULFILLED, + didInvalidate: false, + lastUpdate: Date.now(), + }) + ), + + [additionalActionTypes.SET_AUTHOR_PENDING]: (state, { meta: { exerciseId } }) => + state.setIn(['resources', exerciseId, 'author'], true), + [additionalActionTypes.SET_AUTHOR_REJECTED]: (state, { meta: { exerciseId } }) => + state.setIn(['resources', exerciseId, 'author'], false), + [additionalActionTypes.SET_AUTHOR_FULFILLED]: (state, { payload: data }) => + state.setIn( + ['resources', data.id], + createRecord({ + data, + state: resourceStatus.FULFILLED, + didInvalidate: false, + lastUpdate: Date.now(), + }) + ), + + [additionalActionTypes.SET_ADMINS_PENDING]: (state, { meta: { exerciseId } }) => + state.setIn(['resources', exerciseId, 'admins'], true), + [additionalActionTypes.SET_ADMINS_REJECTED]: (state, { meta: { exerciseId } }) => + state.setIn(['resources', exerciseId, 'admins'], false), + [additionalActionTypes.SET_ADMINS_FULFILLED]: (state, { payload: data }) => + state.setIn( + ['resources', data.id], + createRecord({ + data, + state: resourceStatus.FULFILLED, + didInvalidate: false, + lastUpdate: Date.now(), + }) + ), }), initialState ); diff --git a/src/redux/selectors/exercises.js b/src/redux/selectors/exercises.js index fc641ab23..2d7505cff 100644 --- a/src/redux/selectors/exercises.js +++ b/src/redux/selectors/exercises.js @@ -2,7 +2,7 @@ import { createSelector, defaultMemoize } from 'reselect'; import { EMPTY_ARRAY, EMPTY_LIST } from '../../helpers/common'; import { isReady } from '../helpers/resourceManager'; -const getParam = (state, id) => id; +const getParam = (_, id) => id; const getExercises = state => state.exercises; const getResources = exercises => exercises && exercises.get('resources'); @@ -69,3 +69,18 @@ export const getExerciseTags = state => { export const getExerciseTagsLoading = state => getExercises(state).get('tags') === null; export const getExerciseTagsUpdatePending = state => getExercises(state).get('tagsPending', null); + +export const getExerciseSetArchivedStatus = createSelector( + [exercisesSelector, getParam], + (exercises, exerciseId) => exercises && exercises.getIn([exerciseId, 'archived'], null) +); + +export const getExerciseSetAuthorStatus = createSelector( + [exercisesSelector, getParam], + (exercises, exerciseId) => exercises && exercises.getIn([exerciseId, 'author'], null) +); + +export const getExerciseSetAdminsStatus = createSelector( + [exercisesSelector, getParam], + (exercises, exerciseId) => exercises && exercises.getIn([exerciseId, 'admins'], null) +);
- + : @@ -53,6 +62,30 @@ const ExerciseDetail = ({
+ + + : + + + + + {adminsIds.map(id => ( +
+ +
+ ))} +
@@ -226,6 +259,7 @@ ExerciseDetail.propTypes = { id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, authorId: PropTypes.string.isRequired, + adminsIds: PropTypes.array, groupsIds: PropTypes.array, difficulty: PropTypes.string.isRequired, description: PropTypes.string, diff --git a/src/components/Groups/AddSupervisor/AddSupervisor.js b/src/components/Groups/AddSupervisor/AddSupervisor.js index 30eb26535..0fc495933 100644 --- a/src/components/Groups/AddSupervisor/AddSupervisor.js +++ b/src/components/Groups/AddSupervisor/AddSupervisor.js @@ -8,7 +8,7 @@ import { defaultMemoize } from 'reselect'; import Button, { TheButtonGroup } from '../../widgets/TheButton'; import AddUserContainer from '../../../containers/AddUserContainer'; import { knownRoles, isSupervisorRole } from '../../helpers/usersRoles'; -import { AdminIcon, ObserverIcon, SupervisorIcon, LoadingIcon } from '../../icons'; +import { AdminRoleIcon, ObserverIcon, SupervisorIcon, LoadingIcon } from '../../icons'; const ROLES_FILTER = knownRoles.filter(isSupervisorRole); @@ -67,7 +67,7 @@ const AddSupervisor = ({ onClick={() => addAdmin(groupId, id)} disabled={isMember} variant={isMember ? 'secondary' : 'success'}> - + )} diff --git a/src/components/Groups/MemberGroupsDropdown/MemberGroupsDropdown.js b/src/components/Groups/MemberGroupsDropdown/MemberGroupsDropdown.js index e9d909cf9..2bbec17ee 100644 --- a/src/components/Groups/MemberGroupsDropdown/MemberGroupsDropdown.js +++ b/src/components/Groups/MemberGroupsDropdown/MemberGroupsDropdown.js @@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; import GroupsNameContainer from '../../../containers/GroupsNameContainer'; -import { GroupIcon, AdminIcon, SupervisorIcon, ObserverIcon, UserIcon } from '../../icons'; +import { GroupIcon, AdminRoleIcon, SupervisorIcon, ObserverIcon, UserIcon } from '../../icons'; import withLinks from '../../../helpers/withLinks'; import './MemberGroupsDropdown.css'; @@ -43,7 +43,7 @@ const MemberGroupsDropdown = ({ groupId = null, memberGroups }) => ( groupId={groupId} groups={memberGroups.admin} title={} - icon={} + icon={} /> }> - + ) : type === 'supervisor' ? ( }> )} diff --git a/src/components/icons/index.js b/src/components/icons/index.js index 93516846e..fe403b56c 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -12,7 +12,8 @@ const defaultMessageIcon = ['far', 'envelope']; export const AbortIcon = props => ; export const AcceptIcon = props => ; export const AddIcon = props => ; -export const AdminIcon = props => ; +export const AdminIcon = props => ; +export const AdminRoleIcon = props => ; export const AdressIcon = props => ; export const ArchiveIcon = props => ; export const ArchiveGroupIcon = ({ archived = false, ...props }) => ( @@ -20,6 +21,7 @@ export const ArchiveGroupIcon = ({ archived = false, ...props }) => ( ); export const AssignmentIcon = props => ; export const AssignmentsIcon = props => ; +export const AuthorIcon = props => ; export const BanIcon = props => ; export const BindIcon = props => ; export const BonusIcon = props => ; diff --git a/src/containers/ExerciseUserButtonsContainer/ExerciseUserButtonsContainer.js b/src/containers/ExerciseUserButtonsContainer/ExerciseUserButtonsContainer.js new file mode 100644 index 000000000..68444ca0a --- /dev/null +++ b/src/containers/ExerciseUserButtonsContainer/ExerciseUserButtonsContainer.js @@ -0,0 +1,128 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import { FormattedMessage } from 'react-intl'; + +import { setAuthor, setAdmins } from '../../redux/modules/exercises'; +import { getExerciseSetAuthorStatus, getExerciseSetAdminsStatus } from '../../redux/selectors/exercises'; + +import Button, { TheButtonGroup } from '../../components/widgets/TheButton'; +import { AdminIcon, AuthorIcon, LoadingIcon, WarningIcon } from '../../components/icons'; + +const ExerciseUserButtonsContainer = ({ + exercise, + userId, + setAuthor, + setAuthorStatus, + addAdmin, + removeAdmin, + setAdminsStatus, + size = 'xs', +}) => { + const isAdmin = exercise.adminsIds && exercise.adminsIds.includes(userId); + return exercise.authorId !== userId ? ( + + {exercise.permissionHints.changeAuthor && ( + + + + }> + + + )} + + {exercise.permissionHints.updateAdmins && ( + + {isAdmin ? ( + + ) : ( + + )} + + }> + + + )} + + ) : ( + + + + }> + + + ); +}; + +ExerciseUserButtonsContainer.propTypes = { + userId: PropTypes.string.isRequired, + exercise: PropTypes.object.isRequired, + setAuthorStatus: PropTypes.bool, + setAdminsStatus: PropTypes.bool, + size: PropTypes.string, + setAuthor: PropTypes.func.isRequired, + addAdmin: PropTypes.func.isRequired, + removeAdmin: PropTypes.func.isRequired, +}; + +export default connect( + (state, { exercise }) => ({ + setAuthorStatus: getExerciseSetAuthorStatus(state, exercise.id), + setAdminsStatus: getExerciseSetAdminsStatus(state, exercise.id), + }), + (dispatch, { exercise, userId }) => ({ + setAuthor: () => dispatch(setAuthor(exercise.id, userId)), + addAdmin: () => dispatch(setAdmins(exercise.id, [...exercise.adminsIds, userId])), + removeAdmin: () => + dispatch( + setAdmins( + exercise.id, + exercise.adminsIds.filter(id => id !== userId) + ) + ), + }) +)(ExerciseUserButtonsContainer); diff --git a/src/containers/ExerciseUserButtonsContainer/index.js b/src/containers/ExerciseUserButtonsContainer/index.js new file mode 100644 index 000000000..9e5a4e19e --- /dev/null +++ b/src/containers/ExerciseUserButtonsContainer/index.js @@ -0,0 +1,2 @@ +import ExerciseUserButtonsContainer from './ExerciseUserButtonsContainer'; +export default ExerciseUserButtonsContainer; diff --git a/src/locales/cs.json b/src/locales/cs.json index 0513c1a06..6d9687efa 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -375,11 +375,16 @@ "app.editEnvironmentSimpleForm.success": "Konfigurace byla změněna.", "app.editEnvironmentSimpleForm.validation.environments": "Přidejte prosím alespoň jedno běhové prostředí.", "app.editEnvironmentSimpleForm.validation.standaloneEnvironmentsCollisions": "Některá vybraná běhová prostředí ({envs}) nemohou být kombinovaná s jinými. Odeberte tato běhová prostředí, nebo zajistěte, že bude vybrané právě jedno z nich.", + "app.editExercise.addAdminButton": "Zařadit uživatele mezi administrátory úlohy", "app.editExercise.deleteExercise": "Smazat úlohu", "app.editExercise.deleteExerciseWarning": "Smazání úlohy nebude mít žádný vliv na již zadané instance této úlohy ani jejich řešení, zadané úlohy pouze již nebude možné aktualizovat. Smazání ovšem efektivně odebre tuto úlohu ze všech domovských skupin.", "app.editExercise.editConfig": "Konfigurace úlohy", "app.editExercise.editTags": "Editace nálepek", + "app.editExercise.manageUsers": "Spravovat přidružené uživatele", + "app.editExercise.removeAdminButton": "Odebrat uživatele ze seznamu administrátorů úlohy", + "app.editExercise.setAuthorButton": "Nahradit stávajícího autora úlohy tímto uživatelem", "app.editExercise.title": "Upravit základní nastavení úlohy", + "app.editExercise.userIsAuthor": "Uživatel je autorem úlohy", "app.editExerciseAdvancedConfigForm.validation.emptyFileName": "Prosíme, vložte platné jméno.", "app.editExerciseConfig.cannotDisplayConfigForm": "Formulář s nastavením úlohy nemůže být zobrazen dokud nebude definován alespoň jeden test.", "app.editExerciseConfig.cannotDisplayPipelinesForm": "Testy a běhové prostředí musí být správně nastaveny, aby bylo možné zobrazit konfiguraci pipeline.", @@ -700,6 +705,8 @@ "app.evaluationTable.notAvailable": "Vyhodnocení není dostupné", "app.evaluationTable.score": "Skóre:", "app.exercise.addReferenceSolutionDetailed": "Referenční řešení můžete vytvořit na hlavní stránce úlohy.", + "app.exercise.admins": "Administrátoři", + "app.exercise.admins.explanation": "Administrátoři mají stejná práva jako autor úlohy, ale nejsou zobrazováni v seznamech ani nejsou použiti při filtrování úloh.", "app.exercise.assignButton": "Zadat", "app.exercise.assignToGroup": "Zde můžete zadat úlohu najednou do více skupin, ve kterých jste vedoucím. Úlohu je také možné zadat individuálně na stránkách jednotlivých skupin. Vezměte prosím na vědomí, že úloha může být zadána v jedné skupině vícekrát, a proto je vhodné se ujistit, že vícenásobné zadání není způsobeno omylem.", "app.exercise.attach": "Připojit", @@ -720,6 +727,7 @@ "app.exercise.isPublicExplanation": "Veřejné úlohy jsou viditelné všem vedoucím v asociovaných skupinách a jejich podskupinách. Privátní (neveřejné) úlohy jsou viditelné pouze autorovi.", "app.exercise.manageGroupAttachments": "Úprava vazeb na skupiny", "app.exercise.mergeJudgeLogsExplanation": "Tento příznak určuje zda dojde ke spojení primárního (stdout) a sekundárního (stderr) logu sudího do jednoho (což je výchozí postup u vestavěných sudích). Pokud zůstanou logy odděleny, jejich viditelnost může být u zadaných úloh nastavena nezávisle. To je užitečné v případě, že máte vlastního sudího, který vrací dva oddělené logy (např. jeden pro studenty a jeden pro vyučujicí).", + "app.exercise.noAdmins": "žádní administrátoři nebyli přiřazeni", "app.exercise.noRefSolutions": "Úloha nebyla dostatečně ověřena. Aby bylo možné úlohu zadat musí existovat alespoň jedno referenční řešení.", "app.exercise.noReferenceSolutions": "Tato úloha zatím nemá žádná referenční řešení.", "app.exercise.noReferenceSolutionsDetailed": "Konfigurace úlohy by měla být prověřena alespoň jedním referenčním řešením než bude možné ji zadat ve skupině.", diff --git a/src/locales/en.json b/src/locales/en.json index 1b139ea89..d570c52b8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -375,11 +375,16 @@ "app.editEnvironmentSimpleForm.success": "Environments Saved.", "app.editEnvironmentSimpleForm.validation.environments": "Please add at least one runtime environment.", "app.editEnvironmentSimpleForm.validation.standaloneEnvironmentsCollisions": "Some of the selected environments ({envs}) cannot be combined with any other environment. You need to deselect these environment(s) or make sure only one environment is selected.", + "app.editExercise.addAdminButton": "Make the user an exercise admin", "app.editExercise.deleteExercise": "Delete the exercise", "app.editExercise.deleteExerciseWarning": "Deletion of an exercise will not affect any existing assignments nor their solutions, except they could not be synchronized anymore. However, the deletion will effectively remove the exercise from all groups of residence.", "app.editExercise.editConfig": "Exercise Configuration", "app.editExercise.editTags": "Edit Tags", + "app.editExercise.manageUsers": "Manage related users", + "app.editExercise.removeAdminButton": "Remove the user from exercise admins", + "app.editExercise.setAuthorButton": "Make this user an author of the exercise (replacing current author)", "app.editExercise.title": "Change Basic Exercise Settings", + "app.editExercise.userIsAuthor": "The user is the author of the exercise", "app.editExerciseAdvancedConfigForm.validation.emptyFileName": "Please, fill in a vaild file name.", "app.editExerciseConfig.cannotDisplayConfigForm": "The exercise configuration form cannot be displayed until at least one test is defined.", "app.editExerciseConfig.cannotDisplayPipelinesForm": "The tests and runtime environment must be correctly defined before the pipeline configuration becomes available.", @@ -700,6 +705,8 @@ "app.evaluationTable.notAvailable": "Evaluation not available", "app.evaluationTable.score": "Score:", "app.exercise.addReferenceSolutionDetailed": "A reference solution can be added on the exercise detail page.", + "app.exercise.admins": "Administrators", + "app.exercise.admins.explanation": "The administrators have the same permissions as the author towards the exercise, but they are not explicitly mentioned in listings or used in search filters.", "app.exercise.assignButton": "Assign", "app.exercise.assignToGroup": "Here, you can assign this exercise simultaneously to multiple groups under your supervision. The exercise can also be assigned from within the groups individually. Please note that an exercise may be assigned multiple times in a group, so beware accidental repetitive assignment.", "app.exercise.attach": "Attach", @@ -720,6 +727,7 @@ "app.exercise.isPublicExplanation": "Public exercise is visible to all supervisors in its home groups and respective nested groups. Private (not public) exercise is visible to the author only.", "app.exercise.manageGroupAttachments": "Manage Group Attachments", "app.exercise.mergeJudgeLogsExplanation": "The merge flag indicates whether primary (stdout) and secondary (stderr) judge logs are are concatenated in one log (which should be default for built-in judges). If the logs are separated, the visibility of each part may be controlled idividually in assignments. That might be helpful if you need to pass two separate logs from a custom judge (e.g., one is for students and one is for supervisors).", + "app.exercise.noAdmins": "no administrators appointed", "app.exercise.noRefSolutions": "Exercise has no proof of concept. Exercise must get at least one reference solution before it can be assigned.", "app.exercise.noReferenceSolutions": "There are no reference solutions for this exercise yet.", "app.exercise.noReferenceSolutionsDetailed": "The exercise configuration should be verified on one reference solution at least before it can be assigned.", diff --git a/src/pages/EditExercise/EditExercise.js b/src/pages/EditExercise/EditExercise.js index 42eb088ce..4a2a4038e 100644 --- a/src/pages/EditExercise/EditExercise.js +++ b/src/pages/EditExercise/EditExercise.js @@ -15,15 +15,16 @@ import AttachmentFilesTableContainer from '../../containers/AttachmentFilesTable import ExercisesTagsEditContainer from '../../containers/ExercisesTagsEditContainer'; import DeleteExerciseButtonContainer from '../../containers/DeleteExerciseButtonContainer'; import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts'; +import EditExerciseUsers from '../../components/Exercises/EditExerciseUsers'; import { EditExerciseIcon } from '../../components/icons'; import { fetchExerciseIfNeeded, editExercise, fetchTags } from '../../redux/modules/exercises'; import { getExercise } from '../../redux/selectors/exercises'; import { isSubmitting } from '../../redux/selectors/submission'; -import { loggedInUserIdSelector } from '../../redux/selectors/auth'; +import { loggedInUserSelector } from '../../redux/selectors/users'; import { getLocalizedTextsInitialValues, transformLocalizedTextsFormData } from '../../helpers/localizedData'; -import { hasPermissions } from '../../helpers/common'; +import { hasPermissions, safeGet } from '../../helpers/common'; import withLinks from '../../helpers/withLinks'; import { withRouterProps } from '../../helpers/withRouter'; @@ -89,15 +90,17 @@ class EditExercise extends Component { links: { EXERCISES_URI }, navigate, exercise, + loggedUser, } = this.props; return ( } title={}> - {exercise => - exercise && ( + {(exercise, loggedUser) => + exercise && + loggedUser && (