Skip to content

Commit 0d259d8

Browse files
committed
Adding visualization and editing support for exercise admins and for changing the author of the exercise.
1 parent 17b4623 commit 0d259d8

File tree

14 files changed

+426
-60
lines changed

14 files changed

+426
-60
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { FormattedMessage } from 'react-intl';
4+
import { Table } from 'react-bootstrap';
5+
6+
import ExerciseUserButtonsContainer from '../../../containers/ExerciseUserButtonsContainer';
7+
import AddUserContainer from '../../../containers/AddUserContainer';
8+
import UsersNameContainer from '../../../containers/UsersNameContainer';
9+
import Box from '../../widgets/Box';
10+
import Explanation from '../../widgets/Explanation';
11+
import { AdminIcon, AuthorIcon } from '../../icons';
12+
13+
import { knownRoles, isSupervisorRole } from '../../helpers/usersRoles';
14+
15+
const ROLES_FILTER = knownRoles.filter(isSupervisorRole);
16+
17+
const EditExerciseUsers = ({ exercise, instanceId }) => {
18+
return (
19+
<Box
20+
type="warning"
21+
title={<FormattedMessage id="app.editExercise.manageUsers" defaultMessage="Manage related users" />}
22+
noPadding>
23+
<>
24+
<Table className="border-bottom mb-1">
25+
<tbody>
26+
<tr>
27+
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
28+
<AuthorIcon fixedWidth gapLeft />
29+
</td>
30+
<th>
31+
<FormattedMessage id="generic.author" defaultMessage="Author" />:
32+
</th>
33+
<td>
34+
<UsersNameContainer userId={exercise.authorId} showEmail="icon" link />
35+
</td>
36+
</tr>
37+
<tr>
38+
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
39+
<AdminIcon fixedWidth gapLeft />
40+
</td>
41+
<th>
42+
<FormattedMessage id="app.exercise.admins" defaultMessage="Administrators" />:
43+
<Explanation id="admins">
44+
<FormattedMessage
45+
id="app.exercise.admins.explanation"
46+
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."
47+
/>
48+
</Explanation>
49+
</th>
50+
<td>
51+
{exercise.adminsIds.map(id => (
52+
<div key={id} className="mb-2">
53+
<UsersNameContainer userId={id} showEmail="icon" link />
54+
<span className="float-right mr-2">
55+
<ExerciseUserButtonsContainer userId={id} exercise={exercise} />
56+
</span>
57+
</div>
58+
))}
59+
{exercise.adminsIds.length === 0 && (
60+
<em className="small text-muted">
61+
<FormattedMessage id="app.exercise.noAdmins" defaultMessage="no administrators appointed" />
62+
</em>
63+
)}
64+
</td>
65+
</tr>
66+
</tbody>
67+
</Table>
68+
69+
{(exercise.permissionHints.changeAuthor || exercise.permissionHints.updateAdmins) && (
70+
<div className="m-3 mt-1">
71+
<AddUserContainer
72+
instanceId={instanceId}
73+
id={`add-exercise-user-${exercise.id}`}
74+
rolesFilter={ROLES_FILTER}
75+
createActions={({ id }) => <ExerciseUserButtonsContainer userId={id} exercise={exercise} />}
76+
/>
77+
</div>
78+
)}
79+
</>
80+
</Box>
81+
);
82+
};
83+
84+
EditExerciseUsers.propTypes = {
85+
instanceId: PropTypes.string.isRequired,
86+
exercise: PropTypes.object.isRequired,
87+
};
88+
89+
export default EditExerciseUsers;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import EditExerciseUsers from './EditExerciseUsers';
2+
export default EditExerciseUsers;

src/components/Exercises/ExerciseDetail/ExerciseDetail.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@ import DifficultyIcon from '../DifficultyIcon';
1111
import ResourceRenderer from '../../helpers/ResourceRenderer';
1212
import withLinks from '../../../helpers/withLinks';
1313
import UsersNameContainer from '../../../containers/UsersNameContainer';
14-
import Icon, { SuccessOrFailureIcon, UserIcon, VisibleIcon, CodeIcon, TagIcon, ForkIcon } from '../../icons';
14+
import Icon, {
15+
AdminIcon,
16+
SuccessOrFailureIcon,
17+
AuthorIcon,
18+
VisibleIcon,
19+
CodeIcon,
20+
TagIcon,
21+
ForkIcon,
22+
} from '../../icons';
1523
import { getLocalizedDescription } from '../../../helpers/localizedData';
1624
import { LocalizedExerciseName } from '../../helpers/LocalizedNames';
1725
import EnvironmentsList from '../../helpers/EnvironmentsList';
@@ -21,6 +29,7 @@ import { getTagStyle } from '../../../helpers/exercise/tags';
2129

2230
const ExerciseDetail = ({
2331
authorId,
32+
adminsIds = [],
2433
description = '',
2534
difficulty,
2635
createdAt,
@@ -39,11 +48,11 @@ const ExerciseDetail = ({
3948
links: { EXERCISE_URI_FACTORY },
4049
}) => (
4150
<Box title={<FormattedMessage id="generic.details" defaultMessage="Details" />} noPadding className={className}>
42-
<Table responsive size="sm">
51+
<Table responsive size="sm" className="mb-1">
4352
<tbody>
4453
<tr>
4554
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
46-
<UserIcon />
55+
<AuthorIcon />
4756
</td>
4857
<th>
4958
<FormattedMessage id="generic.author" defaultMessage="Author" />:
@@ -53,6 +62,30 @@ const ExerciseDetail = ({
5362
</td>
5463
</tr>
5564

65+
{adminsIds.length > 0 && (
66+
<tr>
67+
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
68+
<AdminIcon />
69+
</td>
70+
<th>
71+
<FormattedMessage id="app.exercise.admins" defaultMessage="Administrators" />:
72+
<Explanation id="admins">
73+
<FormattedMessage
74+
id="app.exercise.admins.explanation"
75+
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."
76+
/>
77+
</Explanation>
78+
</th>
79+
<td>
80+
{adminsIds.map(id => (
81+
<div key={id}>
82+
<UsersNameContainer userId={id} showEmail="icon" link />
83+
</div>
84+
))}
85+
</td>
86+
</tr>
87+
)}
88+
5689
<tr>
5790
<td className="text-center text-muted shrink-col em-padding-left em-padding-right">
5891
<Icon icon={['far', 'file-alt']} />
@@ -226,6 +259,7 @@ ExerciseDetail.propTypes = {
226259
id: PropTypes.string.isRequired,
227260
name: PropTypes.string.isRequired,
228261
authorId: PropTypes.string.isRequired,
262+
adminsIds: PropTypes.array,
229263
groupsIds: PropTypes.array,
230264
difficulty: PropTypes.string.isRequired,
231265
description: PropTypes.string,

src/components/Groups/AddSupervisor/AddSupervisor.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { defaultMemoize } from 'reselect';
88
import Button, { TheButtonGroup } from '../../widgets/TheButton';
99
import AddUserContainer from '../../../containers/AddUserContainer';
1010
import { knownRoles, isSupervisorRole } from '../../helpers/usersRoles';
11-
import { AdminIcon, ObserverIcon, SupervisorIcon, LoadingIcon } from '../../icons';
11+
import { AdminRoleIcon, ObserverIcon, SupervisorIcon, LoadingIcon } from '../../icons';
1212

1313
const ROLES_FILTER = knownRoles.filter(isSupervisorRole);
1414

@@ -67,7 +67,7 @@ const AddSupervisor = ({
6767
onClick={() => addAdmin(groupId, id)}
6868
disabled={isMember}
6969
variant={isMember ? 'secondary' : 'success'}>
70-
<AdminIcon smallGapRight smallGapLeft fixedWidth />
70+
<AdminRoleIcon smallGapRight smallGapLeft fixedWidth />
7171
</Button>
7272
</OverlayTrigger>
7373
)}

src/components/Groups/MemberGroupsDropdown/MemberGroupsDropdown.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
55
import { Link } from 'react-router-dom';
66

77
import GroupsNameContainer from '../../../containers/GroupsNameContainer';
8-
import { GroupIcon, AdminIcon, SupervisorIcon, ObserverIcon, UserIcon } from '../../icons';
8+
import { GroupIcon, AdminRoleIcon, SupervisorIcon, ObserverIcon, UserIcon } from '../../icons';
99
import withLinks from '../../../helpers/withLinks';
1010

1111
import './MemberGroupsDropdown.css';
@@ -43,7 +43,7 @@ const MemberGroupsDropdown = ({ groupId = null, memberGroups }) => (
4343
groupId={groupId}
4444
groups={memberGroups.admin}
4545
title={<FormattedMessage id="app.memberGroups.asAdmin" defaultMessage="Groups you administer" />}
46-
icon={<AdminIcon gapRight />}
46+
icon={<AdminRoleIcon gapRight />}
4747
/>
4848

4949
<DropdownFragment

src/components/Users/SupervisorsListItem/SupervisorsListItem.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
55

66
import Button, { TheButtonGroup } from '../../widgets/TheButton';
77
import UsersNameContainer from '../../../containers/UsersNameContainer';
8-
import Icon, { AdminIcon, ObserverIcon, SupervisorIcon, UserIcon, LoadingIcon } from '../../icons';
8+
import Icon, { AdminRoleIcon, ObserverIcon, SupervisorIcon, UserIcon, LoadingIcon } from '../../icons';
99

1010
const SupervisorsListItem = ({
1111
showButtons,
@@ -41,7 +41,7 @@ const SupervisorsListItem = ({
4141
</Popover.Content>
4242
</Popover>
4343
}>
44-
<AdminIcon />
44+
<AdminRoleIcon />
4545
</OverlayTrigger>
4646
) : type === 'supervisor' ? (
4747
<OverlayTrigger
@@ -103,7 +103,7 @@ const SupervisorsListItem = ({
103103
</Tooltip>
104104
}>
105105
<Button size="xs" onClick={() => addAdmin(groupId, id)} variant="warning" disabled={pendingMembership}>
106-
<AdminIcon smallGapRight smallGapLeft fixedWidth />
106+
<AdminRoleIcon smallGapRight smallGapLeft fixedWidth />
107107
</Button>
108108
</OverlayTrigger>
109109
)}

src/components/icons/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ const defaultMessageIcon = ['far', 'envelope'];
1212
export const AbortIcon = props => <Icon {...props} icon="car-crash" />;
1313
export const AcceptIcon = props => <Icon {...props} icon={['far', 'handshake']} />;
1414
export const AddIcon = props => <Icon {...props} icon="plus-circle" />;
15-
export const AdminIcon = props => <Icon {...props} icon="crown" />;
15+
export const AdminIcon = props => <Icon {...props} icon="user-tie" />;
16+
export const AdminRoleIcon = props => <Icon {...props} icon="crown" />;
1617
export const AdressIcon = props => <Icon {...props} icon="at" />;
1718
export const ArchiveIcon = props => <Icon {...props} icon="archive" />;
1819
export const ArchiveGroupIcon = ({ archived = false, ...props }) => (
1920
<Icon {...props} icon={archived ? 'dolly' : 'archive'} />
2021
);
2122
export const AssignmentIcon = props => <Icon {...props} icon="laptop-code" />;
2223
export const AssignmentsIcon = props => <Icon {...props} icon="tasks" />;
24+
export const AuthorIcon = props => <Icon {...props} icon="user-pen" />;
2325
export const BanIcon = props => <Icon {...props} icon="ban" />;
2426
export const BindIcon = props => <Icon {...props} icon="link" />;
2527
export const BonusIcon = props => <Icon {...props} icon="hand-holding-usd" />;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { connect } from 'react-redux';
4+
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
5+
import { FormattedMessage } from 'react-intl';
6+
7+
import { setAuthor, setAdmins } from '../../redux/modules/exercises';
8+
import { getExerciseSetAuthorStatus, getExerciseSetAdminsStatus } from '../../redux/selectors/exercises';
9+
10+
import Button, { TheButtonGroup } from '../../components/widgets/TheButton';
11+
import { AdminIcon, AuthorIcon, LoadingIcon, WarningIcon } from '../../components/icons';
12+
13+
const ExerciseUserButtonsContainer = ({
14+
exercise,
15+
userId,
16+
setAuthor,
17+
setAuthorStatus,
18+
addAdmin,
19+
removeAdmin,
20+
setAdminsStatus,
21+
size = 'xs',
22+
}) => {
23+
const isAdmin = exercise.adminsIds && exercise.adminsIds.includes(userId);
24+
return exercise.authorId !== userId ? (
25+
<TheButtonGroup>
26+
{exercise.permissionHints.changeAuthor && (
27+
<OverlayTrigger
28+
placement="bottom"
29+
overlay={
30+
<Tooltip id={`authorButton-${userId}`}>
31+
<FormattedMessage
32+
id="app.editExercise.setAuthorButton"
33+
defaultMessage="Make this user an author of the exercise (replacing current author)"
34+
/>
35+
</Tooltip>
36+
}>
37+
<Button
38+
variant={setAuthorStatus === false ? 'danger' : 'warning'}
39+
size={size}
40+
onClick={setAuthor}
41+
disabled={Boolean(setAuthorStatus)}>
42+
{setAuthorStatus === false && <WarningIcon fixedWidth smallGapRight />}
43+
{setAuthorStatus ? <LoadingIcon fixedWidth /> : <AuthorIcon fixedWidth />}
44+
</Button>
45+
</OverlayTrigger>
46+
)}
47+
48+
{exercise.permissionHints.updateAdmins && (
49+
<OverlayTrigger
50+
placement="bottom"
51+
overlay={
52+
<Tooltip id={`adminButton-${userId}`}>
53+
{isAdmin ? (
54+
<FormattedMessage
55+
id="app.editExercise.removeAdminButton"
56+
defaultMessage="Remove the user from exercise admins"
57+
/>
58+
) : (
59+
<FormattedMessage
60+
id="app.editExercise.addAdminButton"
61+
defaultMessage="Make the user an exercise admin"
62+
/>
63+
)}
64+
</Tooltip>
65+
}>
66+
<Button
67+
variant={isAdmin || setAuthorStatus === false ? 'danger' : 'success'}
68+
size={size}
69+
onClick={isAdmin ? removeAdmin : addAdmin}
70+
disabled={Boolean(setAdminsStatus)}>
71+
{setAdminsStatus === false && <WarningIcon fixedWidth smallGapRight />}
72+
{setAdminsStatus ? (
73+
<LoadingIcon fixedWidth />
74+
) : isAdmin ? (
75+
<AdminIcon fixedWidth />
76+
) : (
77+
<AdminIcon fixedWidth />
78+
)}
79+
</Button>
80+
</OverlayTrigger>
81+
)}
82+
</TheButtonGroup>
83+
) : (
84+
<OverlayTrigger
85+
placement="bottom"
86+
overlay={
87+
<Tooltip id={`disabledButton-${userId}`}>
88+
<FormattedMessage
89+
id="app.editExercise.userIsAuthor"
90+
defaultMessage="The user is the author of the exercise"
91+
/>
92+
</Tooltip>
93+
}>
94+
<Button variant="secondary" size={size} disabled>
95+
<AuthorIcon fixedWidth />
96+
</Button>
97+
</OverlayTrigger>
98+
);
99+
};
100+
101+
ExerciseUserButtonsContainer.propTypes = {
102+
userId: PropTypes.string.isRequired,
103+
exercise: PropTypes.object.isRequired,
104+
setAuthorStatus: PropTypes.bool,
105+
setAdminsStatus: PropTypes.bool,
106+
size: PropTypes.string,
107+
setAuthor: PropTypes.func.isRequired,
108+
addAdmin: PropTypes.func.isRequired,
109+
removeAdmin: PropTypes.func.isRequired,
110+
};
111+
112+
export default connect(
113+
(state, { exercise }) => ({
114+
setAuthorStatus: getExerciseSetAuthorStatus(state, exercise.id),
115+
setAdminsStatus: getExerciseSetAdminsStatus(state, exercise.id),
116+
}),
117+
(dispatch, { exercise, userId }) => ({
118+
setAuthor: () => dispatch(setAuthor(exercise.id, userId)),
119+
addAdmin: () => dispatch(setAdmins(exercise.id, [...exercise.adminsIds, userId])),
120+
removeAdmin: () =>
121+
dispatch(
122+
setAdmins(
123+
exercise.id,
124+
exercise.adminsIds.filter(id => id !== userId)
125+
)
126+
),
127+
})
128+
)(ExerciseUserButtonsContainer);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import ExerciseUserButtonsContainer from './ExerciseUserButtonsContainer';
2+
export default ExerciseUserButtonsContainer;

0 commit comments

Comments
 (0)