Skip to content

Commit 992ec01

Browse files
committed
Adding notification action button to exercise-related pages.
1 parent 32e5ff2 commit 992ec01

File tree

11 files changed

+204
-3
lines changed

11 files changed

+204
-3
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import React, { useState, useRef } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { FormattedMessage } from 'react-intl';
4+
import { Row, Col, Modal, FormGroup, FormControl, FormLabel, Overlay, Popover } from 'react-bootstrap';
5+
6+
import Button, { TheButtonGroup } from '../../widgets/TheButton';
7+
import InsetPanel from '../../widgets/InsetPanel';
8+
import { BellIcon, CloseIcon, LoadingIcon, SendIcon, WarningIcon } from '../../icons';
9+
10+
const ExerciseButtons = ({ id, archivedAt = null, permissionHints = null, sendNotification }) => {
11+
const [message, setMessage] = useState(null); // null = dialog is hidden, string = message (and dialog is open)
12+
const [sendResult, setSendResult] = useState(null); // null = initial, true = pending, false = error, int = result
13+
// the sendResult holds, how many users were notified (-1 is server error)
14+
const buttonTarget = useRef(null);
15+
16+
return !archivedAt && permissionHints && permissionHints.update ? (
17+
<>
18+
<Row>
19+
<Col className="mb-3" xs={12} lg={true}>
20+
<TheButtonGroup></TheButtonGroup>
21+
</Col>
22+
23+
<Col xs={12} lg="auto" className="mb-3">
24+
<TheButtonGroup className="text-nowrap">
25+
<Button
26+
variant={
27+
sendResult === false || sendResult < 0
28+
? 'danger'
29+
: sendResult === null || sendResult === true
30+
? 'warning'
31+
: 'success'
32+
}
33+
onClick={() => (sendResult !== null && sendResult !== true ? setSendResult(null) : setMessage(''))}
34+
disabled={sendResult !== null}
35+
ref={buttonTarget}>
36+
{sendResult === true ? <LoadingIcon gapRight /> : <BellIcon gapRight />}
37+
<FormattedMessage id="app.exercise.notificationButton" defaultMessage="Send Notification" />
38+
</Button>
39+
</TheButtonGroup>
40+
41+
<Overlay target={buttonTarget.current} show={sendResult !== null && sendResult !== true} placement="bottom">
42+
{props => (
43+
<Popover id={id} {...props}>
44+
<Popover.Title>
45+
{sendResult === false || sendResult < 0 ? (
46+
<>
47+
<WarningIcon className="text-danger" gapRight />
48+
<FormattedMessage
49+
id="app.exercise.notificationButton.failedMessage"
50+
defaultMessage="The operation has failed!"
51+
/>
52+
</>
53+
) : sendResult === 0 ? (
54+
<FormattedMessage
55+
id="app.exercise.notificationButton.noRecipients"
56+
defaultMessage="No recipients of the notification were found. Please note that the users may choose to ignore these notifications in their personal settings."
57+
/>
58+
) : (
59+
<FormattedMessage
60+
id="app.exercise.notificationButton.successMessage"
61+
defaultMessage="The notification was successfully sent to {sendResult} {sendResult, plural, one {user} other {users}}."
62+
values={{ sendResult }}
63+
/>
64+
)}
65+
</Popover.Title>
66+
<Popover.Content className="text-center">
67+
<Button onClick={() => setSendResult(null)} size="xs" variant="success">
68+
<FormattedMessage id="generic.acknowledge" defaultMessage="Acknowledge" />
69+
</Button>
70+
</Popover.Content>
71+
</Popover>
72+
)}
73+
</Overlay>
74+
</Col>
75+
</Row>
76+
77+
<Modal show={message !== null} backdrop="static" onHide={() => setMessage(null)} size="xl">
78+
<Modal.Header closeButton>
79+
<Modal.Title>
80+
<FormattedMessage
81+
id="app.exercise.notificationModal.title"
82+
defaultMessage="Send a notification to teachers"
83+
/>
84+
</Modal.Title>
85+
</Modal.Header>
86+
87+
<Modal.Body>
88+
<InsetPanel>
89+
<FormattedMessage
90+
id="app.exercise.notificationModal.explain"
91+
defaultMessage="A notification is sent by email to all group admins and supervisors who have assigned this exercise in their groups. Optionally, you may attach a custom message to the notification. If you leave the message empty, a generic notification informing that the exercise was changed will be sent."
92+
/>
93+
</InsetPanel>
94+
95+
<FormGroup controlId="message">
96+
<FormLabel>
97+
<FormattedMessage id="generic.message" defaultMessage="Message" />:
98+
</FormLabel>
99+
<FormControl type="text" value={message || ''} onChange={ev => setMessage(ev.target.value)} />
100+
</FormGroup>
101+
</Modal.Body>
102+
103+
<Modal.Footer className="d-block text-center">
104+
<TheButtonGroup className="text-nowrap">
105+
<Button
106+
variant="success"
107+
onClick={() => {
108+
if (sendResult !== true) {
109+
setSendResult(true);
110+
sendNotification(message).then(({ value }) => setSendResult(value));
111+
setMessage(null);
112+
}
113+
}}>
114+
<SendIcon gapRight />
115+
<FormattedMessage id="generic.send" defaultMessage="Send" />
116+
</Button>
117+
<Button variant="secondary" onClick={() => setMessage(null)}>
118+
<CloseIcon gapRight />
119+
<FormattedMessage id="generic.close" defaultMessage="Close" />
120+
</Button>
121+
</TheButtonGroup>
122+
</Modal.Footer>
123+
</Modal>
124+
</>
125+
) : null;
126+
};
127+
128+
ExerciseButtons.propTypes = {
129+
id: PropTypes.string.isRequired,
130+
archivedAt: PropTypes.number,
131+
permissionHints: PropTypes.object,
132+
sendNotification: PropTypes.func.isRequired,
133+
};
134+
135+
export default ExerciseButtons;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import ExerciseButtons from './ExerciseButtons';
2+
export default ExerciseButtons;

src/components/icons/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const AssignmentIcon = props => <Icon {...props} icon="laptop-code" />;
2222
export const AssignmentsIcon = props => <Icon {...props} icon="tasks" />;
2323
export const AuthorIcon = props => <Icon {...props} icon="user-pen" />;
2424
export const BanIcon = props => <Icon {...props} icon="ban" />;
25+
export const BellIcon = props => <Icon {...props} icon={['far', 'bell']} />;
2526
export const BindIcon = props => <Icon {...props} icon="link" />;
2627
export const BonusIcon = props => <Icon {...props} icon="hand-holding-usd" />;
2728
export const BugIcon = props => <Icon {...props} icon="bug" />;

src/locales/cs.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,12 @@
742742
"app.exercise.noRefSolutions": "Úloha nebyla dostatečně ověřena. Aby bylo možné úlohu zadat musí existovat alespoň jedno referenční řešení.",
743743
"app.exercise.noReferenceSolutions": "Tato úloha zatím nemá žádná referenční řešení.",
744744
"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ě.",
745+
"app.exercise.notificationButton": "Poslat oznámení",
746+
"app.exercise.notificationButton.failedMessage": "Tato operace selhala!",
747+
"app.exercise.notificationButton.noRecipients": "Odeslané oznámení nemá žádné platné příjemce. Vezměte prosím na vědomí, že uživatelé si mohli tyto notifikace vypnout ve svém osobním nastavení.",
748+
"app.exercise.notificationButton.successMessage": "Oznámení bylo úspěšně odesláno {sendResult} {sendResult, plural, one {uživateli} other {uživatelům}}.",
749+
"app.exercise.notificationModal.explain": "Oznámení je odesláno emailem všem administrátorům a vedoucím skupin, ve kterých je tato úloha zadaná. Volitelně můžete doplnit vlastní zprávu s upřesnění oznámení. Pokud zprávu nevyplníte, bude odesláno obecné oznámení ohlašující změnu v úloze.",
750+
"app.exercise.notificationModal.title": "Odeslat oznámení vyučujícím",
745751
"app.exercise.referenceSolution.deleteConfirm": "Opravdu chcete smazat toto referenční řešení? Tuto akci není možné vrátit.",
746752
"app.exercise.referenceSolutionsBox": "Propagovaná refereční řešení",
747753
"app.exercise.runtimes": "Běhová prostředí",
@@ -1994,6 +2000,7 @@
19942000
"generic.lastUpdatedAt": "aktualizováno",
19952001
"generic.load": "Načíst",
19962002
"generic.loading": "Načítání...",
2003+
"generic.message": "Zpráva",
19972004
"generic.name": "Název",
19982005
"generic.nameOfPerson": "Jméno",
19992006
"generic.noRecordsInTable": "V tabulce nejsou žádné záznamy.",
@@ -2015,6 +2022,7 @@
20152022
"generic.scheduledAt": "Naplánováno",
20162023
"generic.search": "Vyhledat",
20172024
"generic.selectAll": "Vybrat vše",
2025+
"generic.send": "Odeslat",
20182026
"generic.setFilters": "Nastavit filtry",
20192027
"generic.settings": "Nastavení",
20202028
"generic.showAll": "Ukázat vše",

src/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,12 @@
742742
"app.exercise.noRefSolutions": "Exercise has no proof of concept. Exercise must get at least one reference solution before it can be assigned.",
743743
"app.exercise.noReferenceSolutions": "There are no reference solutions for this exercise yet.",
744744
"app.exercise.noReferenceSolutionsDetailed": "The exercise configuration should be verified on one reference solution at least before it can be assigned.",
745+
"app.exercise.notificationButton": "Send Notification",
746+
"app.exercise.notificationButton.failedMessage": "The operation has failed!",
747+
"app.exercise.notificationButton.noRecipients": "No recipients of the notification were found. Please note that the users may choose to ignore these notifications in their personal settings.",
748+
"app.exercise.notificationButton.successMessage": "The notification was successfully sent to {sendResult} {sendResult, plural, one {user} other {users}}.",
749+
"app.exercise.notificationModal.explain": "A notification is sent by email to all group admins and supervisors who have assigned this exercise in their groups. Optionally, you may attach a custom message to the notification. If you leave the message empty, a generic notification informing that the exercise was changed will be sent.",
750+
"app.exercise.notificationModal.title": "Send a notification to teachers",
745751
"app.exercise.referenceSolution.deleteConfirm": "Are you sure you want to delete the reference solution? This cannot be undone.",
746752
"app.exercise.referenceSolutionsBox": "Promoted Reference Solutions",
747753
"app.exercise.runtimes": "Runtime environments",
@@ -1994,6 +2000,7 @@
19942000
"generic.lastUpdatedAt": "updated",
19952001
"generic.load": "Load",
19962002
"generic.loading": "Loading...",
2003+
"generic.message": "Message",
19972004
"generic.name": "Name",
19982005
"generic.nameOfPerson": "Name",
19992006
"generic.noRecordsInTable": "There are no records in the table.",
@@ -2015,6 +2022,7 @@
20152022
"generic.scheduledAt": "Scheduled at",
20162023
"generic.search": "Search",
20172024
"generic.selectAll": "Select All",
2025+
"generic.send": "Send",
20182026
"generic.setFilters": "Set Filters",
20192027
"generic.settings": "Settings",
20202028
"generic.showAll": "Show All",

src/pages/EditExercise/EditExercise.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import ExercisesTagsEditContainer from '../../containers/ExercisesTagsEditContai
1717
import DeleteExerciseButtonContainer from '../../containers/DeleteExerciseButtonContainer';
1818
import ArchiveExerciseButtonContainer from '../../containers/ArchiveExerciseButtonContainer';
1919
import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts';
20+
import ExerciseButtons from '../../components/Exercises/ExerciseButtons';
2021
import EditExerciseUsers from '../../components/Exercises/EditExerciseUsers';
2122
import { EditExerciseIcon } from '../../components/icons';
2223

@@ -26,6 +27,7 @@ import {
2627
fetchTags,
2728
attachExerciseToGroup,
2829
detachExerciseFromGroup,
30+
sendNotification,
2931
} from '../../redux/modules/exercises';
3032
import { fetchAllGroups } from '../../redux/modules/groups';
3133
import { fetchByIds } from '../../redux/modules/users';
@@ -114,6 +116,7 @@ class EditExercise extends Component {
114116
detachingGroupId,
115117
attachExerciseToGroup,
116118
detachExerciseFromGroup,
119+
sendNotification,
117120
} = this.props;
118121

119122
return (
@@ -135,6 +138,8 @@ class EditExercise extends Component {
135138
</Row>
136139
)}
137140

141+
<ExerciseButtons {...exercise} sendNotification={sendNotification} />
142+
138143
{exercise.permissionHints.update && (
139144
<Row>
140145
<Col lg={6}>
@@ -240,6 +245,7 @@ EditExercise.propTypes = {
240245
editExercise: PropTypes.func.isRequired,
241246
attachExerciseToGroup: PropTypes.func.isRequired,
242247
detachExerciseFromGroup: PropTypes.func.isRequired,
248+
sendNotification: PropTypes.func.isRequired,
243249
params: PropTypes.shape({
244250
exerciseId: PropTypes.string.isRequired,
245251
}).isRequired,
@@ -262,6 +268,7 @@ export default withLinks(
262268
editExercise: (version, data) => dispatch(editExercise(exerciseId, { ...data, version })),
263269
attachExerciseToGroup: groupId => dispatch(attachExerciseToGroup(exerciseId, groupId)),
264270
detachExerciseFromGroup: groupId => dispatch(detachExerciseFromGroup(exerciseId, groupId)),
271+
sendNotification: message => dispatch(sendNotification(exerciseId, message)),
265272
})
266273
)(EditExercise)
267274
);

src/pages/EditExerciseConfig/EditExerciseConfig.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@ import EditEnvironmentSimpleForm from '../../components/forms/EditEnvironmentSim
1717
import EditEnvironmentConfigForm from '../../components/forms/EditEnvironmentConfigForm';
1818
import EditExercisePipelinesForm from '../../components/forms/EditExercisePipelinesForm/EditExercisePipelinesForm';
1919
import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts';
20+
import ExerciseButtons from '../../components/Exercises/ExerciseButtons';
2021
import ExerciseConfigTypeButton from '../../components/buttons/ExerciseConfigTypeButton';
2122
import { InfoIcon, TestsIcon } from '../../components/icons';
2223
import Callout from '../../components/widgets/Callout';
2324

24-
import { fetchExercise, fetchExerciseIfNeeded, editExercise, invalidateExercise } from '../../redux/modules/exercises';
25+
import {
26+
fetchExercise,
27+
fetchExerciseIfNeeded,
28+
editExercise,
29+
invalidateExercise,
30+
sendNotification,
31+
} from '../../redux/modules/exercises';
2532
import {
2633
fetchExerciseConfig,
2734
fetchExerciseConfigIfNeeded,
@@ -274,6 +281,7 @@ class EditExerciseConfig extends Component {
274281
pipelinesVariables,
275282
supplementaryFiles,
276283
supplementaryFilesStatus,
284+
sendNotification,
277285
} = this.props;
278286

279287
return (
@@ -300,6 +308,8 @@ class EditExerciseConfig extends Component {
300308
</Row>
301309
)}
302310

311+
<ExerciseButtons {...exercise} sendNotification={sendNotification} />
312+
303313
{hasPermissions(exercise, 'update') && isEmpoweredSupervisorRole(effectiveRole) && (
304314
<table className="em-margin-vertical">
305315
<tbody>
@@ -595,6 +605,7 @@ EditExerciseConfig.propTypes = {
595605
reloadExercise: PropTypes.func.isRequired,
596606
reloadConfig: PropTypes.func.isRequired,
597607
invalidateExercise: PropTypes.func.isRequired,
608+
sendNotification: PropTypes.func.isRequired,
598609
navigate: withRouterProps.navigate,
599610
location: withRouterProps.location,
600611
params: PropTypes.shape({ exerciseId: PropTypes.string }).isRequired,
@@ -639,6 +650,7 @@ export default withRouter(
639650
])
640651
),
641652
invalidateExercise: () => dispatch(invalidateExercise(exerciseId)),
653+
sendNotification: message => dispatch(sendNotification(exerciseId, message)),
642654
})
643655
)(injectIntl(EditExerciseConfig))
644656
)

src/pages/EditExerciseLimits/EditExerciseLimits.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import HardwareGroupMetadata from '../../components/Exercises/HardwareGroupMetad
1313
import EditHardwareGroupForm from '../../components/forms/EditHardwareGroupForm';
1414
import EditLimitsForm from '../../components/forms/EditLimitsForm/EditLimitsForm';
1515
import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts';
16+
import ExerciseButtons from '../../components/Exercises/ExerciseButtons';
1617
import ResourceRenderer from '../../components/helpers/ResourceRenderer';
1718
import Icon, { LimitsIcon } from '../../components/icons';
1819
import Callout from '../../components/widgets/Callout';
@@ -22,6 +23,7 @@ import {
2223
fetchExerciseIfNeeded,
2324
setExerciseHardwareGroups,
2425
invalidateExercise,
26+
sendNotification,
2527
} from '../../redux/modules/exercises';
2628
import {
2729
fetchExerciseLimits,
@@ -124,6 +126,7 @@ class EditExerciseLimits extends Component {
124126
cloneHorizontally,
125127
cloneVertically,
126128
cloneAll,
129+
sendNotification,
127130
} = this.props;
128131

129132
return (
@@ -145,6 +148,8 @@ class EditExerciseLimits extends Component {
145148
</Row>
146149
)}
147150

151+
<ExerciseButtons {...exercise} sendNotification={sendNotification} />
152+
148153
{Boolean(exercise.hardwareGroups && exercise.hardwareGroups.length > 1) && (
149154
<Row>
150155
<Col sm={12}>
@@ -290,6 +295,7 @@ EditExerciseLimits.propTypes = {
290295
cloneAll: PropTypes.func.isRequired,
291296
reloadExercise: PropTypes.func.isRequired,
292297
invalidateExercise: PropTypes.func.isRequired,
298+
sendNotification: PropTypes.func.isRequired,
293299
};
294300

295301
const cloneVerticallyWrapper = defaultMemoize(
@@ -333,5 +339,6 @@ export default connect(
333339
fetchExerciseLimits: envId => dispatch(fetchExerciseLimits(exerciseId, envId)),
334340
reloadExercise: () => dispatch(fetchExercise(exerciseId)),
335341
invalidateExercise: () => dispatch(invalidateExercise(exerciseId)),
342+
sendNotification: message => dispatch(sendNotification(exerciseId, message)),
336343
})
337344
)(EditExerciseLimits);

0 commit comments

Comments
 (0)