Skip to content

Commit

Permalink
Adding visualization and editing support for exercise admins and for …
Browse files Browse the repository at this point in the history
…changing the author of the exercise.
  • Loading branch information
krulis-martin committed Aug 21, 2023
1 parent 17b4623 commit 0d259d8
Show file tree
Hide file tree
Showing 14 changed files with 426 additions and 60 deletions.
89 changes: 89 additions & 0 deletions src/components/Exercises/EditExerciseUsers/EditExerciseUsers.js
Original file line number Diff line number Diff line change
@@ -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 (
<Box
type="warning"
title={<FormattedMessage id="app.editExercise.manageUsers" defaultMessage="Manage related users" />}
noPadding>
<>
<Table className="border-bottom mb-1">
<tbody>
<tr>
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
<AuthorIcon fixedWidth gapLeft />
</td>
<th>
<FormattedMessage id="generic.author" defaultMessage="Author" />:
</th>
<td>
<UsersNameContainer userId={exercise.authorId} showEmail="icon" link />
</td>
</tr>
<tr>
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
<AdminIcon fixedWidth gapLeft />
</td>
<th>
<FormattedMessage id="app.exercise.admins" defaultMessage="Administrators" />:
<Explanation id="admins">
<FormattedMessage
id="app.exercise.admins.explanation"
defaultMessage="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."
/>
</Explanation>
</th>
<td>
{exercise.adminsIds.map(id => (
<div key={id} className="mb-2">
<UsersNameContainer userId={id} showEmail="icon" link />
<span className="float-right mr-2">
<ExerciseUserButtonsContainer userId={id} exercise={exercise} />
</span>
</div>
))}
{exercise.adminsIds.length === 0 && (
<em className="small text-muted">
<FormattedMessage id="app.exercise.noAdmins" defaultMessage="no administrators appointed" />
</em>
)}
</td>
</tr>
</tbody>
</Table>

{(exercise.permissionHints.changeAuthor || exercise.permissionHints.updateAdmins) && (
<div className="m-3 mt-1">
<AddUserContainer
instanceId={instanceId}
id={`add-exercise-user-${exercise.id}`}
rolesFilter={ROLES_FILTER}
createActions={({ id }) => <ExerciseUserButtonsContainer userId={id} exercise={exercise} />}
/>
</div>
)}
</>
</Box>
);
};

EditExerciseUsers.propTypes = {
instanceId: PropTypes.string.isRequired,
exercise: PropTypes.object.isRequired,
};

export default EditExerciseUsers;
2 changes: 2 additions & 0 deletions src/components/Exercises/EditExerciseUsers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import EditExerciseUsers from './EditExerciseUsers';
export default EditExerciseUsers;
40 changes: 37 additions & 3 deletions src/components/Exercises/ExerciseDetail/ExerciseDetail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +29,7 @@ import { getTagStyle } from '../../../helpers/exercise/tags';

const ExerciseDetail = ({
authorId,
adminsIds = [],
description = '',
difficulty,
createdAt,
Expand All @@ -39,11 +48,11 @@ const ExerciseDetail = ({
links: { EXERCISE_URI_FACTORY },
}) => (
<Box title={<FormattedMessage id="generic.details" defaultMessage="Details" />} noPadding className={className}>
<Table responsive size="sm">
<Table responsive size="sm" className="mb-1">
<tbody>
<tr>
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
<UserIcon />
<AuthorIcon />
</td>
<th>
<FormattedMessage id="generic.author" defaultMessage="Author" />:
Expand All @@ -53,6 +62,30 @@ const ExerciseDetail = ({
</td>
</tr>

{adminsIds.length > 0 && (
<tr>
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
<AdminIcon />
</td>
<th>
<FormattedMessage id="app.exercise.admins" defaultMessage="Administrators" />:
<Explanation id="admins">
<FormattedMessage
id="app.exercise.admins.explanation"
defaultMessage="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."
/>
</Explanation>
</th>
<td>
{adminsIds.map(id => (
<div key={id}>
<UsersNameContainer userId={id} showEmail="icon" link />
</div>
))}
</td>
</tr>
)}

<tr>
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
<Icon icon={['far', 'file-alt']} />
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/components/Groups/AddSupervisor/AddSupervisor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -67,7 +67,7 @@ const AddSupervisor = ({
onClick={() => addAdmin(groupId, id)}
disabled={isMember}
variant={isMember ? 'secondary' : 'success'}>
<AdminIcon smallGapRight smallGapLeft fixedWidth />
<AdminRoleIcon smallGapRight smallGapLeft fixedWidth />
</Button>
</OverlayTrigger>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,7 +43,7 @@ const MemberGroupsDropdown = ({ groupId = null, memberGroups }) => (
groupId={groupId}
groups={memberGroups.admin}
title={<FormattedMessage id="app.memberGroups.asAdmin" defaultMessage="Groups you administer" />}
icon={<AdminIcon gapRight />}
icon={<AdminRoleIcon gapRight />}
/>

<DropdownFragment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';

import Button, { TheButtonGroup } from '../../widgets/TheButton';
import UsersNameContainer from '../../../containers/UsersNameContainer';
import Icon, { AdminIcon, ObserverIcon, SupervisorIcon, UserIcon, LoadingIcon } from '../../icons';
import Icon, { AdminRoleIcon, ObserverIcon, SupervisorIcon, UserIcon, LoadingIcon } from '../../icons';

const SupervisorsListItem = ({
showButtons,
Expand Down Expand Up @@ -41,7 +41,7 @@ const SupervisorsListItem = ({
</Popover.Content>
</Popover>
}>
<AdminIcon />
<AdminRoleIcon />
</OverlayTrigger>
) : type === 'supervisor' ? (
<OverlayTrigger
Expand Down Expand Up @@ -103,7 +103,7 @@ const SupervisorsListItem = ({
</Tooltip>
}>
<Button size="xs" onClick={() => addAdmin(groupId, id)} variant="warning" disabled={pendingMembership}>
<AdminIcon smallGapRight smallGapLeft fixedWidth />
<AdminRoleIcon smallGapRight smallGapLeft fixedWidth />
</Button>
</OverlayTrigger>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/components/icons/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ const defaultMessageIcon = ['far', 'envelope'];
export const AbortIcon = props => <Icon {...props} icon="car-crash" />;
export const AcceptIcon = props => <Icon {...props} icon={['far', 'handshake']} />;
export const AddIcon = props => <Icon {...props} icon="plus-circle" />;
export const AdminIcon = props => <Icon {...props} icon="crown" />;
export const AdminIcon = props => <Icon {...props} icon="user-tie" />;
export const AdminRoleIcon = props => <Icon {...props} icon="crown" />;
export const AdressIcon = props => <Icon {...props} icon="at" />;
export const ArchiveIcon = props => <Icon {...props} icon="archive" />;
export const ArchiveGroupIcon = ({ archived = false, ...props }) => (
<Icon {...props} icon={archived ? 'dolly' : 'archive'} />
);
export const AssignmentIcon = props => <Icon {...props} icon="laptop-code" />;
export const AssignmentsIcon = props => <Icon {...props} icon="tasks" />;
export const AuthorIcon = props => <Icon {...props} icon="user-pen" />;
export const BanIcon = props => <Icon {...props} icon="ban" />;
export const BindIcon = props => <Icon {...props} icon="link" />;
export const BonusIcon = props => <Icon {...props} icon="hand-holding-usd" />;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ? (
<TheButtonGroup>
{exercise.permissionHints.changeAuthor && (
<OverlayTrigger
placement="bottom"
overlay={
<Tooltip id={`authorButton-${userId}`}>
<FormattedMessage
id="app.editExercise.setAuthorButton"
defaultMessage="Make this user an author of the exercise (replacing current author)"
/>
</Tooltip>
}>
<Button
variant={setAuthorStatus === false ? 'danger' : 'warning'}
size={size}
onClick={setAuthor}
disabled={Boolean(setAuthorStatus)}>
{setAuthorStatus === false && <WarningIcon fixedWidth smallGapRight />}
{setAuthorStatus ? <LoadingIcon fixedWidth /> : <AuthorIcon fixedWidth />}
</Button>
</OverlayTrigger>
)}

{exercise.permissionHints.updateAdmins && (
<OverlayTrigger
placement="bottom"
overlay={
<Tooltip id={`adminButton-${userId}`}>
{isAdmin ? (
<FormattedMessage
id="app.editExercise.removeAdminButton"
defaultMessage="Remove the user from exercise admins"
/>
) : (
<FormattedMessage
id="app.editExercise.addAdminButton"
defaultMessage="Make the user an exercise admin"
/>
)}
</Tooltip>
}>
<Button
variant={isAdmin || setAuthorStatus === false ? 'danger' : 'success'}
size={size}
onClick={isAdmin ? removeAdmin : addAdmin}
disabled={Boolean(setAdminsStatus)}>
{setAdminsStatus === false && <WarningIcon fixedWidth smallGapRight />}
{setAdminsStatus ? (
<LoadingIcon fixedWidth />
) : isAdmin ? (
<AdminIcon fixedWidth />
) : (
<AdminIcon fixedWidth />
)}
</Button>
</OverlayTrigger>
)}
</TheButtonGroup>
) : (
<OverlayTrigger
placement="bottom"
overlay={
<Tooltip id={`disabledButton-${userId}`}>
<FormattedMessage
id="app.editExercise.userIsAuthor"
defaultMessage="The user is the author of the exercise"
/>
</Tooltip>
}>
<Button variant="secondary" size={size} disabled>
<AuthorIcon fixedWidth />
</Button>
</OverlayTrigger>
);
};

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);
2 changes: 2 additions & 0 deletions src/containers/ExerciseUserButtonsContainer/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import ExerciseUserButtonsContainer from './ExerciseUserButtonsContainer';
export default ExerciseUserButtonsContainer;
Loading

0 comments on commit 0d259d8

Please sign in to comment.