From 992ec01f10bb50b6b2560da314be853db705432e Mon Sep 17 00:00:00 2001 From: Martin Krulis Date: Thu, 14 Sep 2023 02:23:08 +0200 Subject: [PATCH] Adding notification action button to exercise-related pages. --- .../ExerciseButtons/ExerciseButtons.js | 135 ++++++++++++++++++ .../Exercises/ExerciseButtons/index.js | 2 + src/components/icons/index.js | 1 + src/locales/cs.json | 8 ++ src/locales/en.json | 8 ++ src/pages/EditExercise/EditExercise.js | 7 + .../EditExerciseConfig/EditExerciseConfig.js | 14 +- .../EditExerciseLimits/EditExerciseLimits.js | 7 + src/pages/Exercise/Exercise.js | 8 +- .../ExerciseAssignments.js | 8 +- src/redux/modules/exercises.js | 9 ++ 11 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/components/Exercises/ExerciseButtons/ExerciseButtons.js create mode 100644 src/components/Exercises/ExerciseButtons/index.js diff --git a/src/components/Exercises/ExerciseButtons/ExerciseButtons.js b/src/components/Exercises/ExerciseButtons/ExerciseButtons.js new file mode 100644 index 000000000..51571640d --- /dev/null +++ b/src/components/Exercises/ExerciseButtons/ExerciseButtons.js @@ -0,0 +1,135 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Row, Col, Modal, FormGroup, FormControl, FormLabel, Overlay, Popover } from 'react-bootstrap'; + +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import InsetPanel from '../../widgets/InsetPanel'; +import { BellIcon, CloseIcon, LoadingIcon, SendIcon, WarningIcon } from '../../icons'; + +const ExerciseButtons = ({ id, archivedAt = null, permissionHints = null, sendNotification }) => { + const [message, setMessage] = useState(null); // null = dialog is hidden, string = message (and dialog is open) + const [sendResult, setSendResult] = useState(null); // null = initial, true = pending, false = error, int = result + // the sendResult holds, how many users were notified (-1 is server error) + const buttonTarget = useRef(null); + + return !archivedAt && permissionHints && permissionHints.update ? ( + <> + + + + + + + + + + + + {props => ( + + + {sendResult === false || sendResult < 0 ? ( + <> + + + + ) : sendResult === 0 ? ( + + ) : ( + + )} + + + + + + )} + + + + + setMessage(null)} size="xl"> + + + + + + + + + + + + + + : + + setMessage(ev.target.value)} /> + + + + + + + + + + + + ) : null; +}; + +ExerciseButtons.propTypes = { + id: PropTypes.string.isRequired, + archivedAt: PropTypes.number, + permissionHints: PropTypes.object, + sendNotification: PropTypes.func.isRequired, +}; + +export default ExerciseButtons; diff --git a/src/components/Exercises/ExerciseButtons/index.js b/src/components/Exercises/ExerciseButtons/index.js new file mode 100644 index 000000000..6b1068cb9 --- /dev/null +++ b/src/components/Exercises/ExerciseButtons/index.js @@ -0,0 +1,2 @@ +import ExerciseButtons from './ExerciseButtons'; +export default ExerciseButtons; diff --git a/src/components/icons/index.js b/src/components/icons/index.js index ad85549f6..279727084 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -22,6 +22,7 @@ export const AssignmentIcon = props => ; export const AssignmentsIcon = props => ; export const AuthorIcon = props => ; export const BanIcon = props => ; +export const BellIcon = props => ; export const BindIcon = props => ; export const BonusIcon = props => ; export const BugIcon = props => ; diff --git a/src/locales/cs.json b/src/locales/cs.json index dc70fe89d..a72dbc021 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -742,6 +742,12 @@ "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ě.", + "app.exercise.notificationButton": "Poslat oznámení", + "app.exercise.notificationButton.failedMessage": "Tato operace selhala!", + "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í.", + "app.exercise.notificationButton.successMessage": "Oznámení bylo úspěšně odesláno {sendResult} {sendResult, plural, one {uživateli} other {uživatelům}}.", + "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.", + "app.exercise.notificationModal.title": "Odeslat oznámení vyučujícím", "app.exercise.referenceSolution.deleteConfirm": "Opravdu chcete smazat toto referenční řešení? Tuto akci není možné vrátit.", "app.exercise.referenceSolutionsBox": "Propagovaná refereční řešení", "app.exercise.runtimes": "Běhová prostředí", @@ -1994,6 +2000,7 @@ "generic.lastUpdatedAt": "aktualizováno", "generic.load": "Načíst", "generic.loading": "Načítání...", + "generic.message": "Zpráva", "generic.name": "Název", "generic.nameOfPerson": "Jméno", "generic.noRecordsInTable": "V tabulce nejsou žádné záznamy.", @@ -2015,6 +2022,7 @@ "generic.scheduledAt": "Naplánováno", "generic.search": "Vyhledat", "generic.selectAll": "Vybrat vše", + "generic.send": "Odeslat", "generic.setFilters": "Nastavit filtry", "generic.settings": "Nastavení", "generic.showAll": "Ukázat vše", diff --git a/src/locales/en.json b/src/locales/en.json index 1667a36a8..337f48dd3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -742,6 +742,12 @@ "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.", + "app.exercise.notificationButton": "Send Notification", + "app.exercise.notificationButton.failedMessage": "The operation has failed!", + "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.", + "app.exercise.notificationButton.successMessage": "The notification was successfully sent to {sendResult} {sendResult, plural, one {user} other {users}}.", + "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.", + "app.exercise.notificationModal.title": "Send a notification to teachers", "app.exercise.referenceSolution.deleteConfirm": "Are you sure you want to delete the reference solution? This cannot be undone.", "app.exercise.referenceSolutionsBox": "Promoted Reference Solutions", "app.exercise.runtimes": "Runtime environments", @@ -1994,6 +2000,7 @@ "generic.lastUpdatedAt": "updated", "generic.load": "Load", "generic.loading": "Loading...", + "generic.message": "Message", "generic.name": "Name", "generic.nameOfPerson": "Name", "generic.noRecordsInTable": "There are no records in the table.", @@ -2015,6 +2022,7 @@ "generic.scheduledAt": "Scheduled at", "generic.search": "Search", "generic.selectAll": "Select All", + "generic.send": "Send", "generic.setFilters": "Set Filters", "generic.settings": "Settings", "generic.showAll": "Show All", diff --git a/src/pages/EditExercise/EditExercise.js b/src/pages/EditExercise/EditExercise.js index 6734123f2..b66abb753 100644 --- a/src/pages/EditExercise/EditExercise.js +++ b/src/pages/EditExercise/EditExercise.js @@ -17,6 +17,7 @@ import ExercisesTagsEditContainer from '../../containers/ExercisesTagsEditContai import DeleteExerciseButtonContainer from '../../containers/DeleteExerciseButtonContainer'; import ArchiveExerciseButtonContainer from '../../containers/ArchiveExerciseButtonContainer'; import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts'; +import ExerciseButtons from '../../components/Exercises/ExerciseButtons'; import EditExerciseUsers from '../../components/Exercises/EditExerciseUsers'; import { EditExerciseIcon } from '../../components/icons'; @@ -26,6 +27,7 @@ import { fetchTags, attachExerciseToGroup, detachExerciseFromGroup, + sendNotification, } from '../../redux/modules/exercises'; import { fetchAllGroups } from '../../redux/modules/groups'; import { fetchByIds } from '../../redux/modules/users'; @@ -114,6 +116,7 @@ class EditExercise extends Component { detachingGroupId, attachExerciseToGroup, detachExerciseFromGroup, + sendNotification, } = this.props; return ( @@ -135,6 +138,8 @@ class EditExercise extends Component { )} + + {exercise.permissionHints.update && ( @@ -240,6 +245,7 @@ EditExercise.propTypes = { editExercise: PropTypes.func.isRequired, attachExerciseToGroup: PropTypes.func.isRequired, detachExerciseFromGroup: PropTypes.func.isRequired, + sendNotification: PropTypes.func.isRequired, params: PropTypes.shape({ exerciseId: PropTypes.string.isRequired, }).isRequired, @@ -262,6 +268,7 @@ export default withLinks( editExercise: (version, data) => dispatch(editExercise(exerciseId, { ...data, version })), attachExerciseToGroup: groupId => dispatch(attachExerciseToGroup(exerciseId, groupId)), detachExerciseFromGroup: groupId => dispatch(detachExerciseFromGroup(exerciseId, groupId)), + sendNotification: message => dispatch(sendNotification(exerciseId, message)), }) )(EditExercise) ); diff --git a/src/pages/EditExerciseConfig/EditExerciseConfig.js b/src/pages/EditExerciseConfig/EditExerciseConfig.js index 0650d095d..5365b4ee1 100644 --- a/src/pages/EditExerciseConfig/EditExerciseConfig.js +++ b/src/pages/EditExerciseConfig/EditExerciseConfig.js @@ -17,11 +17,18 @@ import EditEnvironmentSimpleForm from '../../components/forms/EditEnvironmentSim import EditEnvironmentConfigForm from '../../components/forms/EditEnvironmentConfigForm'; import EditExercisePipelinesForm from '../../components/forms/EditExercisePipelinesForm/EditExercisePipelinesForm'; import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts'; +import ExerciseButtons from '../../components/Exercises/ExerciseButtons'; import ExerciseConfigTypeButton from '../../components/buttons/ExerciseConfigTypeButton'; import { InfoIcon, TestsIcon } from '../../components/icons'; import Callout from '../../components/widgets/Callout'; -import { fetchExercise, fetchExerciseIfNeeded, editExercise, invalidateExercise } from '../../redux/modules/exercises'; +import { + fetchExercise, + fetchExerciseIfNeeded, + editExercise, + invalidateExercise, + sendNotification, +} from '../../redux/modules/exercises'; import { fetchExerciseConfig, fetchExerciseConfigIfNeeded, @@ -274,6 +281,7 @@ class EditExerciseConfig extends Component { pipelinesVariables, supplementaryFiles, supplementaryFilesStatus, + sendNotification, } = this.props; return ( @@ -300,6 +308,8 @@ class EditExerciseConfig extends Component { )} + + {hasPermissions(exercise, 'update') && isEmpoweredSupervisorRole(effectiveRole) && ( @@ -595,6 +605,7 @@ EditExerciseConfig.propTypes = { reloadExercise: PropTypes.func.isRequired, reloadConfig: PropTypes.func.isRequired, invalidateExercise: PropTypes.func.isRequired, + sendNotification: PropTypes.func.isRequired, navigate: withRouterProps.navigate, location: withRouterProps.location, params: PropTypes.shape({ exerciseId: PropTypes.string }).isRequired, @@ -639,6 +650,7 @@ export default withRouter( ]) ), invalidateExercise: () => dispatch(invalidateExercise(exerciseId)), + sendNotification: message => dispatch(sendNotification(exerciseId, message)), }) )(injectIntl(EditExerciseConfig)) ) diff --git a/src/pages/EditExerciseLimits/EditExerciseLimits.js b/src/pages/EditExerciseLimits/EditExerciseLimits.js index 404979408..a3c30eeb5 100644 --- a/src/pages/EditExerciseLimits/EditExerciseLimits.js +++ b/src/pages/EditExerciseLimits/EditExerciseLimits.js @@ -13,6 +13,7 @@ import HardwareGroupMetadata from '../../components/Exercises/HardwareGroupMetad import EditHardwareGroupForm from '../../components/forms/EditHardwareGroupForm'; import EditLimitsForm from '../../components/forms/EditLimitsForm/EditLimitsForm'; import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts'; +import ExerciseButtons from '../../components/Exercises/ExerciseButtons'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import Icon, { LimitsIcon } from '../../components/icons'; import Callout from '../../components/widgets/Callout'; @@ -22,6 +23,7 @@ import { fetchExerciseIfNeeded, setExerciseHardwareGroups, invalidateExercise, + sendNotification, } from '../../redux/modules/exercises'; import { fetchExerciseLimits, @@ -124,6 +126,7 @@ class EditExerciseLimits extends Component { cloneHorizontally, cloneVertically, cloneAll, + sendNotification, } = this.props; return ( @@ -145,6 +148,8 @@ class EditExerciseLimits extends Component { )} + + {Boolean(exercise.hardwareGroups && exercise.hardwareGroups.length > 1) && ( @@ -290,6 +295,7 @@ EditExerciseLimits.propTypes = { cloneAll: PropTypes.func.isRequired, reloadExercise: PropTypes.func.isRequired, invalidateExercise: PropTypes.func.isRequired, + sendNotification: PropTypes.func.isRequired, }; const cloneVerticallyWrapper = defaultMemoize( @@ -333,5 +339,6 @@ export default connect( fetchExerciseLimits: envId => dispatch(fetchExerciseLimits(exerciseId, envId)), reloadExercise: () => dispatch(fetchExercise(exerciseId)), invalidateExercise: () => dispatch(invalidateExercise(exerciseId)), + sendNotification: message => dispatch(sendNotification(exerciseId, message)), }) )(EditExerciseLimits); diff --git a/src/pages/Exercise/Exercise.js b/src/pages/Exercise/Exercise.js index d72ae0175..8a25e426d 100644 --- a/src/pages/Exercise/Exercise.js +++ b/src/pages/Exercise/Exercise.js @@ -19,11 +19,12 @@ import ReferenceSolutionsTable from '../../components/Exercises/ReferenceSolutio import Box from '../../components/widgets/Box'; import { ExerciseIcon, SendIcon, LinkIcon } from '../../components/icons'; import ExerciseCallouts from '../../components/Exercises/ExerciseCallouts'; +import ExerciseButtons from '../../components/Exercises/ExerciseButtons'; import ForkExerciseForm from '../../components/forms/ForkExerciseForm'; import Callout from '../../components/widgets/Callout'; import { isSubmitting } from '../../redux/selectors/submission'; -import { fetchExerciseIfNeeded, reloadExercise, forkExercise } from '../../redux/modules/exercises'; +import { fetchExerciseIfNeeded, reloadExercise, forkExercise, sendNotification } from '../../redux/modules/exercises'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { runtimeEnvironmentsSelector } from '../../redux/selectors/runtimeEnvironments'; import { fetchReferenceSolutions } from '../../redux/modules/referenceSolutions'; @@ -112,6 +113,7 @@ class Exercise extends Component { groupsAccessor, reload, forkExercise, + sendNotification, links: { EXERCISE_ASSIGNMENTS_URI_FACTORY, EXERCISE_REFERENCE_SOLUTIONS_URI_FACTORY }, } = this.props; @@ -149,6 +151,8 @@ class Exercise extends Component { + + @@ -296,6 +300,7 @@ Exercise.propTypes = { reload: PropTypes.func.isRequired, initCreateReferenceSolution: PropTypes.func.isRequired, forkExercise: PropTypes.func.isRequired, + sendNotification: PropTypes.func.isRequired, navigate: withRouterProps.navigate, location: withRouterProps.location, params: PropTypes.shape({ exerciseId: PropTypes.string }).isRequired, @@ -322,6 +327,7 @@ export default withRouter( reload: () => dispatch(reloadExercise(exerciseId)), initCreateReferenceSolution: userId => dispatch(init(userId, exerciseId)), forkExercise: (forkId, data) => dispatch(forkExercise(exerciseId, forkId, data)), + sendNotification: message => dispatch(sendNotification(exerciseId, message)), }) )(injectIntl(Exercise)) ) diff --git a/src/pages/ExerciseAssignments/ExerciseAssignments.js b/src/pages/ExerciseAssignments/ExerciseAssignments.js index bbb3ec015..928312e28 100644 --- a/src/pages/ExerciseAssignments/ExerciseAssignments.js +++ b/src/pages/ExerciseAssignments/ExerciseAssignments.js @@ -14,12 +14,13 @@ import Box from '../../components/widgets/Box'; import Callout from '../../components/widgets/Callout'; import { AssignmentsIcon, LockIcon, CheckRequiredIcon, SaveIcon } from '../../components/icons'; import ExerciseCallouts, { exerciseCalloutsAreVisible } from '../../components/Exercises/ExerciseCallouts'; +import ExerciseButtons from '../../components/Exercises/ExerciseButtons'; import AssignmentsTable from '../../components/Assignments/Assignment/AssignmentsTable'; import EditAssignmentForm, { prepareInitialValues as prepareEditFormInitialValues, } from '../../components/forms/EditAssignmentForm'; -import { fetchExerciseIfNeeded } from '../../redux/modules/exercises'; +import { fetchExerciseIfNeeded, sendNotification } from '../../redux/modules/exercises'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { create as assignExercise, @@ -125,6 +126,7 @@ class ExerciseAssignments extends Component { syncAssignment, editAssignment, deleteAssignment, + sendNotification, intl: { formatMessage }, } = this.props; @@ -147,6 +149,8 @@ class ExerciseAssignments extends Component { )} + + {hasPermissions(exercise, 'viewAssignments') && ( @@ -264,6 +268,7 @@ ExerciseAssignments.propTypes = { syncAssignment: PropTypes.func.isRequired, editAssignment: PropTypes.func.isRequired, deleteAssignment: PropTypes.func.isRequired, + sendNotification: PropTypes.func.isRequired, }; const multiAssignFormSelector = formValueSelector('multiAssign'); @@ -288,5 +293,6 @@ export default connect( syncAssignment: id => dispatch(syncWithExercise(id)), editAssignment: (id, body) => dispatch(editAssignment(id, body)), deleteAssignment: id => dispatch(deleteAssignment(id)), + sendNotification: message => dispatch(sendNotification(exerciseId, message)), }) )(injectIntl(ExerciseAssignments)); diff --git a/src/redux/modules/exercises.js b/src/redux/modules/exercises.js index 5aeee9a2f..9d0443e46 100644 --- a/src/redux/modules/exercises.js +++ b/src/redux/modules/exercises.js @@ -30,6 +30,7 @@ export const additionalActionTypes = { 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', + SEND_NOTIFICATION: 'recodex/exercises/SEND_NOTIFICATION', ...createActionsWithPostfixes('FORK_EXERCISE', 'recodex/exercises'), ...createActionsWithPostfixes('ATTACH_EXERCISE_GROUP', 'recodex/exercises'), ...createActionsWithPostfixes('DETACH_EXERCISE_GROUP', 'recodex/exercises'), @@ -182,6 +183,14 @@ export const setAdmins = (exerciseId, admins) => body: { admins }, }); +export const sendNotification = (id, message) => + createApiAction({ + type: additionalActionTypes.SEND_NOTIFICATION, + endpoint: `/exercises/${id}/notification`, + method: 'POST', + body: { message }, + }); + /* * Reducer */