diff --git a/hypha/apply/activity/templates/messages/email/ready_to_review.html b/hypha/apply/activity/templates/messages/email/ready_to_review.html index 4aa9a622a4af41cefb81900732d11d50af413e22..be03439ca12a14f27a1891796be44b79ecc3bed9 100644 --- a/hypha/apply/activity/templates/messages/email/ready_to_review.html +++ b/hypha/apply/activity/templates/messages/email/ready_to_review.html @@ -4,5 +4,11 @@ This application is awaiting your review. Title: {{ source.title }} +{% if related.title %} +Reminder Title: {{ related.title }} +{% endif %} +{% if related.description %} +Reminder Description: {{ related.description }} +{% endif %} Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} {% endblock %} diff --git a/hypha/apply/api/v1/reminder/serializers.py b/hypha/apply/api/v1/reminder/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..8f99cbab7ebfecd1eec7807d213e156f27f4ef07 --- /dev/null +++ b/hypha/apply/api/v1/reminder/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from hypha.apply.funds.models import Reminder + + +class SubmissionReminderSerializer(serializers.ModelSerializer): + + def validate(self, data): + """ + Check title is empty. + """ + required_fields = ['title'] + for field in required_fields: + if not data.get(field, None): + raise serializers.ValidationError({field: "shouldn't be empty"}) + return data + + class Meta: + model = Reminder + fields = ('time', 'action_type', 'is_expired', 'id', 'action', 'title', 'description') + read_only_fields = ('action_type', 'is_expired') diff --git a/hypha/apply/api/v1/reminder/views.py b/hypha/apply/api/v1/reminder/views.py new file mode 100644 index 0000000000000000000000000000000000000000..d390928ac8a68237c3a2e1b78905ccb27049e5e4 --- /dev/null +++ b/hypha/apply/api/v1/reminder/views.py @@ -0,0 +1,71 @@ +from rest_framework import mixins, permissions, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework_api_key.permissions import HasAPIKey + +from hypha.apply.funds.models import Reminder + +from ..mixin import SubmissionNestedMixin +from ..permissions import IsApplyStaffUser +from .serializers import SubmissionReminderSerializer + + +class SubmissionReminderViewSet( + SubmissionNestedMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + permission_classes = ( + HasAPIKey | permissions.IsAuthenticated, HasAPIKey | IsApplyStaffUser, + ) + serializer_class = SubmissionReminderSerializer + pagination_class = None + + def get_queryset(self): + submission = self.get_submission_object() + return Reminder.objects.filter(submission=submission).order_by('-time') + + def perform_create(self, serializer): + serializer.save(user=self.request.user, submission=self.get_submission_object()) + + def destroy(self, request, *args, **kwargs): + reminder = self.get_object() + reminder.delete() + ser = self.get_serializer(self.get_queryset(), many=True) + return Response(ser.data) + + @action(detail=False, methods=['get']) + def fields(self, request, *args, **kwargs): + """ + List details of all the form fields that were created by admin for adding reminders. + + These field details will be used in frontend to render the reminder form. + """ + fields = [ + { + "id": "title", + "kwargs": {"required": True, "label": "Title", "max_length": 60}, + "type": "TextInput", + }, + { + "id": "description", + "type": "textArea", + "kwargs": {"label": "Description"}, + "widget": { + "attrs": {"cols": 40, "rows": 5}, + "type": "Textarea" + } + }, + { + "id": "time", + "kwargs": {"label": "Time", "required": True}, + "type": "DateTime" + }, + { + "id": "action", + "kwargs": {"label": "Action", "required": True, "choices": getattr(Reminder, 'ACTIONS').items(), "initial": getattr(Reminder, 'REVIEW')}, + "type": "Select" + } + ] + return Response(fields) diff --git a/hypha/apply/api/v1/urls.py b/hypha/apply/api/v1/urls.py index 6d990a5626facf1ddccaa975acdf3cc6badaaebd..8205840cdfcb4c77f8b3ac7026f844c265aad041 100644 --- a/hypha/apply/api/v1/urls.py +++ b/hypha/apply/api/v1/urls.py @@ -2,6 +2,7 @@ from django.urls import path from rest_framework_nested import routers from hypha.apply.api.v1.determination.views import SubmissionDeterminationViewSet +from hypha.apply.api.v1.reminder.views import SubmissionReminderViewSet from hypha.apply.api.v1.review.views import SubmissionReviewViewSet from hypha.apply.api.v1.screening.views import ( ScreeningStatusViewSet, @@ -33,6 +34,7 @@ submission_router.register(r'comments', SubmissionCommentViewSet, basename='subm submission_router.register(r'reviews', SubmissionReviewViewSet, basename='reviews') submission_router.register(r'determinations', SubmissionDeterminationViewSet, basename='determinations') submission_router.register(r'screening_statuses', SubmissionScreeningStatusViewSet, basename='submission-screening_statuses') +submission_router.register(r'reminders', SubmissionReminderViewSet, basename='submission-reminder') urlpatterns = [ path('user/', CurrentUser.as_view(), name='user'), diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index 3714761dcd8a14f656658bfe7de61ba58b7f9eb8..ccef66b2780b12e74e23b8428c3eb552a409ae84 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -453,7 +453,7 @@ class CreateReminderForm(forms.ModelForm): queryset=ApplicationSubmission.objects.filter(), widget=forms.HiddenInput(), ) - time = forms.DateTimeField() + time = forms.DateField() def __init__(self, instance=None, user=None, *args, **kwargs): super().__init__(*args, **kwargs) @@ -464,10 +464,12 @@ class CreateReminderForm(forms.ModelForm): def save(self, *args, **kwargs): return Reminder.objects.create( + title=self.cleaned_data['title'], + description=self.cleaned_data['description'], time=self.cleaned_data['time'], submission=self.cleaned_data['submission'], user=self.user) class Meta: model = Reminder - fields = ['time', 'action'] + fields = ['title', 'description', 'time', 'action'] diff --git a/hypha/apply/funds/migrations/0088_auto_20210423_1257.py b/hypha/apply/funds/migrations/0088_auto_20210423_1257.py new file mode 100644 index 0000000000000000000000000000000000000000..d3e96f8dc868a5464510fc1e6569c734cf928ec2 --- /dev/null +++ b/hypha/apply/funds/migrations/0088_auto_20210423_1257.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.19 on 2021-04-23 12:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0087_applicationsettings'), + ] + + operations = [ + migrations.AddField( + model_name='reminder', + name='description', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='reminder', + name='title', + field=models.CharField(default='', max_length=60), + ), + ] diff --git a/hypha/apply/funds/models/reminders.py b/hypha/apply/funds/models/reminders.py index 14ff327fe9bc7e5de52c1fcb0fb7e07a1e1a4496..0fc1515ecb79d4a0a2471880037b6b490c0bac4d 100644 --- a/hypha/apply/funds/models/reminders.py +++ b/hypha/apply/funds/models/reminders.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone @@ -33,6 +34,8 @@ class Reminder(models.Model): max_length=50, ) sent = models.BooleanField(default=False) + title = models.CharField(max_length=60, blank=False, default='') + description = models.TextField(blank=True) def __str__(self): return '{} at {}'.format( @@ -43,6 +46,10 @@ class Reminder(models.Model): class Meta: ordering = ['-time'] + def clean(self): + if self.title == '': + raise ValidationError('Title is Empty') + @property def is_expired(self): return timezone.now() > self.time @@ -51,6 +58,10 @@ class Reminder(models.Model): def action_message(self): return self.ACTION_MESSAGE[f'{self.action}-{self.medium}'] + @property + def action_type(self): + return self.ACTIONS[self.action] + @property def medium(self): return self.MEDIUM[self.action] diff --git a/hypha/apply/funds/templates/funds/includes/create_reminder_form.html b/hypha/apply/funds/templates/funds/includes/create_reminder_form.html index 6e74066c408cce5cb2c58473f677bb9762c2d7f7..00fbb7381ce5ff1d83b36d8856e177469f5dd234 100644 --- a/hypha/apply/funds/templates/funds/includes/create_reminder_form.html +++ b/hypha/apply/funds/templates/funds/includes/create_reminder_form.html @@ -1,4 +1,4 @@ <div class="modal" id="create-reminder"> <h4 class="modal__header-bar">Create Reminder</h4> - {% include 'funds/includes/delegated_form_base.html' with form=reminder_form value='Create' %} + {% include 'funds/includes/delegated_form_base.html' with form=reminder_form value='Create' extra_classes='form__reminder'%} </div> diff --git a/hypha/apply/funds/templates/funds/includes/reminders_block.html b/hypha/apply/funds/templates/funds/includes/reminders_block.html index 2cb7144206ea96467671c76a763b99aacd9c46e3..01ce92acd40f2da089b3d5c85e5a4ce62e70e8b5 100644 --- a/hypha/apply/funds/templates/funds/includes/reminders_block.html +++ b/hypha/apply/funds/templates/funds/includes/reminders_block.html @@ -6,9 +6,15 @@ <li><strong>{{ action.grouper }}</strong> <ul> {% for reminder in action.list %} - <li class="{% if reminder.is_expired %}expired-reminder{% endif %}"> - {{ reminder.time|date:"SHORT_DATETIME_FORMAT" }} - <a class="link" href="{% url 'funds:submissions:reminders:delete' object.id reminder.id %}"> + <li class="{% if reminder.is_expired %}expired-reminder{% endif %} reminder-list"> + <div class="reminder-title"> + {% if reminder.title %} + {{ reminder.title }} + {% else %} + untitled reminder + {% endif %} + </div> + <a class="link reminder-delete" href="{% url 'funds:submissions:reminders:delete' object.id reminder.id %}"> <svg class="icon icon--delete"><use xlink:href="#delete"></use></svg> </a> </li> diff --git a/hypha/static_src/src/app/src/common/components/DateTime/index.js b/hypha/static_src/src/app/src/common/components/DateTime/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e2fbd7e714e81da235a1ee9b117c67427d9b4dec --- /dev/null +++ b/hypha/static_src/src/app/src/common/components/DateTime/index.js @@ -0,0 +1,51 @@ +import * as React from "react"; +import PropTypes from 'prop-types'; +import HelperComponent from "@common/components/HelperComponent"; +import { withStyles } from '@material-ui/core/styles'; +import { DatePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; +import DateFnsUtils from '@date-io/date-fns'; + +const styles = { + textField : { + width: '385px' + } +} + +const DateTime = props => { + const [selectedDate, handleDateChange] = React.useState(new Date()); + + return <div className="form__group form__group--datetime form__group--wrap"> + <label + htmlFor={props.name} + className="form__question form__question--boolean_field datetime_input"> + <span>{props.label}</span> + {props.required ? <span className="form__required"> *</span> : ""} + </label> + <div className="form__item"> + <MuiPickersUtilsProvider utils={DateFnsUtils}> + <DatePicker + className={props.classes.textField} + onChange={e => {handleDateChange(e);props.onChange(props.name, new Date(e).toISOString())}} + name={props.name} + id={props.name} + value={selectedDate} + format={'Y-MM-d'} + /> + </MuiPickersUtilsProvider> + </div> + <HelperComponent {...props.helperProps} /> + </div> +} + +DateTime.propTypes = { + name: PropTypes.string, + label: PropTypes.string, + required: PropTypes.bool, + onChange: PropTypes.func, + value: PropTypes.node, + helperProps: PropTypes.object, + classes: PropTypes.object +} + +DateTime.displayName = 'DateTime'; +export default withStyles(styles)(DateTime); diff --git a/hypha/static_src/src/app/src/common/components/TextBox/index.js b/hypha/static_src/src/app/src/common/components/TextBox/index.js index 2f60ea0f69c1b4c9736c5f997fb662b60a47873e..53905e54caca499b0bdd56afea248b6d9ecca6bb 100644 --- a/hypha/static_src/src/app/src/common/components/TextBox/index.js +++ b/hypha/static_src/src/app/src/common/components/TextBox/index.js @@ -19,6 +19,7 @@ const TextBox = props => { value={props.value ? props.value : ""} onChange={e => props.onChange(props.name, e.currentTarget.value)} id={props.id} + maxLength={props.maxLength} /> </div> </div> @@ -32,6 +33,7 @@ TextBox.propTypes = { value: PropTypes.node, helperProps: PropTypes.object, name: PropTypes.string, + maxLength: PropTypes.number } TextBox.displayName = 'TextBox'; diff --git a/hypha/static_src/src/app/src/common/components/Textarea/__tests__/__snapshots__/index.test.js.snap b/hypha/static_src/src/app/src/common/components/Textarea/__tests__/__snapshots__/index.test.js.snap index 326879d054599bbd3c4acf0221479533f3dbb9ff..14041360ca620949e7820e2457f733348cf8b765 100644 --- a/hypha/static_src/src/app/src/common/components/Textarea/__tests__/__snapshots__/index.test.js.snap +++ b/hypha/static_src/src/app/src/common/components/Textarea/__tests__/__snapshots__/index.test.js.snap @@ -77,6 +77,11 @@ exports[`Test textarea component render a TextArea component 1`] = ` id="test name" onChange={[Function]} rows="c" + style={ + Object { + "maxWidth": "90%", + } + } value="1" /> </div> @@ -158,6 +163,11 @@ exports[`Test textarea component with required render a TextArea component with id="test name" onChange={[Function]} rows="c" + style={ + Object { + "maxWidth": "90%", + } + } value="1" /> </div> diff --git a/hypha/static_src/src/app/src/common/components/Textarea/index.js b/hypha/static_src/src/app/src/common/components/Textarea/index.js index de5447c60a0e671f9e7ee2927a17ed4584d34ec9..d39120025a3beba136791f3871ec950e582fe748 100644 --- a/hypha/static_src/src/app/src/common/components/Textarea/index.js +++ b/hypha/static_src/src/app/src/common/components/Textarea/index.js @@ -14,6 +14,7 @@ const Textarea = props => { <HelperComponent {...props.helperProps}/> <div className="form__item"> <textarea + style={{maxWidth: '90%'}} cols={props.widget.attrs.cols} rows={props.widget.attrs.rows} id={props.name} diff --git a/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/__tests__/__snapshots__/index.test.js.snap b/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/__tests__/__snapshots__/index.test.js.snap index fb1da976ef002b2e98799b6a17c430d2489e01b7..531d7bb5fd1ea9012ef03e2cc9596c98b3c0467f 100644 --- a/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/__tests__/__snapshots__/index.test.js.snap +++ b/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/__tests__/__snapshots__/index.test.js.snap @@ -893,6 +893,11 @@ exports[`Test form field component should render Textarea render a form field co cols={1} onChange={[Function]} rows={1} + style={ + Object { + "maxWidth": "90%", + } + } value="value" /> </div> diff --git a/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/index.js b/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/index.js index 7bb7cd1520fe4991f628a0e14c5b5e0896f0d827..c6ec9a1faae252756f5d1cab7ea034136460d5de 100644 --- a/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/index.js +++ b/hypha/static_src/src/app/src/common/containers/FormContainer/components/FormField/index.js @@ -8,6 +8,7 @@ import ScoredAnswerWidget from "@common/components/ScoredAnswerWidget"; import LoadHTML from "@common/components/LoadHTML"; import Textarea from "@common/components/Textarea"; import CheckBox from "@common/components/CheckBox"; +import DateTime from "@common/components/DateTime"; import PageDownWidget from "@common/components/PageDownWidget"; import PropTypes from 'prop-types'; @@ -55,6 +56,7 @@ class FormField extends React.Component { onChange={this.onChange} id={fieldProps.name} helperProps={this.getHelperprops()} + maxLength={kwargs.max_length} />; case "TinyMCE": @@ -153,6 +155,17 @@ class FormField extends React.Component { helperProps={this.getHelperprops()} /> + case "DateTime": + return <DateTime + value={value} + help_text={kwargs.help_text} + label={kwargs.label} + name={fieldProps.name} + onChange={this.onChange} + required={kwargs.required} + helperProps={this.getHelperprops()} + /> + default: return <div>Unknown field type {this.getType()}</div> } diff --git a/hypha/static_src/src/app/src/common/containers/FormContainer/index.js b/hypha/static_src/src/app/src/common/containers/FormContainer/index.js index 671d9a0af589d3a46a8354eea34497243bbd9313..db05d897ff84fd4aae178e2b6e2106590c909866 100644 --- a/hypha/static_src/src/app/src/common/containers/FormContainer/index.js +++ b/hypha/static_src/src/app/src/common/containers/FormContainer/index.js @@ -64,7 +64,7 @@ class FormContainer extends React.Component { const formFields = this.props.metadata.fields; const actions = this.props.metadata.actions; const {errors, values} = this.formInfo; - return <div> + return <div style={this.props.metadata.style}> {this.props.metadata.title && <h3> {this.props.metadata.title} </h3>} <div style={{ width: `${this.props.metadata.width}px` }}> <form className="form form--with-p-tags form--scoreable"> diff --git a/hypha/static_src/src/app/src/containers/DisplayPanel/index.js b/hypha/static_src/src/app/src/containers/DisplayPanel/index.js index 0be0beb41776cc1d40eee6f1dbd49978c60c2b5b..ce27751756925ccc777bb078db088bf52a3d9b68 100644 --- a/hypha/static_src/src/app/src/containers/DisplayPanel/index.js +++ b/hypha/static_src/src/app/src/containers/DisplayPanel/index.js @@ -28,6 +28,7 @@ import ReviewFormContainer from '@containers/ReviewForm'; import Determination from '../Determination'; import DeterminationFormContainer from '@containers/DeterminationForm' import FlagContainer from '@containers/FlagContainer' +import ReminderContainer from '@containers/ReminderContainer' import ResizablePanels from '@components/ResizablePanels' import ScreeningStatusContainer from '@containers/ScreeningStatus'; @@ -79,6 +80,7 @@ const DisplayPanel = props => { {/* <ScreeningOutcome submissionID={submissionID} /> */} <StatusActions submissionID={submissionID} /> <ScreeningStatusContainer submissionID={submissionID} /> + <ReminderContainer submissionID={submissionID}/> <UserFlagContainer /> <StaffFlagContainer /> <ReviewInformation submissionID={submissionID} /> diff --git a/hypha/static_src/src/app/src/containers/FlagContainer/style.scss b/hypha/static_src/src/app/src/containers/FlagContainer/style.scss index 5b966f226783c79423c8479e16be12dfa53746bc..3a407e331cd32bd82d2e2e0ca85e393bc9eb7b80 100644 --- a/hypha/static_src/src/app/src/containers/FlagContainer/style.scss +++ b/hypha/static_src/src/app/src/containers/FlagContainer/style.scss @@ -5,4 +5,5 @@ .flag-button{ line-height: normal; height: 40px; + padding: 10px; } diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/actions.js b/hypha/static_src/src/app/src/containers/ReminderContainer/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..298192e1afd1d22553283d82ba53d757d8917568 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/actions.js @@ -0,0 +1,25 @@ +import * as ActionTypes from './constants'; + +export const initializeAction = (submissionID) => ({ + type: ActionTypes.INITIALIZE, + submissionID +}); + +export const deleteReminderAction = (submissionID, reminderID) => ({ + type: ActionTypes.DELETE_REMINDER, + submissionID, + reminderID +}); + +export const getRemindersSuccessAction = (data) => ({ + type: ActionTypes.GET_REMINDERS_SUCCESS, + data +}); + +export const showLoadingAction = () => ({ + type: ActionTypes.SHOW_LOADING, +}) + +export const hideLoadingAction = () => ({ + type: ActionTypes.HIDE_LOADING, +}) diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/components/ReminderList.js b/hypha/static_src/src/app/src/containers/ReminderContainer/components/ReminderList.js new file mode 100644 index 0000000000000000000000000000000000000000..70c1a5ef5c43a661d62f087eacc9589a6a15c188 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/components/ReminderList.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import DeleteIcon from '@material-ui/icons/Delete'; +import Tooltip from '@material-ui/core/Tooltip'; +import './style.scss' + +class ReminderList extends React.PureComponent { + + render() { + return (<ul> + <li> + <strong>{this.props.title}</strong> + <ul> + {this.props.reminders.map(reminder => { + return <li style={{color : reminder.is_expired ? 'grey' : 'black'}} className="list-item" key={reminder.id}> + <div className="title-text">{reminder.title ? reminder.title : "untitled reminder"}</div> + <Tooltip title={<span style={{fontSize: '14px'}}>Delete</span>} placement="right-start"> + <DeleteIcon + className="delete-icon" + fontSize="small" + onClick={() => this.props.deleteReminder(this.props.submissionID, reminder.id)} + /> + </Tooltip> + </li> + })} + </ul> + </li> + </ul>) + } +} + +ReminderList.propTypes = { + reminders: PropTypes.array, + deleteReminder: PropTypes.func, + submissionID: PropTypes.number, + title: PropTypes.string +} + +export default ReminderList; diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/components/style.scss b/hypha/static_src/src/app/src/containers/ReminderContainer/components/style.scss new file mode 100644 index 0000000000000000000000000000000000000000..c8361e51b8b4ea4ecc5f424707bcf1a3e4c04a62 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/components/style.scss @@ -0,0 +1,20 @@ +.title-text { + width: 80%; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.delete-icon { + color : red; + cursor: pointer; + float: right; +} + +.list-item { + padding: 3px 3px 0px 3px; + &:hover { + background-color: #d4d4d4; + } +} diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/constants.js b/hypha/static_src/src/app/src/containers/ReminderContainer/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..3ccdc327a8551e44fa8872d9e998cbf236b84b18 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/constants.js @@ -0,0 +1,6 @@ + +export const INITIALIZE = 'ReminderContainer/constants/INITIALIZE'; +export const GET_REMINDERS_SUCCESS = 'ReminderContainer/constants/GET_REMINDERS_SUCCESS'; +export const SHOW_LOADING = 'ReminderContainer/constants/SHOW_LOADING' +export const HIDE_LOADING = 'ReminderContainer/constants/HIDE_LOADING' +export const DELETE_REMINDER = 'ReminderContainer/constants/DELETE_REMINDER'; diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/actions.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/actions.js new file mode 100644 index 0000000000000000000000000000000000000000..c266387b5c3e66c84477330877c1165448bc05ca --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/actions.js @@ -0,0 +1,26 @@ +import * as ActionTypes from './constants'; + + +export const fetchFieldsSuccessAction = (fields) => ({ + type: ActionTypes.FETCH_FIELDS_SUCCESS, + fields + }) + +export const fetchFieldsAction = (submissionID) => ({ + type: ActionTypes.FETCH_FIELDS, + submissionID +}) + +export const createReminderAction = (values, submissionID) => ({ + type: ActionTypes.CREATE_REMINDER, + values, + submissionID +}) + +export const showLoadingAction = () => ({ + type: ActionTypes.SHOW_LOADING, +}) + +export const hideLoadingAction = () => ({ + type: ActionTypes.HIDE_LOADING, +}) diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/constants.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..5de1e9b32d36b073ed7d7a9bf1f8474172fa378c --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/constants.js @@ -0,0 +1,5 @@ +export const FETCH_FIELDS = 'ReminderForm/constants/FETCH_FIELDS' +export const FETCH_FIELDS_SUCCESS = 'ReminderForm/constants/FETCH_FIELDS_SUCCESS'; +export const CREATE_REMINDER = 'ReminderForm/constants/CREATE_REMINDER' +export const SHOW_LOADING = 'ReminderForm/constants/SHOW_LOADING' +export const HIDE_LOADING = 'ReminderForm/constants/HIDE_LOADING' diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/index.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/index.js new file mode 100644 index 0000000000000000000000000000000000000000..4ca944b19b1b807366693cc9ae8c507b0070799c --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/index.js @@ -0,0 +1,94 @@ +import React from 'react' +import injectReducer from '@utils/injectReducer' +import injectSaga from '@utils/injectSaga' +import { withRouter } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { bindActionCreators, compose } from 'redux'; +import PropTypes from 'prop-types'; +import * as Actions from './actions'; +import reducer from './reducer'; +import saga from './sagas'; +import * as Selectors from './selectors'; +import FormContainer from '@common/containers/FormContainer'; +import './styles.scss'; +import LoadingPanel from '@components/LoadingPanel' + + +class ReminderForm extends React.PureComponent { + + componentDidMount(){ + this.props.fetchFieldsAction(this.props.submissionID) + } + + getMetaFields(){ + let metaFieldsActions = [{ + text: "Cancel", + type: "secondary", + callback: () => this.props.closeForm() + }, + { + text: "Create", + type: "primary", + callback: (values) => {this.props.createReminderAction(values, this.props.submissionID); this.props.closeForm()} + } + + ]; + + return { + fields: this.props.reminderForm.metaStructure, + actions: metaFieldsActions, + initialValues: null, + title: "Create Reminder", + style: {paddingLeft : '30px'} + } + } + + render(){ + if(this.props.reminderForm.loading) return <LoadingPanel /> + return ( + <div className="reminder-form"> + {this.props.reminderForm.metaStructure && this.props.reminderForm.metaStructure.length != 0 && + <FormContainer metadata={this.getMetaFields()} formId={"ReminderForm"} /> + } + </div> + ) + } +} + +ReminderForm.propTypes = { + fetchFieldsAction: PropTypes.func, + submissionID: PropTypes.number, + closeForm: PropTypes.func, + reminderForm: PropTypes.object, + createReminderAction: PropTypes.func +} + +const mapStateToProps = state => ({ + reminderForm : Selectors.selectReminderForm(state) +}); + + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + fetchFieldsAction: Actions.fetchFieldsAction, + createReminderAction: Actions.createReminderAction, + }, + dispatch, +); +} + +const withConnect = connect( + mapStateToProps, + mapDispatchToProps, +); + +const withReducer = injectReducer({ key: 'ReminderForm', reducer }); +const withSaga = injectSaga({ key: 'ReminderForm', saga }); + + +export default compose( + withSaga, + withReducer, + withConnect, + withRouter, +)(ReminderForm); diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/models.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/models.js new file mode 100644 index 0000000000000000000000000000000000000000..838c118020a9462cb653883539a555b9929aa472 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/models.js @@ -0,0 +1,8 @@ +import * as Immutable from 'seamless-immutable'; + +const initialState = Immutable.from({ + metaStructure: null, + loading: false +}); + +export default initialState; diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/reducer.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/reducer.js new file mode 100644 index 0000000000000000000000000000000000000000..3f08a84619e093b8bd91de987c4bfe8382350efc --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/reducer.js @@ -0,0 +1,18 @@ +import * as ActionTypes from './constants'; +import initialState from './models'; + +/* eslint-disable default-case, no-param-reassign */ +const reminderFormReducer = (state = initialState, action) => { + switch (action.type) { + case ActionTypes.FETCH_FIELDS_SUCCESS: + return state.set('metaStructure', action.fields) + case ActionTypes.SHOW_LOADING: + return state.set("loading", true); + case ActionTypes.HIDE_LOADING: + return state.set("loading", false); + default: + return state; + } +}; + +export default reminderFormReducer; diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/sagas.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/sagas.js new file mode 100644 index 0000000000000000000000000000000000000000..b3a0d0195eced3fcff63a45396fb57072b529ac8 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/sagas.js @@ -0,0 +1,51 @@ +import { + call, + put, + takeLatest, + } from 'redux-saga/effects'; + import * as ActionTypes from './constants'; + import * as Actions from './actions'; + import { apiFetch } from '@api/utils'; + import { initializeAction } from '../../actions' + + +export function* fetchFields(action) { + try { + yield put(Actions.showLoadingAction()) + let response = yield call(apiFetch, {path : `/v1/submissions/${action.submissionID}/reminders/fields/`}); + let data = yield response.json() + yield put( + Actions.fetchFieldsSuccessAction(data), + ); + yield put(Actions.hideLoadingAction()) + } catch (e) { + yield put(Actions.hideLoadingAction()) + } +} + +export function* createReminder(action) { + try { + yield put(Actions.showLoadingAction()) + yield call( + apiFetch, + { + path : `/v1/submissions/${action.submissionID}/reminders/`, + method : "POST", + options : { + body : JSON.stringify(action.values), + } + } + ) + yield put( + initializeAction(action.submissionID), + ); + yield put(Actions.hideLoadingAction()) + } catch (e) { + yield put(Actions.hideLoadingAction()) + } +} + +export default function* reminderFormSaga() { + yield takeLatest(ActionTypes.FETCH_FIELDS, fetchFields) + yield takeLatest(ActionTypes.CREATE_REMINDER, createReminder) +} diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/selectors.js b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/selectors.js new file mode 100644 index 0000000000000000000000000000000000000000..d2811b3c664b59985639318bc18a56c2f71d87d3 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/selectors.js @@ -0,0 +1,7 @@ +import { createSelector } from 'reselect'; +import initialState from './models'; + +export const selectFieldsRenderer = state => + state.ReminderForm ? state.ReminderForm : initialState; + +export const selectReminderForm = createSelector(selectFieldsRenderer, domain => domain); diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/styles.scss b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..3eb370138b0943640a5b70250ba065f48fea0a11 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/containers/ReminderForm/styles.scss @@ -0,0 +1,4 @@ +.reminder-form { + background-color : white; + padding: 25px; +} diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/index.js b/hypha/static_src/src/app/src/containers/ReminderContainer/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9dafb37d74973752b8802f915c225cf7a907c0dd --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/index.js @@ -0,0 +1,127 @@ +import React from 'react' +import injectReducer from '@utils/injectReducer' +import injectSaga from '@utils/injectSaga' +import { withRouter } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { bindActionCreators, compose } from 'redux'; +import PropTypes from 'prop-types'; +import * as Actions from './actions'; +import reducer from './reducer'; +import saga from './sagas'; +import * as Selectors from './selectors'; +import "./styles.scss"; +import { SidebarBlock } from '@components/SidebarBlock' +import LoadingPanel from '@components/LoadingPanel'; +import Modal from '@material-ui/core/Modal'; +import { withStyles } from '@material-ui/core/styles'; +import ReminderList from './components/ReminderList'; +import ReminderForm from './containers/ReminderForm' + + +const styles = { + modal: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + }; + +class ReminderContainer extends React.PureComponent { + + state = { + open : false + } + + componentDidUpdate(prevProps) { + if(this.props.submissionID != prevProps.submissionID) { + this.props.initAction(this.props.submissionID) + } + } + + handleModalClose = () => { + this.setState({open : false}) + } + + render(){ + const { classes } = this.props; + if(this.props.reminderInfo.loading) return <LoadingPanel /> + return ( + <div className="reminder-container"> + <SidebarBlock title={"Reminders"}> + <div className="status-actions"> + <button + className="button button--primary button--half-width button--bottom-space reminder-button" + onClick={() => this.setState({open : true})} + > + Create Reminder + </button> + <Modal + className={classes.modal} + open={this.state.open} + > + <> + <ReminderForm + submissionID={this.props.submissionID} + closeForm={() => this.setState({open: false})} + /> + </> + </Modal> + {this.props.reminders.length + ? + this.props.reminders.map(reminders => + <ReminderList + key={reminders.grouper} + title={reminders.grouper} + reminders={reminders.list} + submissionID={this.props.submissionID} + deleteReminder={this.props.deleteReminder} + />) + : + <div>No reminders yet.</div>} + </div> + </SidebarBlock> + </div> + ) + } +} + +ReminderContainer.propTypes = { + reminderInfo: PropTypes.object, + initAction: PropTypes.func, + deleteReminder: PropTypes.func, + classes: PropTypes.object, + submissionID: PropTypes.number, + reminders: PropTypes.array +} + +const mapStateToProps = state => ({ + reminderInfo : Selectors.selectReminderContainer(state), + reminders: Selectors.selectReminders(state) +}); + + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + initAction: Actions.initializeAction, + deleteReminder: Actions.deleteReminderAction, + }, + dispatch, + ); +} + +const withConnect = connect( + mapStateToProps, + mapDispatchToProps, +); + +const withReducer = injectReducer({ key: 'ReminderContainer', reducer }); +const withSaga = injectSaga({ key: 'ReminderContainer', saga }); + + +export default compose( + withSaga, + withReducer, + withConnect, + withRouter, + withStyles(styles) +)(ReminderContainer); diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/models.js b/hypha/static_src/src/app/src/containers/ReminderContainer/models.js new file mode 100644 index 0000000000000000000000000000000000000000..598f19047c79df0e7bbe11b89013b9c9d37937dd --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/models.js @@ -0,0 +1,8 @@ +import * as Immutable from 'seamless-immutable'; + +const initialState = Immutable.from({ + loading : false, + reminders: null, +}); + +export default initialState; diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/reducer.js b/hypha/static_src/src/app/src/containers/ReminderContainer/reducer.js new file mode 100644 index 0000000000000000000000000000000000000000..b5656924ede5ec77bc72aca679ce75b3bc717eea --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/reducer.js @@ -0,0 +1,18 @@ +import * as ActionTypes from './constants'; +import initialState from './models'; + +/* eslint-disable default-case, no-param-reassign */ +const reminderContainerReducer = (state = initialState, action) => { + switch (action.type) { + case ActionTypes.GET_REMINDERS_SUCCESS: + return state.set("reminders", action.data); + case ActionTypes.SHOW_LOADING: + return state.set("loading", true); + case ActionTypes.HIDE_LOADING: + return state.set("loading", false); + default: + return state; + } +}; + +export default reminderContainerReducer; diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/sagas.js b/hypha/static_src/src/app/src/containers/ReminderContainer/sagas.js new file mode 100644 index 0000000000000000000000000000000000000000..f0bb90f38ecc1f02306320704e9aba90c7650466 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/sagas.js @@ -0,0 +1,47 @@ +import { + call, + put, + takeLatest, +} from 'redux-saga/effects'; +import * as ActionTypes from './constants'; +import * as Actions from './actions'; +import { apiFetch } from '@api/utils' + +export function* remindersFetch(action) { + + try { + yield put(Actions.showLoadingAction()) + let response = yield call(apiFetch, {path : `/v1/submissions/${action.submissionID}/reminders/`}); + let data = yield response.json() + yield put( + Actions.getRemindersSuccessAction(data), + ); + yield put(Actions.hideLoadingAction()) + } catch (e) { + yield put(Actions.hideLoadingAction()) + } +} + + +export function* deleteReminder(action) { + try { + yield put(Actions.showLoadingAction()) + let response = yield call(apiFetch, + { + path : `/v1/submissions/${action.submissionID}/reminders/${action.reminderID}/`, + method : "DELETE" + }) + let data = yield response.json() + yield put( + Actions.getRemindersSuccessAction(data), + ); + yield put(Actions.hideLoadingAction()) + }catch (e) { + yield put(Actions.hideLoadingAction()) + } +} + +export default function* reminderContainerSaga() { + yield takeLatest(ActionTypes.INITIALIZE, remindersFetch); + yield takeLatest(ActionTypes.DELETE_REMINDER, deleteReminder) +} diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/selectors.js b/hypha/static_src/src/app/src/containers/ReminderContainer/selectors.js new file mode 100644 index 0000000000000000000000000000000000000000..29112453b061d3f2db0c78a3f1339c5c7890c344 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/selectors.js @@ -0,0 +1,21 @@ +import { createSelector } from 'reselect'; +import initialState from './models'; + +export const selectFieldsRenderer = state => + state.ReminderContainer ? state.ReminderContainer : initialState; + +export const selectReminderContainer = createSelector(selectFieldsRenderer, domain => domain); + +export const selectReminders = createSelector(selectReminderContainer, domain => { + let reminders = [] + domain.reminders && domain.reminders.map(reminder => { + const existingReminderIndex = reminders.findIndex(r => r.grouper == reminder.action_type); + if (existingReminderIndex != -1) { + reminders[existingReminderIndex].list.push(reminder) + } else { + // new reminder. + reminders.push({ grouper: reminder.action_type, list: [reminder] }) + } + }) + return reminders +}) diff --git a/hypha/static_src/src/app/src/containers/ReminderContainer/styles.scss b/hypha/static_src/src/app/src/containers/ReminderContainer/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..10bad8dea055afe393c98cd41fe2afb22bb52274 --- /dev/null +++ b/hypha/static_src/src/app/src/containers/ReminderContainer/styles.scss @@ -0,0 +1,3 @@ +.reminder-button { + padding: 10px +} diff --git a/hypha/static_src/src/sass/apply/components/_reminder-sidebar.scss b/hypha/static_src/src/sass/apply/components/_reminder-sidebar.scss index 520156dbe9d9781b4ce5422e22eaeb26cfd82b87..cf0dea315a030359bf359eade68b09726b3d669e 100644 --- a/hypha/static_src/src/sass/apply/components/_reminder-sidebar.scss +++ b/hypha/static_src/src/sass/apply/components/_reminder-sidebar.scss @@ -1,3 +1,23 @@ .expired-reminder { color: $color--mid-dark-grey; } + +.reminder-list { + padding: 3px 3px 0; +} + +.reminder-title { + width: 80%; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.reminder-delete { + float: right; +} + +.form__reminder { + padding-left: 70px; +} diff --git a/package-lock.json b/package-lock.json index 39470bc04d0213754124b7af9399360bec9be7f4..d708402e6692edd263fa42b3b414005c76414069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1025,6 +1025,19 @@ "to-fast-properties": "^2.0.0" } }, + "@date-io/core": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz", + "integrity": "sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA==" + }, + "@date-io/date-fns": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.13.tgz", + "integrity": "sha512-yXxGzcRUPcogiMj58wVgFjc9qUYrCnnU9eLcyNbsQCmae4jPuZCDoIBR21j8ZURsM7GRtU62VOw5yNd4dDHunA==", + "requires": { + "@date-io/core": "^1.3.13" + } + }, "@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1138,6 +1151,19 @@ } } }, + "@material-ui/pickers": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.10.tgz", + "integrity": "sha512-hS4pxwn1ZGXVkmgD4tpFpaumUaAg2ZzbTrxltfC5yPw4BJV+mGkfnQOB4VpWEYZw2jv65Z0wLwDE/piQiPPZ3w==", + "requires": { + "@babel/runtime": "^7.6.0", + "@date-io/core": "1.x", + "@types/styled-jsx": "^2.2.8", + "clsx": "^1.0.2", + "react-transition-group": "^4.0.0", + "rifm": "^0.7.0" + } + }, "@material-ui/styles": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.1.tgz", @@ -1466,6 +1492,14 @@ "@types/react": "*" } }, + "@types/styled-jsx": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.8.tgz", + "integrity": "sha512-Yjye9VwMdYeXfS71ihueWRSxrruuXTwKCbzue4+5b2rjnQ//AtyM7myZ1BEhNhBQ/nL/RE7bdToUoLln2miKvg==", + "requires": { + "@types/react": "*" + } + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -4071,6 +4105,11 @@ } } }, + "date-fns": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.20.3.tgz", + "integrity": "sha512-BbiJSlfmr1Fnfi1OHY8arklKdwtZ9n3NkjCeK8G9gtEe0ZSUwJuwHc6gYBl0uoC0Oa5RdpJV1gBBdXcZi8Efdw==" + }, "debug": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", @@ -11987,6 +12026,14 @@ "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", "dev": true }, + "rifm": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz", + "integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==", + "requires": { + "@babel/runtime": "^7.3.1" + } + }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", diff --git a/package.json b/package.json index e0cddfbf11e2f276add0be2e896dcf289f1059cf..58b0f25b9708bf57ed23c4777adfe23e6a4c574f 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,17 @@ "last 2 versions" ], "dependencies": { + "@date-io/date-fns": "^1.3.13", "@material-ui/core": "^4.11.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", + "@material-ui/pickers": "^3.3.10", "@rooks/use-interval": "^3.0.1", "@svgr/webpack": "^4.2.0", "@tinymce/tinymce-react": "^3.6.0", "connected-react-router": "^6.4.0", "core-js": "^3.6.5", + "date-fns": "^2.20.3", "del": "^4.1.1", "detect-node": "^2.0.4", "gulp": "^4.0.2",