diff --git a/src/components/SystemMessages/MessagesList/MessagesList.js b/src/components/SystemMessages/MessagesList/MessagesList.js new file mode 100644 index 000000000..22c3a44b0 --- /dev/null +++ b/src/components/SystemMessages/MessagesList/MessagesList.js @@ -0,0 +1,122 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, intlShape, FormattedMessage } from 'react-intl'; +import { defaultMemoize } from 'reselect'; + +import SortableTable, { SortableTableColumnDescriptor } from '../../widgets/SortableTable'; +import { getLocalizedText } from '../../../helpers/localizedData'; +import DateTime from '../../widgets/DateTime'; +import { roleLabels } from '../../helpers/usersRoles'; +import UsersNameContainer from '../../../containers/UsersNameContainer'; + +class MessagesList extends Component { + prepareColumnDescriptors = defaultMemoize((systemMessages, locale) => { + const columns = [ + new SortableTableColumnDescriptor( + 'text', + , + { + className: 'text-left', + comparator: ({ text: t1 }, { text: t2 }) => + getLocalizedText(t1, locale).localeCompare(getLocalizedText(t2, locale), locale), + cellRenderer: text => text && {getLocalizedText(text, locale)}, + } + ), + + new SortableTableColumnDescriptor( + 'visibleFrom', + , + { + comparator: ({ visibleFrom: f1 }, { visibleFrom: f2 }) => f2 - f1, + cellRenderer: visibleFrom => visibleFrom && , + } + ), + + new SortableTableColumnDescriptor( + 'visibleTo', + , + { + comparator: ({ visibleTo: t1 }, { visibleTo: t2 }) => t2 - t1, + cellRenderer: visibleTo => visibleTo && , + } + ), + + new SortableTableColumnDescriptor('authorId', , { + cellRenderer: authorId => authorId && , + }), + + new SortableTableColumnDescriptor( + 'role', + , + { + comparator: ({ role: r1 }, { role: r2 }) => r1.localeCompare(r2, locale), + cellRenderer: role => role && roleLabels[role], + } + ), + + new SortableTableColumnDescriptor( + 'type', + , + { + comparator: ({ type: t1 }, { type: t2 }) => t1.localeCompare(t2, locale), + cellRenderer: type => type && {type}, + } + ), + + new SortableTableColumnDescriptor('buttons', '', { + className: 'text-right', + }), + ]; + + return columns; + }); + + prepareData = defaultMemoize(systemMessages => { + const { renderActions } = this.props; + + return systemMessages.map(message => { + const data = { + text: { localizedTexts: message.localizedTexts }, + visibleFrom: message.visibleFrom, + visibleTo: message.visibleTo, + authorId: message.authorId, + role: message.role, + type: message.type, + buttons: renderActions && renderActions(message), + }; + return data; + }); + }); + + render() { + const { + systemMessages, + intl: { locale }, + } = this.props; + + return ( + + + + } + /> + ); + } +} + +MessagesList.propTypes = { + systemMessages: PropTypes.array.isRequired, + intl: intlShape.isRequired, + renderActions: PropTypes.func, +}; + +export default injectIntl(MessagesList); diff --git a/src/components/SystemMessages/MessagesList/index.js b/src/components/SystemMessages/MessagesList/index.js new file mode 100644 index 000000000..0a9247ab0 --- /dev/null +++ b/src/components/SystemMessages/MessagesList/index.js @@ -0,0 +1,2 @@ +import MessagesList from './MessagesList'; +export default MessagesList; diff --git a/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js b/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js new file mode 100644 index 000000000..726a13f7a --- /dev/null +++ b/src/components/forms/EditSystemMessageForm/EditSystemMessageForm.js @@ -0,0 +1,168 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { reduxForm, Field, FieldArray } from 'redux-form'; +import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; +import { Alert, Modal, Button } from 'react-bootstrap'; + +import { SelectField, DatetimeField } from '../Fields'; + +import SubmitButton from '../SubmitButton'; +import LocalizedTextsFormField from '../LocalizedTextsFormField'; +import { validateLocalizedTextsFormData } from '../../../helpers/localizedData'; +import withLinks from '../../../helpers/withLinks'; +import { CloseIcon } from '../../icons'; +import { roleLabelsSimpleMessages } from '../../helpers/usersRoles'; + +const EditSystemMessageForm = ({ + initialValues, + error, + dirty, + submitting, + handleSubmit, + submitFailed, + submitSucceeded, + invalid, + asyncValidating, + isOpen, + onClose, + intl: { formatMessage }, +}) => ( + + + + + + + + {submitFailed && ( + + + + )} + + + + } + /> + + ({ + key: role, + name: formatMessage(roleLabelsSimpleMessages[role]), + }))} + addEmptyOption + label={ + + } + /> + + + } + /> + + + } + /> + + {error && dirty && {error}} + + +
+ , + submitting: , + success: , + validating: , + }} + /> + + +
+
+
+); + +EditSystemMessageForm.propTypes = { + error: PropTypes.any, + initialValues: PropTypes.object.isRequired, + values: PropTypes.object, + handleSubmit: PropTypes.func.isRequired, + dirty: PropTypes.bool, + submitting: PropTypes.bool, + submitFailed: PropTypes.bool, + submitSucceeded: PropTypes.bool, + invalid: PropTypes.bool, + asyncValidating: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), + links: PropTypes.object, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + intl: intlShape.isRequired, +}; + +const validate = ({ localizedTexts }) => { + const errors = {}; + validateLocalizedTextsFormData(errors, localizedTexts, ({ text }) => { + const textErrors = {}; + if (!text.trim()) { + textErrors.text = ( + + ); + } + + return textErrors; + }); + + return errors; +}; + +export default withLinks( + reduxForm({ + form: 'editSystemMessage', + validate, + enableReinitialize: true, + keepDirtyOnReinitialize: false, + })(injectIntl(EditSystemMessageForm)) +); diff --git a/src/components/forms/EditSystemMessageForm/index.js b/src/components/forms/EditSystemMessageForm/index.js new file mode 100644 index 000000000..f51a753d0 --- /dev/null +++ b/src/components/forms/EditSystemMessageForm/index.js @@ -0,0 +1,2 @@ +import EditSystemMessageForm from './EditSystemMessageForm'; +export default EditSystemMessageForm; diff --git a/src/components/forms/LocalizedTextsFormField/LocalizedSystemMessageFormField.js b/src/components/forms/LocalizedTextsFormField/LocalizedSystemMessageFormField.js new file mode 100644 index 000000000..67d49154a --- /dev/null +++ b/src/components/forms/LocalizedTextsFormField/LocalizedSystemMessageFormField.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Well } from 'react-bootstrap'; +import { FormattedMessage } from 'react-intl'; +import { Field } from 'redux-form'; + +import { MarkdownTextAreaField, CheckboxField } from '../Fields'; + +const LocalizedSystemMessageFormField = ({ prefix, data: enabled }) => ( + + + } + /> + + + } + /> + +); + +LocalizedSystemMessageFormField.propTypes = { + prefix: PropTypes.string.isRequired, + data: PropTypes.bool.isRequired, +}; + +export default LocalizedSystemMessageFormField; diff --git a/src/components/forms/LocalizedTextsFormField/LocalizedTextsFormField.js b/src/components/forms/LocalizedTextsFormField/LocalizedTextsFormField.js index dd110149d..59d74fb7c 100644 --- a/src/components/forms/LocalizedTextsFormField/LocalizedTextsFormField.js +++ b/src/components/forms/LocalizedTextsFormField/LocalizedTextsFormField.js @@ -8,6 +8,7 @@ import LocalizedAssignmentFormField from './LocalizedAssignmentFormField'; import LocalizedShadowAssignmentFormField from './LocalizedShadowAssignmentFormField'; import LocalizedExerciseFormField from './LocalizedExerciseFormField'; import LocalizedGroupFormField from './LocalizedGroupFormField'; +import LocalizedSystemMessageFormField from './LocalizedSystemMessageFormField'; import { WarningIcon } from '../../icons'; import { knownLocalesNames } from '../../../helpers/localizedData'; @@ -16,6 +17,7 @@ const fieldTypes = { shadowAssignment: LocalizedShadowAssignmentFormField, exercise: LocalizedExerciseFormField, group: LocalizedGroupFormField, + systemMessage: LocalizedSystemMessageFormField, }; const renderTitle = ({ locale, _enabled }) => ( diff --git a/src/components/helpers/usersRoles.js b/src/components/helpers/usersRoles.js index 972627070..462b29107 100644 --- a/src/components/helpers/usersRoles.js +++ b/src/components/helpers/usersRoles.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages } from 'react-intl'; import { OverlayTrigger, Tooltip } from 'react-bootstrap'; import { SuperadminIcon, EmpoweredSupervisorIcon, SupervisorIcon, SupervisorStudentIcon, UserIcon } from '../icons'; @@ -23,6 +23,14 @@ export const roleLabels = { [SUPERADMIN_ROLE]: , }; +export const roleLabelsSimpleMessages = defineMessages({ + [STUDENT_ROLE]: { id: 'app.roles.student', defaultMessage: 'Student' }, + [SUPERVISOR_STUDENT_ROLE]: { id: 'app.roles.supervisorStudent', defaultMessage: 'Supervisor-student' }, + [SUPERVISOR_ROLE]: { id: 'app.roles.supervisor', defaultMessage: 'Supervisor' }, + [EMPOWERED_SUPERVISOR_ROLE]: { id: 'app.roles.empoweredSupervisor', defaultMessage: 'Empowered Supervisor' }, + [SUPERADMIN_ROLE]: { id: 'app.roles.superadmin', defaultMessage: 'Main Administrator' }, +}); + export const roleLabelsPlural = { [STUDENT_ROLE]: , [SUPERVISOR_STUDENT_ROLE]: ( diff --git a/src/components/layout/Sidebar/Admin.js b/src/components/layout/Sidebar/Admin.js index 59fc5b73a..ad54a25d8 100644 --- a/src/components/layout/Sidebar/Admin.js +++ b/src/components/layout/Sidebar/Admin.js @@ -7,7 +7,7 @@ import MenuItem from '../../widgets/Sidebar/MenuItem'; import withLinks from '../../../helpers/withLinks'; -const Admin = ({ currentUrl, links: { ADMIN_INSTANCES_URI, USERS_URI, FAILURES_URI, BROKER_URI } }) => ( +const Admin = ({ currentUrl, links: { ADMIN_INSTANCES_URI, USERS_URI, FAILURES_URI, BROKER_URI, MESSAGES_URI } }) => (
    } /> + } + currentPath={currentUrl} + link={MESSAGES_URI} + /> } diff --git a/src/containers/DeleteSystemMessageButtonContainer/DeleteSystemMessageButtonContainer.js b/src/containers/DeleteSystemMessageButtonContainer/DeleteSystemMessageButtonContainer.js new file mode 100644 index 000000000..e54c56a68 --- /dev/null +++ b/src/containers/DeleteSystemMessageButtonContainer/DeleteSystemMessageButtonContainer.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import DeleteButton from '../../components/buttons/DeleteButton'; +import { deleteMessage } from '../../redux/modules/systemMessages'; +import { getMessage } from '../../redux/selectors/systemMessages'; + +const DeleteSystemMessageButtonContainer = ({ message, deleteMessage, onDeleted, ...props }) => ( + +); + +DeleteSystemMessageButtonContainer.propTypes = { + id: PropTypes.string.isRequired, + message: ImmutablePropTypes.map, + deleteMessage: PropTypes.func.isRequired, + onDeleted: PropTypes.func, +}; + +export default connect( + (state, { id }) => ({ + message: getMessage(id)(state), + }), + (dispatch, { id, onDeleted }) => ({ + deleteMessage: () => dispatch(deleteMessage(id)).then(() => onDeleted && onDeleted()), + }) +)(DeleteSystemMessageButtonContainer); diff --git a/src/containers/DeleteSystemMessageButtonContainer/index.js b/src/containers/DeleteSystemMessageButtonContainer/index.js new file mode 100644 index 000000000..1d36f5a8b --- /dev/null +++ b/src/containers/DeleteSystemMessageButtonContainer/index.js @@ -0,0 +1,2 @@ +import DeleteSystemMessageButtonContainer from './DeleteSystemMessageButtonContainer'; +export default DeleteSystemMessageButtonContainer; diff --git a/src/helpers/localizedData.js b/src/helpers/localizedData.js index 3b2c0c111..2038afcd2 100644 --- a/src/helpers/localizedData.js +++ b/src/helpers/localizedData.js @@ -32,6 +32,7 @@ const getLocalizedResourceX = field => (resource, locale) => { export const getLocalizedName = getLocalizedX('name'); export const getLocalizedDescription = getLocalizedX('description'); +export const getLocalizedText = getLocalizedX('text'); export const getLocalizedResourceName = getLocalizedResourceX('name'); diff --git a/src/links/index.js b/src/links/index.js index 3996fd3f5..d93dd93fb 100644 --- a/src/links/index.js +++ b/src/links/index.js @@ -79,6 +79,9 @@ export const linksFactory = lang => { // failures details const FAILURES_URI = `${prefix}/app/submission-failures`; + // system messages (notifications) + const MESSAGES_URI = `${prefix}/app/system-messages`; + // sis integration const SIS_INTEGRATION_URI = `${prefix}/app/sis-integration`; @@ -135,6 +138,7 @@ export const linksFactory = lang => { DOWNLOAD, LOGIN_EXTERN_FINALIZATION, FAILURES_URI, + MESSAGES_URI, SIS_INTEGRATION_URI, ARCHIVE_URI, BROKER_URI, diff --git a/src/pages/SystemMessages/SystemMessages.js b/src/pages/SystemMessages/SystemMessages.js new file mode 100644 index 000000000..571cf175d --- /dev/null +++ b/src/pages/SystemMessages/SystemMessages.js @@ -0,0 +1,177 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { Button } from 'react-bootstrap'; + +import PageContent from '../../components/layout/PageContent'; +import FetchManyResourceRenderer from '../../components/helpers/FetchManyResourceRenderer'; +import { fetchAllMessages, createMessage, editMessage } from '../../redux/modules/systemMessages'; +import { fetchManyStatus, readySystemMessagesSelector } from '../../redux/selectors/systemMessages'; +import Box from '../../components/widgets/Box/Box'; +import { AddIcon, EditIcon } from '../../components/icons'; +import EditSystemMessageForm from '../../components/forms/EditSystemMessageForm/EditSystemMessageForm'; +import { getLocalizedTextsInitialValues } from '../../helpers/localizedData'; +import moment from 'moment'; +import MessagesList from '../../components/SystemMessages/MessagesList/MessagesList'; +import DeleteSystemMessageButtonContainer from '../../containers/DeleteSystemMessageButtonContainer'; + +const localizedTextDefaults = { + text: '', +}; + +const messageInitialValues = () => ({ + localizedTexts: getLocalizedTextsInitialValues([], localizedTextDefaults), + groupsIds: [], + id: undefined, +}); + +const messageToForm = message => { + const processedData = Object.assign({}, message, { + visibleFrom: new Date(message.visibleFrom * 1000), + visibleTo: new Date(message.visibleTo * 1000), + }); + processedData.localizedTexts.forEach((value, index) => { + if (value.text) { + processedData.localizedTexts[index]['_enabled'] = true; + } + }); + return processedData; +}; + +const messageToSubmit = data => + Object.assign({}, data, { + visibleFrom: moment(data.visibleFrom).unix(), + visibleTo: moment(data.visibleTo).unix(), + }); + +class SystemMessages extends Component { + state = { + isOpen: false, + message: messageInitialValues(), + }; + + formReset = () => this.setState({ isOpen: false, message: messageInitialValues() }); + + static loadAsync = (params, dispatch) => Promise.all([dispatch(fetchAllMessages)]); + + componentWillMount() { + this.props.loadAsync(); + } + + render() { + const { fetchStatus, createMessage, editMessage, systemMessages } = this.props; + + return ( + } + description={ + + } + /> + } + failed={ + + } + description={ + + } + /> + }> + {() => ( + } + description={ + + } + breadcrumbs={[ + { + text: , + iconName: 'flag', + }, + ]}> + + } + unlimitedHeight + noPadding + footer={ +

    + +

    + }> + ( + + + + + )} + /> +
    + this.formReset()} + onSubmit={data => + this.state.message.id + ? editMessage(this.state.message.id, data).then(() => this.formReset()) + : createMessage(data).then(() => this.formReset()) + } + /> +
    +
    + )} +
    + ); + } +} + +SystemMessages.propTypes = { + loadAsync: PropTypes.func.isRequired, + fetchStatus: PropTypes.string, + createMessage: PropTypes.func, + editMessage: PropTypes.func, + systemMessages: PropTypes.array.isRequired, +}; + +export default connect( + state => ({ + fetchStatus: fetchManyStatus(state), + systemMessages: readySystemMessagesSelector(state), + }), + dispatch => ({ + loadAsync: () => SystemMessages.loadAsync({}, dispatch), + createMessage: data => dispatch(createMessage(messageToSubmit(data))), + editMessage: (id, data) => dispatch(editMessage(id, messageToSubmit(data))), + }) +)(SystemMessages); diff --git a/src/pages/SystemMessages/index.js b/src/pages/SystemMessages/index.js new file mode 100644 index 000000000..d246cf18f --- /dev/null +++ b/src/pages/SystemMessages/index.js @@ -0,0 +1,2 @@ +import SystemMessages from './SystemMessages'; +export default SystemMessages; diff --git a/src/pages/routes.js b/src/pages/routes.js index fdfe228dd..b3cfdb241 100644 --- a/src/pages/routes.js +++ b/src/pages/routes.js @@ -43,6 +43,7 @@ import FAQ from './FAQ'; import SubmissionFailures from './SubmissionFailures'; import SisIntegration from './SisIntegration'; import Archive from './Archive'; +import SystemMessages from './SystemMessages/SystemMessages'; import ChangePassword from './ChangePassword'; import ResetPassword from './ResetPassword'; @@ -134,6 +135,7 @@ const createRoutes = getState => { + diff --git a/src/redux/modules/systemMessages.js b/src/redux/modules/systemMessages.js new file mode 100644 index 000000000..856b9f6e9 --- /dev/null +++ b/src/redux/modules/systemMessages.js @@ -0,0 +1,25 @@ +import { handleActions } from 'redux-actions'; +import factory, { initialState } from '../helpers/resourceManager'; + +const resourceName = 'systemMessages'; +var { actions, reduceActions } = factory({ resourceName, apiEndpointFactory: id => `/notifications/${id}` }); + +/** + * Actions + */ +export const createMessage = actions.addResource; +export const editMessage = actions.updateResource; +export const deleteMessage = actions.removeResource; + +export const fetchManyEndpoint = '/notifications/all'; +export const fetchAllMessages = actions.fetchMany({ + endpoint: fetchManyEndpoint, +}); + +/** + * Reducer takes mainly care about all the state of individual attachments + */ + +const reducer = handleActions(Object.assign({}, reduceActions, {}), initialState); + +export default reducer; diff --git a/src/redux/reducer.js b/src/redux/reducer.js index f0d1d31af..1f4e38372 100644 --- a/src/redux/reducer.js +++ b/src/redux/reducer.js @@ -52,6 +52,7 @@ import upload from './modules/upload'; import users from './modules/users'; import userSwitching from './modules/userSwitching'; import broker from './modules/broker'; +import systemMessages from './modules/systemMessages'; const createRecodexReducers = (token, instanceId) => ({ app, @@ -104,6 +105,7 @@ const createRecodexReducers = (token, instanceId) => ({ userSwitching, upload, broker, + systemMessages, }); const librariesReducers = { diff --git a/src/redux/selectors/systemMessages.js b/src/redux/selectors/systemMessages.js new file mode 100644 index 000000000..112f27f02 --- /dev/null +++ b/src/redux/selectors/systemMessages.js @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import { fetchManyEndpoint } from '../modules/systemMessages'; +import { isReady, getJsData } from '../helpers/resourceManager'; + +const getSystemMessages = state => state.systemMessages; +const getResources = messages => messages.get('resources'); + +export const systemMessagesSelector = createSelector( + getSystemMessages, + getResources +); + +export const fetchManyStatus = createSelector( + getSystemMessages, + state => state.getIn(['fetchManyStatus', fetchManyEndpoint]) +); + +export const getMessage = messageId => + createSelector( + systemMessagesSelector, + messages => messages.get(messageId) + ); + +export const readySystemMessagesSelector = createSelector( + systemMessagesSelector, + messages => + messages + .toList() + .filter(isReady) + .map(getJsData) + .toArray() +);