diff --git a/gulpfile.js b/gulpfile.js index c25abcae7119a05d57cf7b17697f43ccd392ed72..5164b87387ebb51afe39cf3dffd7f0f5d733fbf2 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -235,10 +235,13 @@ gulp.task('watch:static', function watch () { gulp.task('watch:app', function watch (callback) { var webpackOptions = webpackDev(); - webpackOptions.entry.unshift( - `webpack-dev-server/client?http://localhost:${webpackOptions.devServer.port}/`, - `webpack/hot/dev-server` - ); + webpackOptions.entry = Object.keys(webpackOptions.entry).reduce((acc, key) => { + acc[key] = [ + `webpack-dev-server/client?http://localhost:${webpackOptions.devServer.port}/`, + 'webpack/hot/dev-server', + ].concat(webpackOptions.entry[key]) + return acc; + }, {}); var serverOptions = Object.assign( {}, webpackOptions.devServer, { diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index a7cb75e0afddeb0094c57b55fb515547cb29ac91..d38841979716887ec80ee92c46a5ea6067299075 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -4,6 +4,7 @@ import requests from django.db import models from django.conf import settings from django.contrib import messages +from django.contrib.auth import get_user_model from django.template.loader import render_to_string from .models import INTERNAL, PUBLIC @@ -11,8 +12,12 @@ from .options import MESSAGES from .tasks import send_mail +User = get_user_model() + + def link_to(target, request): - return request.scheme + '://' + request.get_host() + target.get_absolute_url() + if target: + return request.scheme + '://' + request.get_host() + target.get_absolute_url() neat_related = { @@ -70,11 +75,38 @@ class AdapterBase: def recipients(self, message_type, **kwargs): raise NotImplementedError() + def batch_recipients(self, message_type, submissions, **kwargs): + # Default batch recipients is to send a message to each of the recipients that would + # receive a message under normal conditions + return [ + { + 'recipients': self.recipients(message_type, submission=submission, **kwargs), + 'submissions': [submission] + } + for submission in submissions + ] + + def process_batch(self, message_type, events, request, user, submissions, related=None, **kwargs): + events_by_submission = { + event.submission.id: event + for event in events + } + for recipient in self.batch_recipients(message_type, submissions, **kwargs): + recipients = recipient['recipients'] + submissions = recipient['submissions'] + events = [events_by_submission[submission.id] for submission in submissions] + self.process_send(message_type, recipients, events, request, user, submissions=submissions, submission=None, related=related, **kwargs) + def process(self, message_type, event, request, user, submission, related=None, **kwargs): + recipients = self.recipients(message_type, submission=submission, **kwargs) + self.process_send(message_type, recipients, [event], request, user, submission, related=related, **kwargs) + + def process_send(self, message_type, recipients, events, request, user, submission, submissions=list(), related=None, **kwargs): kwargs = { 'request': request, 'user': user, 'submission': submission, + 'submissions': submissions, 'related': related, **kwargs, } @@ -85,30 +117,40 @@ class AdapterBase: if not message: return - for recipient in self.recipients(message_type, **kwargs): - message_log = self.create_log(message, recipient, event) + for recipient in recipients: + message_logs = self.create_logs(message, recipient, *events) + if settings.SEND_MESSAGES or self.always_send: - status = self.send_message(message, recipient=recipient, message_log=message_log, **kwargs) + status = self.send_message(message, recipient=recipient, logs=message_logs, **kwargs) else: status = 'Message not sent as SEND_MESSAGES==FALSE' - message_log.update_status(status) + message_logs.update_status(status) if not settings.SEND_MESSAGES: if recipient: message = '{} [to: {}]: {}'.format(self.adapter_type, recipient, message) else: message = '{}: {}'.format(self.adapter_type, message) - messages.add_message(request, messages.INFO, message) + messages.add_message(request, messages.DEBUG, message) - def create_log(self, message, recipient, event): + def create_logs(self, message, recipient, *events): from .models import Message - return Message.objects.create( - type=self.adapter_type, - content=message, - recipient=recipient or '', - event=event, + messages = Message.objects.bulk_create( + Message( + **self.log_kwargs(message, recipient, event) + ) + for event in events ) + return Message.objects.filter(id__in=[message.id for message in messages]) + + def log_kwargs(self, message, recipient, event): + return { + 'type': self.adapter_type, + 'content': message, + 'recipient': recipient or '', + 'event': event, + } def send_message(self, message, **kwargs): # Process the message, should return the result of the send @@ -128,6 +170,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.DETERMINATION_OUTCOME: 'Sent a determination. Outcome: {determination.clean_outcome}', MESSAGES.INVITED_TO_PROPOSAL: 'Invited to submit a proposal', MESSAGES.REVIEWERS_UPDATED: 'reviewers_updated', + MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', MESSAGES.NEW_REVIEW: 'Submitted a review', MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed', MESSAGES.SCREENING: 'Screening status from {old_status} to {submission.screening_status}' @@ -145,7 +188,7 @@ class ActivityAdapter(AdapterBase): return {'visibility': INTERNAL} return {} - def reviewers_updated(self, added, removed, **kwargs): + def reviewers_updated(self, added=list(), removed=list(), **kwargs): message = ['Reviewers updated.'] if added: message.append('Added:') @@ -157,6 +200,9 @@ class ActivityAdapter(AdapterBase): return ' '.join(message) + def batch_reviewers_updated(self, added, **kwargs): + return 'Batch ' + self.reviewers_updated(added, **kwargs) + def handle_transition(self, old_phase, submission, **kwargs): base_message = 'Progressed from {old_display} to {new_display}' @@ -184,7 +230,7 @@ class ActivityAdapter(AdapterBase): return staff_message - def send_message(self, message, user, submission, **kwargs): + def send_message(self, message, user, submission, submissions, **kwargs): from .models import Activity, PUBLIC visibility = kwargs.get('visibility', PUBLIC) @@ -195,6 +241,12 @@ class ActivityAdapter(AdapterBase): else: related_object = None + try: + # If this was a batch action we want to pull out the submission + submission = submissions[0] + except IndexError: + pass + Activity.actions.create( user=user, submission=submission, @@ -214,6 +266,7 @@ class SlackAdapter(AdapterBase): MESSAGES.EDIT: '{user} has edited <{link}|{submission.title}>', MESSAGES.APPLICANT_EDIT: '{user} has edited <{link}|{submission.title}>', MESSAGES.REVIEWERS_UPDATED: '{user} has updated the reviewers on <{link}|{submission.title}>', + MESSAGES.BATCH_REVIEWERS_UPDATED: 'handle_batch_reviewers', MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{submission.title}>: {old_phase.display_name} → {submission.phase}', MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{submission.title}> was sent by email. Outcome: {determination.clean_outcome}', MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{submission.title}>', @@ -230,13 +283,45 @@ class SlackAdapter(AdapterBase): def extra_kwargs(self, message_type, **kwargs): submission = kwargs['submission'] + submissions = kwargs['submissions'] request = kwargs['request'] link = link_to(submission, request) - return {'link': link} + links = { + submission.id: link_to(submission, request) + for submission in submissions + } + return { + 'link': link, + 'links': links, + } def recipients(self, message_type, submission, **kwargs): return [self.slack_id(submission.lead)] + def batch_recipients(self, message_type, submissions, **kwargs): + # We group the messages by lead + leads = User.objects.filter(id__in=submissions.values('lead')) + return [ + { + 'recipients': [self.slack_id(lead)], + 'submissions': submissions.filter(lead=lead), + } for lead in leads + ] + + def handle_batch_reviewers(self, submissions, links, user, added, **kwargs): + submissions_text = ', '.join( + f'<{links[submission.id]}|{submission.title}>' + for submission in submissions + ) + reviewers_text = ', '.join([str(user) for user in added]) + return ( + '{user} has batch added {reviewers_text} as reviewers on: {submissions_text}'.format( + user=user, + submissions_text=submissions_text, + reviewers_text=reviewers_text, + ) + ) + def notify_reviewers(self, submission, **kwargs): reviewers_to_notify = [] for reviewer in submission.reviewers.all(): @@ -317,13 +402,17 @@ class EmailAdapter(AdapterBase): MESSAGES.READY_FOR_REVIEW: 'messages/email/ready_to_review.html', } - def extra_kwargs(self, message_type, submission, **kwargs): - if message_type == MESSAGES.READY_FOR_REVIEW: - subject = 'Application ready to review: {submission.title}'.format(submission=submission) - else: - subject = submission.page.specific.subject or 'Your application to Open Technology Fund: {submission.title}'.format(submission=submission) + def get_subject(self, message_type, submission): + if submission: + if message_type == MESSAGES.READY_FOR_REVIEW: + subject = 'Application ready to review: {submission.title}'.format(submission=submission) + else: + subject = submission.page.specific.subject or 'Your application to Open Technology Fund: {submission.title}'.format(submission=submission) + return subject + + def extra_kwargs(self, message_type, submission, submissions, **kwargs): return { - 'subject': subject, + 'subject': self.get_subject(message_type, submission), } def notify_comment(self, **kwargs): @@ -352,37 +441,76 @@ class EmailAdapter(AdapterBase): def render_message(self, template, **kwargs): return render_to_string(template, kwargs) - def send_message(self, message, submission, subject, recipient, **kwargs): + def send_message(self, message, submission, subject, recipient, logs, **kwargs): try: send_mail( subject, message, submission.page.specific.from_address, [recipient], - log=kwargs['message_log'] + logs=logs ) except Exception as e: return 'Error: ' + str(e) +class DjangoMessagesAdapter(AdapterBase): + adapter_type = 'Django' + always_send = True + + messages = { + MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', + } + + def batch_reviewers_updated(self, added, submissions, **kwargs): + return ( + 'Batch reviewers added: ' + + ', '.join([str(user) for user in added]) + + ' to ' + + ', '.join(['"{}"'.format(submission.title) for submission in submissions]) + ) + + def recipients(self, *args, **kwargs): + return [None] + + def batch_recipients(self, message_type, submissions, *args, **kwargs): + return [{ + 'recipients': [None], + 'submissions': submissions, + }] + + def send_message(self, message, request, **kwargs): + messages.add_message(request, messages.INFO, message) + + class MessengerBackend: def __init__(self, *adpaters): self.adapters = adpaters - def __call__(self, message_type, request, user, submission, related=None, **kwargs): - return self.send(message_type, request=request, user=user, submission=submission, related=related, **kwargs) + def __call__(self, *args, related=None, **kwargs): + return self.send(*args, related=related, **kwargs) - def send(self, message_type, request, user, submission, related, **kwargs): + def send(self, message_type, request, user, related, submission=None, submissions=list(), **kwargs): from .models import Event - event = Event.objects.create(type=message_type.name, by=user, submission=submission) - for adapter in self.adapters: - adapter.process(message_type, event, request=request, user=user, submission=submission, related=related, **kwargs) + if submission: + event = Event.objects.create(type=message_type.name, by=user, submission=submission) + for adapter in self.adapters: + adapter.process(message_type, event, request=request, user=user, submission=submission, related=related, **kwargs) + + elif submissions: + events = Event.objects.bulk_create( + Event(type=message_type.name, by=user, submission=submission) + for submission in submissions + ) + for adapter in self.adapters: + adapter.process_batch(message_type, events, request=request, user=user, submissions=submissions, related=related, **kwargs) adapters = [ ActivityAdapter(), SlackAdapter(), EmailAdapter(), + DjangoMessagesAdapter(), ] diff --git a/opentech/apply/activity/migrations/0014_add_batch_reviewer_message.py b/opentech/apply/activity/migrations/0014_add_batch_reviewer_message.py new file mode 100644 index 0000000000000000000000000000000000000000..261fc13324a2ad246101a82ddc10b9568855dc5f --- /dev/null +++ b/opentech/apply/activity/migrations/0014_add_batch_reviewer_message.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.9 on 2019-01-31 23:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0013_add_new_event_type_screening'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index cabc295831be94bc29954fe864d51fe2e33e99ae..ea3e7101324996b16082ff1d3694b1d6b388ec6c 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -81,7 +81,7 @@ class Activity(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) submission = models.ForeignKey('funds.ApplicationSubmission', related_name='activities', on_delete=models.CASCADE) message = models.TextField() - visibility = models.CharField(choices=VISIBILITY.items(), default=PUBLIC, max_length=10) + visibility = models.CharField(choices=list(VISIBILITY.items()), default=PUBLIC, max_length=10) # Fields for generic relations to other objects. related_object should implement `get_absolute_url` content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE) @@ -134,6 +134,19 @@ class Event(models.Model): return ' '.join([self.get_type_display(), 'by:', str(self.by), 'on:', self.submission.title]) +class MessagesQueryset(models.QuerySet): + def update_status(self, status): + if status: + return self.update( + status=Case( + When(status='', then=Value(status)), + default=Concat('status', Value('<br />' + status)) + ) + ) + + update_status.queryset_only = True + + class Message(models.Model): """Model to track content of messages sent from an event""" @@ -144,6 +157,8 @@ class Message(models.Model): status = models.TextField() external_id = models.CharField(max_length=75, null=True, blank=True) # Stores the id of the object from an external system + objects = MessagesQueryset.as_manager() + def update_status(self, status): if status: self.status = Case( diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 46d744e035fa0af24a76b6c63996edf6e3523e6e..35aa1e64454ab3a0918c60c986792a4f4dc4dec0 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -11,6 +11,7 @@ class MESSAGES(Enum): DETERMINATION_OUTCOME = 'Determination Outcome' INVITED_TO_PROPOSAL = 'Invited To Proposal' REVIEWERS_UPDATED = 'Reviewers Updated' + BATCH_REVIEWERS_UPDATED = 'Batch Reviewers Updated' READY_FOR_REVIEW = 'Ready For Review' NEW_REVIEW = 'New Review' COMMENT = 'Comment' diff --git a/opentech/apply/activity/tasks.py b/opentech/apply/activity/tasks.py index 73ff71b8343d368b91776ef80fc0b59cdb6a1dd0..569c2775323dcce3630b021ab52e3d5280f62e8b 100644 --- a/opentech/apply/activity/tasks.py +++ b/opentech/apply/activity/tasks.py @@ -8,7 +8,7 @@ app = Celery('tasks') app.config_from_object(settings, namespace='CELERY', force=True) -def send_mail(subject, message, from_address, recipients, log=None): +def send_mail(subject, message, from_address, recipients, logs=None): # Convenience method to wrap the tasks and handle the callback send_mail_task.apply_async( kwargs={ @@ -17,7 +17,7 @@ def send_mail(subject, message, from_address, recipients, log=None): 'from_email': from_address, 'to': recipients, }, - link=update_message_status.s(log.id), + link=update_message_status.s([log.pk for log in logs]), ) @@ -42,8 +42,8 @@ def send_mail_task(**kwargs): @app.task -def update_message_status(response, message_id): +def update_message_status(response, message_pks): from .models import Message - message = Message.objects.get(id=message_id) - message.external_id = response['id'] - message.update_status(response['status']) + messages = Message.objects.filter(pk__in=message_pks) + messages.update(external_id=response['id']) + messages.update_status(response['status']) diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index d804f3c4f52f815e9b8376e62ea926cd269b3574..4b9838aaf062e2106768f668114736621d0c2d4a 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -193,7 +193,7 @@ class TestActivityAdapter(TestCase): user = UserFactory() submission = ApplicationSubmissionFactory() - self.adapter.send_message(message, user=user, submission=submission, related=None) + self.adapter.send_message(message, user=user, submission=submission, submissions=[], related=None) self.assertEqual(Activity.objects.count(), 1) activity = Activity.objects.first() @@ -271,7 +271,7 @@ class TestActivityAdapter(TestCase): def test_lead_not_saved_on_activity(self): submission = ApplicationSubmissionFactory() user = UserFactory() - self.adapter.send_message('a message', user=user, submission=submission, related=user) + self.adapter.send_message('a message', user=user, submission=submission, submissions=[], related=user) activity = Activity.objects.first() self.assertEqual(activity.related_object, None) @@ -279,7 +279,7 @@ class TestActivityAdapter(TestCase): submission = ApplicationSubmissionFactory() user = UserFactory() review = ReviewFactory(submission=submission) - self.adapter.send_message('a message', user=user, submission=submission, related=review) + self.adapter.send_message('a message', user=user, submission=submission, submissions=[], related=review) activity = Activity.objects.first() self.assertEqual(activity.related_object, review) diff --git a/opentech/apply/activity/tests/test_tasks.py b/opentech/apply/activity/tests/test_tasks.py index b6aede89d7e74c4ab4886d7b956c5196e26e62b6..f468f396c0e0e2f0415359a9a912beecc5097b31 100644 --- a/opentech/apply/activity/tests/test_tasks.py +++ b/opentech/apply/activity/tests/test_tasks.py @@ -16,5 +16,5 @@ class TestSendEmail(TestCase): 'from_email': 'from_email', 'to': 'to', } - send_mail(*kwargs, log=MessageFactory()) + send_mail(*kwargs, logs=[MessageFactory()]) email_mock.assert_called_once_with(**kwargs) diff --git a/opentech/apply/dashboard/templates/dashboard/dashboard.html b/opentech/apply/dashboard/templates/dashboard/dashboard.html index de14c3caef48d98e96c5c102aa8cdbd8362a1118..d0a54dca98910e400df9fb6b68446f6aa750577f 100644 --- a/opentech/apply/dashboard/templates/dashboard/dashboard.html +++ b/opentech/apply/dashboard/templates/dashboard/dashboard.html @@ -18,6 +18,11 @@ </div> <div class="wrapper wrapper--large wrapper--inner-space-medium"> <div class="wrapper wrapper--large wrapper--inner-space-medium"> + + {% if closed_rounds or open_rounds %} + {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title %} + {% endif %} + <h3>Applications to review</h3> {% if in_review.data %} {% render_table in_review %} @@ -30,4 +35,5 @@ {% block extra_js %} <script src="{% static 'js/apply/submission-tooltips.js' %}"></script> + <script src="{% static 'js/apply/tabs.js' %}"></script> {% endblock %} diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py index b8703e77d2457cd107d877c81bd101f44ad90d3f..22cba795cec3e3440864a9f85a9cc269ba1656ad 100644 --- a/opentech/apply/dashboard/views.py +++ b/opentech/apply/dashboard/views.py @@ -3,7 +3,7 @@ from django.views.generic import TemplateView from django_tables2 import RequestConfig from django_tables2.views import SingleTableView -from opentech.apply.funds.models import ApplicationSubmission +from opentech.apply.funds.models import ApplicationSubmission, RoundsAndLabs from opentech.apply.funds.tables import SubmissionsTable, AdminSubmissionsTable from opentech.apply.utils.views import ViewDispatcher @@ -15,9 +15,21 @@ class AdminDashboardView(TemplateView): in_review = SubmissionsTable(qs.in_review_for(request.user), prefix='in-review-') RequestConfig(request, paginate={'per_page': 10}).configure(in_review) + base_query = RoundsAndLabs.objects.with_progress().active().order_by('-end_date') + base_query = base_query.by_lead(request.user) + open_rounds = base_query.open()[:6] + open_query = '?round_state=open' + closed_rounds = base_query.closed()[:6] + closed_query = '?round_state=closed' + rounds_title = 'Your rounds and labs' return render(request, 'dashboard/dashboard.html', { 'in_review': in_review, + 'open_rounds': open_rounds, + 'open_query': open_query, + 'closed_rounds': closed_rounds, + 'closed_query': closed_query, + 'rounds_title': rounds_title, }) diff --git a/opentech/apply/funds/api_views.py b/opentech/apply/funds/api_views.py index 4e294f673bb74547106e2521eff91bb93be5fc8a..ece53fb38815dfe6cac8a8578e7adc86c99cba00 100644 --- a/opentech/apply/funds/api_views.py +++ b/opentech/apply/funds/api_views.py @@ -1,14 +1,26 @@ +from django.core.exceptions import PermissionDenied as DjangoPermissionDenied from django.db.models import Q -from rest_framework import generics -from rest_framework import permissions +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied, ValidationError from django_filters import rest_framework as filters -from wagtail.core.models import Page - from opentech.api.pagination import StandardResultsSetPagination -from .models import ApplicationSubmission -from .serializers import SubmissionListSerializer, SubmissionDetailSerializer +from opentech.apply.activity.models import Activity, COMMENT +from opentech.apply.activity.messaging import messenger, MESSAGES + +from .models import ApplicationSubmission, RoundsAndLabs +from .serializers import ( + CommentSerializer, + CommentCreateSerializer, + RoundLabDetailSerializer, + RoundLabSerializer, + SubmissionActionSerializer, + SubmissionListSerializer, + SubmissionDetailSerializer, +) from .permissions import IsApplyStaffUser +from .workflow import PHASES class RoundLabFilter(filters.ModelChoiceFilter): @@ -20,12 +32,22 @@ class RoundLabFilter(filters.ModelChoiceFilter): class SubmissionsFilter(filters.FilterSet): - # TODO replace with better call to Round and Lab base class - round = RoundLabFilter(queryset=Page.objects.all()) + round = RoundLabFilter(queryset=RoundsAndLabs.objects.all()) + status = filters.MultipleChoiceFilter(choices=PHASES) + active = filters.BooleanFilter(method='filter_active') class Meta: model = ApplicationSubmission - fields = ('status', 'round') + fields = ('status', 'round', 'active') + + def filter_active(self, value): + if value is None: + return qs + + if value: + return qs.active() + else: + return qs.inactive() class SubmissionList(generics.ListAPIView): @@ -45,3 +67,95 @@ class SubmissionDetail(generics.RetrieveAPIView): permission_classes = ( permissions.IsAuthenticated, IsApplyStaffUser, ) + + +class SubmissionAction(generics.RetrieveAPIView): + queryset = ApplicationSubmission.objects.all() + serializer_class = SubmissionActionSerializer + permission_classes = ( + permissions.IsAuthenticated, IsApplyStaffUser, + ) + + def post(self, request, *args, **kwargs): + action = request.data.get('action') + if not action: + raise ValidationError('Action must be provided.') + obj = self.get_object() + try: + obj.perform_transition(action, self.request.user, request=self.request) + except DjangoPermissionDenied as e: + raise PermissionDenied(str(e)) + return Response(status=status.HTTP_200_OK) + + +class RoundLabDetail(generics.RetrieveAPIView): + queryset = RoundsAndLabs.objects.all() + serializer_class = RoundLabDetailSerializer + permission_classes = ( + permissions.IsAuthenticated, IsApplyStaffUser, + ) + + def get_object(self): + return super().get_object().specific + + +class RoundLabList(generics.ListAPIView): + queryset = RoundsAndLabs.objects.specific() + serializer_class = RoundLabSerializer + permission_classes = ( + permissions.IsAuthenticated, IsApplyStaffUser, + ) + pagination_class = StandardResultsSetPagination + + +class CommentFilter(filters.FilterSet): + since = filters.DateTimeFilter(field_name="timestamp", lookup_expr='gte') + before = filters.DateTimeFilter(field_name="timestamp", lookup_expr='lte') + + class Meta: + model = Activity + fields = ['submission', 'visibility', 'since', 'before'] + + +class CommentList(generics.ListAPIView): + queryset = Activity.comments.all() + serializer_class = CommentSerializer + permission_classes = ( + permissions.IsAuthenticated, IsApplyStaffUser, + ) + filter_backends = (filters.DjangoFilterBackend,) + filter_class = CommentFilter + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + return super().get_queryset().visible_to(self.request.user) + + +class CommentListCreate(generics.ListCreateAPIView): + queryset = Activity.comments.all() + serializer_class = CommentCreateSerializer + permission_classes = ( + permissions.IsAuthenticated, IsApplyStaffUser, + ) + filter_backends = (filters.DjangoFilterBackend,) + filter_fields = ('visibility',) + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + return super().get_queryset().filter( + submission=self.kwargs['pk'] + ).visible_to(self.request.user) + + def perform_create(self, serializer): + obj = serializer.save( + type=COMMENT, + user=self.request.user, + submission_id=self.kwargs['pk'] + ) + messenger( + MESSAGES.COMMENT, + request=self.request, + user=self.request.user, + submission=obj.submission, + related=obj, + ) diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index 16d36fd6bb706bd270fbe35d67fd151812b2dff2..a75cda24e32c1b113f30aa31bb7bc1643640b6c4 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -66,29 +66,29 @@ class UpdateReviewersForm(forms.ModelForm): model = ApplicationSubmission fields: list = [] - def can_alter_reviewers(self, user): - return self.instance.stage.has_external_review and user == self.instance.lead - def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super().__init__(*args, **kwargs) reviewers = self.instance.reviewers.all() self.submitted_reviewers = User.objects.filter(id__in=self.instance.reviews.values('author')) - staff_field = self.fields['staff_reviewers'] - staff_field.queryset = staff_field.queryset.exclude(id__in=self.submitted_reviewers) - staff_field.initial = reviewers - - if self.can_alter_reviewers(self.user): - review_field = self.fields['reviewer_reviewers'] - review_field.queryset = review_field.queryset.exclude(id__in=self.submitted_reviewers) - review_field.initial = reviewers + self.prepare_field('staff_reviewers', reviewers, self.submitted_reviewers) + if self.can_alter_external_reviewers(self.instance, self.user): + self.prepare_field('reviewer_reviewers', reviewers, self.submitted_reviewers) else: self.fields.pop('reviewer_reviewers') + def prepare_field(self, field_name, initial, excluded): + field = self.fields[field_name] + field.queryset = field.queryset.exclude(id__in=excluded) + field.initial = initial + + def can_alter_external_reviewers(self, instance, user): + return instance.stage.has_external_review and (user == instance.lead or user.is_superuser) + def save(self, *args, **kwargs): instance = super().save(*args, **kwargs) - if self.can_alter_reviewers(self.user): + if self.can_alter_external_reviewers(self.instance, self.user): reviewers = self.cleaned_data.get('reviewer_reviewers') else: reviewers = instance.reviewers_not_reviewed @@ -99,3 +99,15 @@ class UpdateReviewersForm(forms.ModelForm): self.submitted_reviewers ) return instance + + +class BatchUpdateReviewersForm(forms.Form): + staff_reviewers = forms.ModelMultipleChoiceField( + queryset=User.objects.staff(), + widget=Select2MultiCheckboxesWidget(attrs={'data-placeholder': 'Staff'}), + ) + submission_ids = forms.CharField(widget=forms.HiddenInput()) + + def clean_submission_ids(self): + value = self.cleaned_data['submission_ids'] + return [int(submission) for submission in value.split(',')] diff --git a/opentech/apply/funds/models/applications.py b/opentech/apply/funds/models/applications.py index 30097f1d3833a2c6f29beef9d29353bddf1c3c80..f2a791dee8cbebd6dd92cb2ba855d519d1728147 100644 --- a/opentech/apply/funds/models/applications.py +++ b/opentech/apply/funds/models/applications.py @@ -345,6 +345,9 @@ class RoundsAndLabsQueryset(PageQuerySet): def closed(self): return self.filter(end_date__lt=date.today()) + def by_lead(self, user): + return self.filter(lead_pk=user.pk) + class RoundsAndLabsProgressQueryset(RoundsAndLabsQueryset): def active(self): @@ -367,6 +370,10 @@ class RoundsAndLabsManager(PageManager): end_date=F('roundbase__end_date'), parent_path=Left(F('path'), Length('path') - ApplicationBase.steplen, output_field=CharField()), fund=Subquery(funds.values('title')[:1]), + lead_pk=Coalesce( + F('roundbase__lead__pk'), + F('labbase__lead__pk'), + ), ) def with_progress(self): @@ -406,6 +413,9 @@ class RoundsAndLabsManager(PageManager): def new(self): return self.get_queryset().new() + def by_lead(self, user): + return self.get_queryset().by_lead(user) + class RoundsAndLabs(Page): """ diff --git a/opentech/apply/funds/serializers.py b/opentech/apply/funds/serializers.py index 3203a7ecca0cefc53594d594c9b39964655dbf3e..bd0a79964b08ab07e6e45627f33316cce73f697b 100644 --- a/opentech/apply/funds/serializers.py +++ b/opentech/apply/funds/serializers.py @@ -1,24 +1,56 @@ +import mistune from rest_framework import serializers +from django_bleach.templatetags.bleach_tags import bleach_value -from .models import ApplicationSubmission +from opentech.apply.activity.models import Activity +from .models import ApplicationSubmission, RoundsAndLabs + +markdown = mistune.Markdown() + + +class ActionSerializer(serializers.Field): + def to_representation(self, instance): + actions = instance.get_actions_for_user(self.context['request'].user) + return { + transition: action + for transition, action in actions + } + + +class ReviewSummarySerializer(serializers.Field): + def to_representation(self, instance): + return { + 'count': instance.reviews.count(), + 'score': instance.reviews.score(), + 'recommendation': instance.reviews.recommendation(), + } class SubmissionListSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='funds:submissions-api:detail') + url = serializers.HyperlinkedIdentityField(view_name='funds:api:submissions:detail') + round = serializers.SerializerMethodField() class Meta: model = ApplicationSubmission - fields = ('id', 'title', 'status', 'url') + fields = ('id', 'title', 'status', 'url', 'round') + + def get_round(self, obj): + """ + This gets round or lab ID. + """ + return obj.round_id or obj.page_id class SubmissionDetailSerializer(serializers.ModelSerializer): questions = serializers.SerializerMethodField() meta_questions = serializers.SerializerMethodField() stage = serializers.CharField(source='stage.name') + actions = ActionSerializer(source='*') + review = ReviewSummarySerializer(source='*') class Meta: model = ApplicationSubmission - fields = ('id', 'title', 'stage', 'meta_questions', 'questions') + fields = ('id', 'title', 'stage', 'status', 'meta_questions', 'questions', 'actions', 'review') def serialize_questions(self, obj, fields): for field_id in fields: @@ -45,3 +77,54 @@ class SubmissionDetailSerializer(serializers.ModelSerializer): def get_questions(self, obj): return self.serialize_questions(obj, obj.normal_blocks) + + +class SubmissionActionSerializer(serializers.ModelSerializer): + actions = ActionSerializer(source='*') + + class Meta: + model = ApplicationSubmission + fields = ('id', 'actions',) + + +class RoundLabDetailSerializer(serializers.ModelSerializer): + workflow = serializers.SerializerMethodField() + + class Meta: + model = RoundsAndLabs + fields = ('id', 'title', 'workflow') + + def get_workflow(self, obj): + return [ + { + 'value': phase.name, + 'display': phase.display_name + } + for phase in obj.workflow.values() + ] + + +class RoundLabSerializer(serializers.ModelSerializer): + class Meta: + model = RoundsAndLabs + fields = ('id', 'title') + + +class CommentSerializer(serializers.ModelSerializer): + user = serializers.StringRelatedField() + message = serializers.SerializerMethodField() + + class Meta: + model = Activity + fields = ('id', 'timestamp', 'user', 'submission', 'message', 'visibility') + + def get_message(self, obj): + return bleach_value(markdown(obj.message)) + + +class CommentCreateSerializer(serializers.ModelSerializer): + user = serializers.StringRelatedField() + + class Meta: + model = Activity + fields = ('id', 'timestamp', 'user', 'message', 'visibility') diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py index 4456510530e47f6676caf9973e04d0eed9e8d320..b49943f90a7c98996142e0df56f073873d95516e 100644 --- a/opentech/apply/funds/tables.py +++ b/opentech/apply/funds/tables.py @@ -65,8 +65,25 @@ class SubmissionsTable(tables.Table): return qs, True -class AdminSubmissionsTable(SubmissionsTable): - """Adds admin only columns to the submissions table""" +class LabeledCheckboxColumn(tables.CheckBoxColumn): + def wrap_with_label(self, checkbox, for_value): + return format_html( + '<label for="{}">{}</label>', + for_value, + checkbox, + ) + + @property + def header(self): + checkbox = super().header + return self.wrap_with_label(checkbox, 'selectall') + + def render(self, value, record, bound_column): + checkbox = super().render(value=value, record=record, bound_column=bound_column) + return self.wrap_with_label(checkbox, value) + + +class BaseAdminSubmissionsTable(SubmissionsTable): lead = tables.Column(order_by=('lead.full_name',)) reviews_stats = tables.TemplateColumn(template_name='funds/tables/column_reviews.html', verbose_name=mark_safe("Reviews\n<span>Assgn.\tComp.</span>"), orderable=False) screening_status = tables.Column(verbose_name="Screening") @@ -79,8 +96,17 @@ class AdminSubmissionsTable(SubmissionsTable): return format_html('<span>{}</span>', value) -class SummarySubmissionsTable(AdminSubmissionsTable): - class Meta(AdminSubmissionsTable.Meta): +class AdminSubmissionsTable(BaseAdminSubmissionsTable): + """Adds admin only columns to the submissions table""" + selected = LabeledCheckboxColumn(accessor=A('pk'), attrs={'input': {'class': 'js-batch-select'}, 'th__input': {'class': 'js-batch-select-all'}}) + + class Meta(BaseAdminSubmissionsTable.Meta): + fields = ('selected', *BaseAdminSubmissionsTable.Meta.fields) + sequence = fields + + +class SummarySubmissionsTable(BaseAdminSubmissionsTable): + class Meta(BaseAdminSubmissionsTable.Meta): orderable = False @@ -125,9 +151,16 @@ class Select2ModelMultipleChoiceFilter(Select2MultipleChoiceFilter, filters.Mode class StatusMultipleChoiceFilter(Select2MultipleChoiceFilter): - def __init__(self, *args, **kwargs): - choices = [(slugify(status), status) for status in STATUSES] - self.status_map = {slugify(name): status for name, status in STATUSES.items()} + def __init__(self, limit_to, *args, **kwargs): + choices = [ + (slugify(name), name) + for name, statuses in STATUSES.items() + if not limit_to or self.has_any(statuses, limit_to) + ] + self.status_map = { + slugify(name): status + for name, status in STATUSES.items() + } super().__init__( *args, name='status', @@ -136,6 +169,9 @@ class StatusMultipleChoiceFilter(Select2MultipleChoiceFilter): **kwargs, ) + def has_any(self, first, second): + return any(item in second for item in first) + def get_filter_predicate(self, v): return {f'{ self.field_name }__in': self.status_map[v]} @@ -143,7 +179,6 @@ class StatusMultipleChoiceFilter(Select2MultipleChoiceFilter): class SubmissionFilter(filters.FilterSet): round = Select2ModelMultipleChoiceFilter(queryset=get_used_rounds, label='Rounds') fund = Select2ModelMultipleChoiceFilter(name='page', queryset=get_used_funds, label='Funds') - status = StatusMultipleChoiceFilter() lead = Select2ModelMultipleChoiceFilter(queryset=get_round_leads, label='Leads') reviewers = Select2ModelMultipleChoiceFilter(queryset=get_reviewers, label='Reviewers') screening_status = Select2ModelMultipleChoiceFilter(queryset=get_screening_statuses, label='Screening') @@ -152,9 +187,11 @@ class SubmissionFilter(filters.FilterSet): model = ApplicationSubmission fields = ('fund', 'round', 'status') - def __init__(self, *args, exclude=list(), **kwargs): + def __init__(self, *args, exclude=list(), limit_statuses=None, **kwargs): super().__init__(*args, **kwargs) + self.filters['status'] = StatusMultipleChoiceFilter(limit_to=limit_statuses) + self.filters = { field: filter for field, filter in self.filters.items() diff --git a/opentech/apply/funds/templates/funds/base_submissions_table.html b/opentech/apply/funds/templates/funds/base_submissions_table.html index d9214f80da196c86ca5211367455a9e292fd1bac..322a4dca51c1d0a1b9c8aaf5e03531a308549429 100644 --- a/opentech/apply/funds/templates/funds/base_submissions_table.html +++ b/opentech/apply/funds/templates/funds/base_submissions_table.html @@ -3,6 +3,7 @@ {% load render_table from django_tables2 %} {% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> {{ filter.form.media.css }} {% endblock %} @@ -16,10 +17,13 @@ {% block extra_js %} {{ filter.form.media.js }} + <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> + <script src="{% static 'js/apply/fancybox-global.js' %}"></script> <script src="{% static 'js/apply/all-submissions-table.js' %}"></script> <script src="https://cdn.jsdelivr.net/npm/symbol-es6@0.1.2/symbol-es6.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/url-search-params/1.1.0/url-search-params.js"></script> <script src="{% static 'js/apply/submission-filters.js' %}"></script> <script src="{% static 'js/apply/submission-tooltips.js' %}"></script> <script src="{% static 'js/apply/tabs.js' %}"></script> + <script src="{% static 'js/apply/batch-actions.js' %}"></script> {% endblock %} diff --git a/opentech/apply/funds/templates/funds/includes/batch_update_reviewer_form.html b/opentech/apply/funds/templates/funds/includes/batch_update_reviewer_form.html new file mode 100644 index 0000000000000000000000000000000000000000..d74095ba0a497d3c206669684cd33608c9003813 --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/batch_update_reviewer_form.html @@ -0,0 +1,9 @@ +<div class="modal modal--secondary" id="batch-update-reviewers"> + <h4 class="modal__header-bar">Add Reviewers</h4> + <div class="modal__list-item modal__list-item--meta" aria-live="polite"> + <span class="js-batch-title-count"></span> + <a href="#" class="modal__hide-link js-toggle-batch-list">Show</a> + </div> + <div class="modal__list js-batch-titles is-closed" aria-live="polite"></div> + {% include 'funds/includes/delegated_form_base.html' with form=batch_reviewer_form value='Update'%} +</div> diff --git a/opentech/apply/funds/templates/funds/includes/round-block.html b/opentech/apply/funds/templates/funds/includes/round-block.html index 8fdca206107d2438cef6a3115d49dfe101260797..98efb329f1b9c5f5ce983a8bbd2bfc41fd20057f 100644 --- a/opentech/apply/funds/templates/funds/includes/round-block.html +++ b/opentech/apply/funds/templates/funds/includes/round-block.html @@ -1,6 +1,6 @@ <div class="wrapper wrapper--bottom-space"> <section class="section section--with-options"> - <h4 class="heading heading--normal heading--no-margin">All Rounds and Labs</h4> + <h4 class="heading heading--normal heading--no-margin">{{ title }}</h4> <div class="js-tabs"> <a class="tab__item tab__item--alt" href="#closed-rounds" data-tab="tab-1">Closed</a> <a class="tab__item tab__item--alt" href="#open-rounds" data-tab="tab-2">Open</a> diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html index e4cff0f2d2f2147b94009bcebc63f27aefbe9f7f..7edcad1832cbf156157176e6ead6b98116ed6392 100644 --- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html +++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html @@ -1,14 +1,30 @@ <div class="wrapper wrapper--table-actions"> - <button class="button button--filters button--contains-icons js-toggle-filters">Filters</button> + <div class="actions-bar"> + {# Left #} + <div class="actions-bar__inner actions-bar__inner--left"> + <p class="actions-bar__total"><span class="js-total-actions">0</span> Selected</p> + <form action="" class="js-batch-update-status"> + <button class="button button--action button--change-status" type="submit">Change status</button> + </form> + + <button data-fancybox data-src="#batch-update-reviewers" class="button button--action button--reviewers js-batch-update-reviewers" type="submit">Reviewers</button> + </div> + + {# Right #} + <div class="actions-bar__inner actions-bar__inner--right"> + <button class="button button--filters button--contains-icons button--action js-toggle-filters">Filters</button> + + {% if use_search|default:False %} + <form method="get" role="search" class="form form--search-desktop js-search-form"> + <button class="button button--search" type="submit" aria-label="Search"> + <svg class="icon icon--magnifying-glass icon--search"><use xlink:href="#magnifying-glass"></use></svg> + </button> + <input class="input input--search input--secondary js-search-input" type="text" placeholder="Search submissions" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input"> + </form> + {% endif %} + </div> + </div> - {% if use_search|default:False %} - <form method="get" role="search" class="form form--search js-search-form"> - <button class="button button--search" type="submit" aria-label="Search"> - <svg class="icon icon--magnifying-glass icon--search"><use xlink:href="#magnifying-glass"></use></svg> - </button> - <input class="input input--search input--secondary js-search-input" type="text" placeholder="Search submissions" name="query"{% if search_term %} value="{{ search_term }}"{% endif %} aria-label="Search input"> - </form> - {% endif %} </div> <div class="filters"> @@ -27,3 +43,5 @@ </ul> </form> </div> + +{% include "funds/includes/batch_update_reviewer_form.html" %} diff --git a/opentech/apply/funds/templates/funds/submissions.html b/opentech/apply/funds/templates/funds/submissions.html index 353450fa78d785a9339a12023d7eb3f69c5f3736..ca70880476eaac1808566d08c7b7a7c21e54ef21 100644 --- a/opentech/apply/funds/templates/funds/submissions.html +++ b/opentech/apply/funds/templates/funds/submissions.html @@ -14,6 +14,11 @@ </div> <div class="wrapper wrapper--large wrapper--inner-space-medium"> + + {% if closed_rounds or open_rounds %} + {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title %} + {% endif %} + {% block table %} {{ block.super }} {% endblock %} diff --git a/opentech/apply/funds/templates/funds/submissions_by_round.html b/opentech/apply/funds/templates/funds/submissions_by_round.html index 2aee68595093c4fb1ecc7df571dbef701a500b53..f09d574a2b7b0a371a9f59e3779181165e732f24 100644 --- a/opentech/apply/funds/templates/funds/submissions_by_round.html +++ b/opentech/apply/funds/templates/funds/submissions_by_round.html @@ -1,4 +1,5 @@ {% extends "funds/base_submissions_table.html" %} +{% load static %} {% load render_bundle from webpack_loader %} {% block title %}{{ object }}{% endblock %} @@ -21,6 +22,16 @@ {% endblock %} </div> </div> -{% render_bundle 'main' %} +{% render_bundle 'submissionsByRound' %} +<a href="#" class="js-open-feed link link--open-feed"> + <h4 class="heading heading--no-margin heading--activity-feed">Activity Feed</h4> +</a> +{% include "funds/includes/activity-feed.html" %} + +{% endblock %} + +{% block extra_js %} + {{ block.super }} + <script src="{% static 'js/apply/activity-feed.js' %}"></script> {% endblock %} diff --git a/opentech/apply/funds/templates/funds/submissions_by_status.html b/opentech/apply/funds/templates/funds/submissions_by_status.html new file mode 100644 index 0000000000000000000000000000000000000000..0cbd08c1b79098e8eadf0a415531e4fb00609490 --- /dev/null +++ b/opentech/apply/funds/templates/funds/submissions_by_status.html @@ -0,0 +1,25 @@ +{% extends "funds/base_submissions_table.html" %} +{% load render_bundle from webpack_loader %} + +{% block title %}{{ status }}{% endblock %} + +{% block content %} + <div class="admin-bar"> + <div class="admin-bar__inner admin-bar__inner--with-button"> + <div> + <h1 class="gamma heading heading--no-margin heading--bold">{{ status }}</h1> + </div> + <div id="submissions-by-status-app-react-switcher"></div> + </div> + </div> + + <div id="submissions-by-status-react-app" data-statuses="{{ statuses|join:',' }}"> + <div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% block table %} + {{ block.super }} + {% endblock %} + </div> + </div> +{% render_bundle 'submissionsByStatus' %} + +{% endblock %} diff --git a/opentech/apply/funds/templates/funds/submissions_overview.html b/opentech/apply/funds/templates/funds/submissions_overview.html index 3963db804992e273f05a4202a2846aede8554c0a..120cb947c51be68cf0517fd02263108195169e1b 100644 --- a/opentech/apply/funds/templates/funds/submissions_overview.html +++ b/opentech/apply/funds/templates/funds/submissions_overview.html @@ -17,7 +17,7 @@ <div class="wrapper wrapper--large wrapper--inner-space-medium"> {% if closed_rounds or open_rounds %} - {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds %} + {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title%} {% endif %} {% block table %} diff --git a/opentech/apply/funds/templates/funds/tables/table.html b/opentech/apply/funds/templates/funds/tables/table.html index cb3095fc7f961f67e78ed0eadc73308fbf4e2f24..66599d9371b55d206e44642fe6df8ed0f9000a23 100644 --- a/opentech/apply/funds/templates/funds/tables/table.html +++ b/opentech/apply/funds/templates/funds/tables/table.html @@ -5,7 +5,9 @@ <tr {{ row.attrs.as_html }}> {% for column, cell in row.items %} <td {{ column.attrs.td.as_html }}> - <span class="mobile-label {{ column.attrs.td.class }}">{{ column.header }}: </span> + {% if column.name != "selected" %} + <span class="mobile-label {{ column.attrs.td.class }}">{{ column.header }}: </span> + {% endif %} {% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %} </td> {% endfor %} @@ -54,6 +56,7 @@ <tr class="submission-meta__row"> {% for column in row.table.columns %} {% if forloop.first %} + {% elif forloop.counter == 2 %} <th>Linked {{ row.record.previous.stage }}</th> {% else %} <th class="th th--{{ column.header|lower }}">{{ column.header }}</th> @@ -63,7 +66,13 @@ {# mutate the row to render the data for the child row #} {% with row=row|row_from_record:row.record.previous %} - {{ block.super }} + <tr {{ row.attrs.as_html }}> + {% for column, cell in row.items %} + {% if column.name != "selected" %} + <td {{ column.attrs.td.as_html }}>{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}</td> + {% endif %} + {% endfor %} + </tr> {% endwith %} </table> </td> diff --git a/opentech/apply/funds/tests/models/test_roundsandlabs.py b/opentech/apply/funds/tests/models/test_roundsandlabs.py index 5eb8709d7b39e407a568deb51dd14ed9ff0ee27c..a41aa04568fc2445992316eaaa4772f3220df3b8 100644 --- a/opentech/apply/funds/tests/models/test_roundsandlabs.py +++ b/opentech/apply/funds/tests/models/test_roundsandlabs.py @@ -67,6 +67,18 @@ class BaseRoundsAndLabTestCase: self.assertEqual(fetched_obj, obj) self.assertFalse(base_qs.active().exists()) + def test_by_lead(self): + obj = self.base_factory() + # Create an additional round which will create a new staff lead + round_other_lead = RoundFactory() + qs_all = RoundsAndLabs.objects.with_progress() + qs_by_lead = qs_all.by_lead(obj.lead) + fetched_obj = qs_by_lead.first() + self.assertEqual(qs_all.count(), 2) + self.assertEqual(qs_by_lead.count(), 1) + self.assertEqual(fetched_obj.lead, obj.lead.full_name) + self.assertNotEqual(round_other_lead.title, fetched_obj.title) + class TestForLab(BaseRoundsAndLabTestCase, TestCase): base_factory = LabFactory diff --git a/opentech/apply/funds/tests/views/__init__.py b/opentech/apply/funds/tests/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 7897ce287fc42c0919b4bbd171a1f4c9096a8ca3..4a8712ba959b95e2e59ad663ba018d20f8229d44 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -5,13 +5,22 @@ from .views import ( RevisionListView, RoundListView, SubmissionsByRound, + SubmissionsByStatus, SubmissionDetailView, SubmissionEditView, SubmissionListView, SubmissionOverviewView, SubmissionSealedView, ) -from .api_views import SubmissionList, SubmissionDetail +from .api_views import ( + CommentList, + CommentListCreate, + RoundLabDetail, + RoundLabList, + SubmissionAction, + SubmissionList, + SubmissionDetail, +) revision_urls = ([ @@ -35,14 +44,24 @@ submission_urls = ([ path('', include('opentech.apply.determinations.urls', namespace="determinations")), path('revisions/', include(revision_urls, namespace="revisions")), ])), + path('<slug:status>/', SubmissionsByStatus.as_view(), name='status'), ], 'submissions') - -submission_api_urls = ([ - path('', SubmissionList.as_view(), name='list'), - path('<int:pk>/', SubmissionDetail.as_view(), name='detail'), -], 'submissions-api') - +api_urls = ([ + path('submissions/', include(([ + path('', SubmissionList.as_view(), name='list'), + path('<int:pk>/', SubmissionDetail.as_view(), name='detail'), + path('<int:pk>/actions/', SubmissionAction.as_view(), name='actions'), + path('<int:pk>/comments/', CommentListCreate.as_view(), name='comments'), + ], 'submissions'))), + path('rounds/', include(([ + path('', RoundLabList.as_view(), name='list'), + path('<int:pk>/', RoundLabDetail.as_view(), name='detail'), + ], 'rounds'))), + path('comments/', include(([ + path('', CommentList.as_view(), name='list'), + ], 'comments'))) +], 'api') rounds_urls = ([ path('', RoundListView.as_view(), name="list"), @@ -53,5 +72,5 @@ rounds_urls = ([ urlpatterns = [ path('submissions/', include(submission_urls)), path('rounds/', include(rounds_urls)), - path('api/submissions/', include(submission_api_urls)), + path('api/', include(api_urls)), ] diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index 9d0376b7d31452fcb5c30ce2792e830e7cfa4889..67d504a0ff2eb369940586ed130e7571d769bc8f 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -10,7 +10,7 @@ from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.utils.text import mark_safe from django.utils.translation import ugettext_lazy as _ -from django.views.generic import DetailView, ListView, UpdateView +from django.views.generic import DetailView, FormView, ListView, UpdateView from django_filters.views import FilterView from django_tables2.views import SingleTableMixin @@ -27,10 +27,17 @@ from opentech.apply.activity.messaging import messenger, MESSAGES from opentech.apply.determinations.views import DeterminationCreateOrUpdateView from opentech.apply.review.views import ReviewContextMixin from opentech.apply.users.decorators import staff_required -from opentech.apply.utils.views import DelegateableView, ViewDispatcher +from opentech.apply.users.models import User +from opentech.apply.utils.views import DelegateableListView, DelegateableView, ViewDispatcher from .differ import compare -from .forms import ProgressSubmissionForm, ScreeningSubmissionForm, UpdateReviewersForm, UpdateSubmissionLeadForm +from .forms import ( + BatchUpdateReviewersForm, + ProgressSubmissionForm, + ScreeningSubmissionForm, + UpdateReviewersForm, + UpdateSubmissionLeadForm, +) from .models import ApplicationSubmission, ApplicationRevision, RoundsAndLabs, RoundBase, LabBase from .tables import ( AdminSubmissionsTable, @@ -39,7 +46,7 @@ from .tables import ( SubmissionFilterAndSearch, SummarySubmissionsTable, ) -from .workflow import STAGE_CHANGE_ACTIONS +from .workflow import STAGE_CHANGE_ACTIONS, PHASES_MAPPING @method_decorator(staff_required, name='dispatch') @@ -59,24 +66,58 @@ class BaseAdminSubmissionsTable(SingleTableMixin, FilterView): def get_table_kwargs(self, **kwargs): return {**self.excluded, **kwargs} - def get_filterset_kwargs(self, filterset_class): - kwargs = super().get_filterset_kwargs(filterset_class) - kwargs.update(self.excluded) - return kwargs + def get_filterset_kwargs(self, filterset_class, **kwargs): + new_kwargs = super().get_filterset_kwargs(filterset_class) + new_kwargs.update(self.excluded) + new_kwargs.update(kwargs) + return new_kwargs def get_queryset(self): return self.filterset_class._meta.model.objects.current().for_table(self.request.user) def get_context_data(self, **kwargs): - kwargs = super().get_context_data(**kwargs) - search_term = self.request.GET.get('query') - kwargs.update( + + return super().get_context_data( search_term=search_term, filter_action=self.filter_action, + **kwargs, ) - return super().get_context_data(**kwargs) + +@method_decorator(staff_required, name='dispatch') +class BatchUpdateReviewersView(DelegatedViewMixin, FormView): + form_class = BatchUpdateReviewersForm + context_name = 'batch_reviewer_form' + + def form_invalid(self, form): + messages.error(self.request, mark_safe(_('Sorry something went wrong') + form.errors.as_ul())) + return super().form_invalid(form) + + def form_valid(self, form): + """ + Loop through all submissions selected on the page, + Add any reviewers that were selected, only if they are not + currently saved to that submission. + Send out a message of updates. + """ + reviewers = User.objects.filter(id__in=form.cleaned_data['staff_reviewers']) + + submission_ids = form.cleaned_data['submission_ids'] + submissions = ApplicationSubmission.objects.filter(id__in=submission_ids) + + for submission in submissions: + submission.reviewers.add(*reviewers) + + messenger( + MESSAGES.BATCH_REVIEWERS_UPDATED, + request=self.request, + user=self.request.user, + submissions=submissions, + added=reviewers, + ) + + return super().form_valid(form) class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable): @@ -89,27 +130,35 @@ class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable) return super().get_table_data().order_by(F('last_update').desc(nulls_last=True))[:5] def get_context_data(self, **kwargs): - base_query = RoundsAndLabs.objects.with_progress().order_by('end_date') + base_query = RoundsAndLabs.objects.with_progress().active().order_by('-end_date') open_rounds = base_query.open()[:6] open_query = '?round_state=open' closed_rounds = base_query.closed()[:6] closed_query = '?round_state=closed' + rounds_title = 'All Rounds and Labs' return super().get_context_data( open_rounds=open_rounds, open_query=open_query, closed_rounds=closed_rounds, closed_query=closed_query, + rounds_title=rounds_title, **kwargs, ) -class SubmissionListView(AllActivityContextMixin, BaseAdminSubmissionsTable): +class SubmissionListView(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions.html' + form_views = [ + BatchUpdateReviewersView + ] -class SubmissionsByRound(BaseAdminSubmissionsTable): +class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions_by_round.html' + form_views = [ + BatchUpdateReviewersView + ] excluded_fields = ('round', 'lead', 'fund') @@ -128,6 +177,34 @@ class SubmissionsByRound(BaseAdminSubmissionsTable): return super().get_context_data(object=self.obj, **kwargs) +class SubmissionsByStatus(BaseAdminSubmissionsTable): + template_name = 'funds/submissions_by_status.html' + status_mapping = PHASES_MAPPING + + def get(self, request, *args, **kwargs): + self.status = kwargs.get('status') + status_data = self.status_mapping[self.status] + self.status_name = status_data['name'] + self.statuses = status_data['statuses'] + if self.status not in self.status_mapping: + raise Http404(_("No statuses match the requested value")) + + return super().get(request, *args, **kwargs) + + def get_filterset_kwargs(self, filterset_class, **kwargs): + return super().get_filterset_kwargs(filterset_class, limit_statuses=self.statuses, **kwargs) + + def get_queryset(self): + return super().get_queryset().filter(status__in=self.statuses) + + def get_context_data(self, **kwargs): + return super().get_context_data( + status=self.status_name, + statuses=self.statuses, + **kwargs, + ) + + @method_decorator(staff_required, name='dispatch') class ProgressSubmissionView(DelegatedViewMixin, UpdateView): model = ApplicationSubmission @@ -207,10 +284,11 @@ class UpdateReviewersView(DelegatedViewMixin, UpdateView): added=added, removed=removed, ) + return response -class AdminSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, DelegateableView): +class AdminSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, DelegateableView, DetailView): template_name_suffix = '_admin_detail' model = ApplicationSubmission form_views = [ @@ -298,7 +376,7 @@ class SubmissionSealedView(DetailView): return HttpResponseRedirect(reverse_lazy('funds:submissions:sealed', args=(submission.id,))) -class ApplicantSubmissionDetailView(ActivityContextMixin, DelegateableView): +class ApplicantSubmissionDetailView(ActivityContextMixin, DelegateableView, DetailView): model = ApplicationSubmission form_views = [CommentFormView] diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index 3ed61c1752f7f97d68cd31fa01a388209f659697..b0a965ccab242409cac75da402486072c01eb284 100644 --- a/opentech/apply/funds/workflow.py +++ b/opentech/apply/funds/workflow.py @@ -349,7 +349,7 @@ SingleStageExternalDefinition = [ 'ext_post_external_review_more_info': 'Request More Information', 'ext_determination': 'Ready For Determination', }, - 'display': 'Ready for Discussion', + 'display': 'Ready For Discussion', 'stage': RequestExt, 'permissions': hidden_from_applicant_permissions, }, @@ -444,7 +444,7 @@ DoubleStageDefinition = [ 'concept_review_more_info': 'Request More Information', 'concept_determination': 'Ready For Preliminary Determination', }, - 'display': 'Ready for Discussion', + 'display': 'Ready For Discussion', 'stage': Concept, 'permissions': hidden_from_applicant_permissions, }, @@ -555,7 +555,7 @@ DoubleStageDefinition = [ 'proposal_rejected': 'Dismiss', 'post_proposal_review_more_info': 'Request More Information', }, - 'display': 'Ready for Discussion', + 'display': 'Ready For Discussion', 'stage': Proposal, 'permissions': hidden_from_applicant_permissions, }, @@ -591,7 +591,7 @@ DoubleStageDefinition = [ 'proposal_determination': 'Ready For Final Determination', 'post_external_review_more_info': 'Request More Information', }, - 'display': 'Ready for Discussion', + 'display': 'Ready For Discussion', 'stage': Proposal, 'permissions': hidden_from_applicant_permissions, }, @@ -733,3 +733,50 @@ def get_determination_transitions(): DETERMINATION_OUTCOMES = get_determination_transitions() + + +def phases_matching(phrase, exclude=list()): + return [ + status for status, _ in PHASES + if status.endswith(phrase) and status not in exclude + ] + + +PHASES_MAPPING = { + 'received': { + 'name': 'Received', + 'statuses': [INITIAL_STATE, 'proposal_discussion'], + }, + 'internal-review': { + 'name': 'Internal Review', + 'statuses': phases_matching('internal_review'), + }, + 'in-discussion': { + 'name': 'In Discussion', + 'statuses': phases_matching('discussion', exclude=[INITIAL_STATE, 'proposal_discussion']), + }, + 'more-information': { + 'name': 'More Information Requested', + 'statuses': phases_matching('more_info'), + }, + 'invited-for-proposal': { + 'name': 'Invited for proposal', + 'statuses': ['draft_proposal'], + }, + 'external-review': { + 'name': 'AC Review', + 'statuses': phases_matching('external_review'), + }, + 'ready-for-determination': { + 'name': 'Ready for determination', + 'statuses': phases_matching('determination'), + }, + 'accepted': { + 'name': 'Accepted', + 'statuses': phases_matching('accepted'), + }, + 'dismissed': { + 'name': 'Dismissed', + 'statuses': phases_matching('rejected'), + }, +} diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py index 87932814703c400b72c81e554d1bc1695c9e806e..3e83f17d44a38942df0dc9f1133d0c3141c7cff2 100644 --- a/opentech/apply/utils/views.py +++ b/opentech/apply/utils/views.py @@ -1,7 +1,9 @@ from django.contrib.auth.decorators import login_required +from django.forms.models import ModelForm from django.utils.decorators import method_decorator from django.views import defaults -from django.views.generic import DetailView, View +from django.views.generic import View +from django.views.generic.base import ContextMixin from django.views.generic.detail import SingleObjectTemplateResponseMixin from django.views.generic.edit import ModelFormMixin, ProcessFormView @@ -35,12 +37,20 @@ class ViewDispatcher(View): return view.as_view()(request, *args, **kwargs) -class DelegateableView(DetailView): - """A view which passes its context to child form views to allow them to post to the same URL """ +class DelegatableBase(ContextMixin): + """ + A view which passes its context to child form views to allow them to post to the same URL + `DelegateableViews` objects should contain form views that inherit from `DelegatedViewMixin` + and `FormView` + """ form_prefix = 'form-submitted-' + def get_form_args(self): + return (None, None) + def get_context_data(self, **kwargs): - forms = dict(form_view.contribute_form(self.object, self.request.user) for form_view in self.form_views) + forms = dict(form_view.contribute_form(*self.get_form_args()) for form_view in self.form_views) + return super().get_context_data( form_prefix=self.form_prefix, **forms, @@ -48,13 +58,9 @@ class DelegateableView(DetailView): ) def post(self, request, *args, **kwargs): - self.object = self.get_object() - - kwargs['submission'] = self.object - # Information to pretend we originate from this view - kwargs['template_names'] = self.get_template_names() kwargs['context'] = self.get_context_data() + kwargs['template_names'] = self.get_template_names() for form_view in self.form_views: if self.form_prefix + form_view.context_name in request.POST: @@ -64,14 +70,34 @@ class DelegateableView(DetailView): return self.get(request, *args, **kwargs) +class DelegateableView(DelegatableBase): + def get_form_args(self): + return self.object, self.request.user + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + kwargs['submission'] = self.object + + return super().post(request, *args, **kwargs) + + +class DelegateableListView(DelegatableBase): + def post(self, request, *args, **kwargs): + self.object_list = self.get_queryset() + return super().post(request, *args, **kwargs) + + class DelegatedViewMixin(View): """For use on create views accepting forms from another view""" + def get_template_names(self): return self.kwargs['template_names'] def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs['user'] = self.request.user + if self.is_model_form(): + kwargs['user'] = self.request.user return kwargs def get_form(self, *args, **kwargs): @@ -86,12 +112,22 @@ class DelegatedViewMixin(View): kwargs.update(**{self.context_name: form}) return super().get_context_data(**kwargs) + @classmethod + def is_model_form(cls): + return issubclass(cls.form_class, ModelForm) + @classmethod def contribute_form(cls, submission, user): - form = cls.form_class(instance=submission, user=user) + if cls.is_model_form(): + form = cls.form_class(instance=submission, user=user) + else: + form = cls.form_class() # This is for the batch update, we don't pass in the user or a single submission form.name = cls.context_name return cls.context_name, form + def get_success_url(self): + return self.request.path + class CreateOrUpdateView(SingleObjectTemplateResponseMixin, ModelFormMixin, ProcessFormView): diff --git a/opentech/settings/base.py b/opentech/settings/base.py index fb39394186581b10c4c316deb34e0a6de93ce67c..ca17c3ff66873d59355387e1ff0c228ca0b8195e 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -446,6 +446,11 @@ HIJACK_DECORATOR = 'opentech.apply.users.decorators.superuser_decorator' # Messaging Settings SEND_MESSAGES = env.get('SEND_MESSAGES', 'false').lower() == 'true' + +if not SEND_MESSAGES: + from django.contrib.messages import constants as message_constants + MESSAGE_LEVEL = message_constants.DEBUG + SLACK_DESTINATION_URL = env.get('SLACK_DESTINATION_URL', None) SLACK_DESTINATION_ROOM = env.get('SLACK_DESTINATION_ROOM', None) diff --git a/opentech/settings/test.py b/opentech/settings/test.py index e084affe86bd4e5fa78898f94cac87274848dc42..11a71f78e5248d8568b15eaf0f8d0ca00ff70acd 100644 --- a/opentech/settings/test.py +++ b/opentech/settings/test.py @@ -1,3 +1,7 @@ +import logging + +logging.disable(logging.CRITICAL) + from .base import * # noqa # Should only include explicit testing settings diff --git a/opentech/static_src/src/app/src/SubmissionsByRoundApp.js b/opentech/static_src/src/app/src/SubmissionsByRoundApp.js index 133ec908ac6d5d229408761c8f6bb708eae82fed..33a3c41831079424ba5c05019a7c9c4f6b83f2d8 100644 --- a/opentech/static_src/src/app/src/SubmissionsByRoundApp.js +++ b/opentech/static_src/src/app/src/SubmissionsByRoundApp.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux' -import Switcher from '@components/Switcher'; +import SwitcherApp from './SwitcherApp'; import GroupByStatusDetailView from '@containers/GroupByStatusDetailView'; import { setCurrentSubmissionRound } from '@actions/submissions'; @@ -15,43 +15,15 @@ class SubmissionsByRoundApp extends React.Component { pageContent: PropTypes.node.isRequired, }; - - state = { detailOpened: false }; - componentDidMount() { this.props.setSubmissionRound(this.props.roundID); } - openDetail = () => { - this.setState(state => ({ - style: { ...state.style, display: 'none' } , - detailOpened: true, - })); - } - - closeDetail = () => { - this.setState(state => { - const newStyle = { ...state.style }; - delete newStyle.display; - return { - style: newStyle, - detailOpened: false, - }; - }); - } - render() { - return ( - <> - <Switcher selector='submissions-by-round-app-react-switcher' open={this.state.detailOpened} handleOpen={this.openDetail} handleClose={this.closeDetail} /> - - <div style={this.state.style} ref={this.setOriginalContentRef} dangerouslySetInnerHTML={{ __html: this.props.pageContent }} /> - - {this.state.detailOpened && - <GroupByStatusDetailView roundId={this.props.roundID} /> - } - </> - ) + return <SwitcherApp + detailComponent={<GroupByStatusDetailView />} + switcherSelector={'submissions-by-round-app-react-switcher'} + pageContent={this.props.pageContent} />; } } diff --git a/opentech/static_src/src/app/src/SubmissionsByStatusApp.js b/opentech/static_src/src/app/src/SubmissionsByStatusApp.js new file mode 100644 index 0000000000000000000000000000000000000000..785d1c2ffdd6373d27d69aa9a856a24997a4b87f --- /dev/null +++ b/opentech/static_src/src/app/src/SubmissionsByStatusApp.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import SwitcherApp from './SwitcherApp'; +import { hot } from 'react-hot-loader'; + +import GroupByRoundDetailView from '@containers/GroupByRoundDetailView'; + + +class SubmissionsByStatusApp extends React.Component { + static propTypes = { + pageContent: PropTypes.node.isRequired, + statuses: PropTypes.arrayOf(PropTypes.string), + }; + + render() { + return <SwitcherApp + detailComponent={<GroupByRoundDetailView submissionStatuses={this.props.statuses} />} + switcherSelector={'submissions-by-status-app-react-switcher'} + pageContent={this.props.pageContent} />; + } +} + +export default hot(module)(SubmissionsByStatusApp); diff --git a/opentech/static_src/src/app/src/SwitcherApp.js b/opentech/static_src/src/app/src/SwitcherApp.js new file mode 100644 index 0000000000000000000000000000000000000000..7394d65977a8b3bf16e6683f7a6a1d56480855a2 --- /dev/null +++ b/opentech/static_src/src/app/src/SwitcherApp.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Switcher from '@components/Switcher'; + +export default class SwitcherApp extends React.Component { + static propTypes = { + pageContent: PropTypes.node.isRequired, + detailComponent: PropTypes.node.isRequired, + switcherSelector: PropTypes.string.isRequired, + }; + + + state = { detailOpened: false }; + + openDetail = () => { + document.body.classList.add('app-open'); + this.setState(state => ({ + style: { ...state.style, display: 'none' } , + detailOpened: true, + })); + } + + closeDetail = () => { + document.body.classList.remove('app-open'); + this.setState(state => { + const newStyle = { ...state.style }; + delete newStyle.display; + return { + style: newStyle, + detailOpened: false, + }; + }); + } + + render() { + return ( + <> + <Switcher selector={this.props.switcherSelector} open={this.state.detailOpened} handleOpen={this.openDetail} handleClose={this.closeDetail} /> + + <div style={this.state.style} ref={this.setOriginalContentRef} dangerouslySetInnerHTML={{ __html: this.props.pageContent }} /> + + {this.state.detailOpened && this.props.detailComponent} + </> + ) + } +} diff --git a/opentech/static_src/src/app/src/api/index.js b/opentech/static_src/src/app/src/api/index.js index 6fcd16016bf03484a9777a09772ecd92b46f4507..e71a708a4cda32b21936364955ec21d3e5bd8fd9 100644 --- a/opentech/static_src/src/app/src/api/index.js +++ b/opentech/static_src/src/app/src/api/index.js @@ -1,6 +1,15 @@ -import { fetchSubmission, fetchSubmissionsByRound } from '@api/submissions'; +import { fetchSubmission, fetchSubmissionsByRound, fetchSubmissionsByStatuses } from '@api/submissions'; +import { fetchRound, fetchRounds } from '@api/rounds'; +import { createNoteForSubmission, fetchNotesForSubmission } from '@api/notes'; export default { fetchSubmissionsByRound, + fetchSubmissionsByStatuses, fetchSubmission, + + fetchRound, + fetchRounds, + + fetchNotesForSubmission, + createNoteForSubmission, }; diff --git a/opentech/static_src/src/app/src/api/notes.js b/opentech/static_src/src/app/src/api/notes.js new file mode 100644 index 0000000000000000000000000000000000000000..ec431cf28874a8b7baed6976de7a1988a751c5ab --- /dev/null +++ b/opentech/static_src/src/app/src/api/notes.js @@ -0,0 +1,20 @@ +export function fetchNotesForSubmission(submissionID, visibility = 'internal') { + return { + path: `/apply/api/submissions/${submissionID}/comments/`, + params: { + visibility, + page_size: 1000, + } + }; +} + + +export function createNoteForSubmission(submissionID, note) { + return { + path: `/apply/api/submissions/${submissionID}/comments/`, + method: 'POST', + options: { + body: note, + } + }; +} diff --git a/opentech/static_src/src/app/src/api/rounds.js b/opentech/static_src/src/app/src/api/rounds.js new file mode 100644 index 0000000000000000000000000000000000000000..c7ef0a75be9a4e641419fa1e2227d6fe8e55464b --- /dev/null +++ b/opentech/static_src/src/app/src/api/rounds.js @@ -0,0 +1,14 @@ +export function fetchRound(id) { + return { + path:`/apply/api/rounds/${id}/`, + }; +} + +export function fetchRounds() { + return { + path:`/apply/api/rounds/`, + params: { + page_size: 1000, + }, + }; +} diff --git a/opentech/static_src/src/app/src/api/submissions.js b/opentech/static_src/src/app/src/api/submissions.js index 8d7f95f776558bdb14a68dc874901214de1dcceb..f6f2cb41fc9acfcfe364cf07cdac9828ff7cbbb7 100644 --- a/opentech/static_src/src/app/src/api/submissions.js +++ b/opentech/static_src/src/app/src/api/submissions.js @@ -1,13 +1,27 @@ -import { apiFetch } from '@api/utils'; +export function fetchSubmissionsByRound(id) { + return { + path:'/apply/api/submissions/', + params: { + round: id, + page_size: 1000, + } + }; +} + -export async function fetchSubmissionsByRound(id) { - return apiFetch('/apply/api/submissions/', 'GET', { - 'round': id, - 'page_size': 1000, - }); +export function fetchSubmission(id) { + return { + path: `/apply/api/submissions/${id}/`, + }; } +export function fetchSubmissionsByStatuses(statuses) { + const params = new URLSearchParams + params.append('page_size', 1000) + statuses.forEach(v => params.append('status', v)); -export async function fetchSubmission(id) { - return apiFetch(`/apply/api/submissions/${id}/`, 'GET'); + return { + path:'/apply/api/submissions/', + params, + }; } diff --git a/opentech/static_src/src/app/src/api/utils.js b/opentech/static_src/src/app/src/api/utils.js index 094b3281fc9d5ee1ff4d5ea12e8ede48d92f40af..29e4a3ef537aaa4054ba4738a0eb1fb989c288a8 100644 --- a/opentech/static_src/src/app/src/api/utils.js +++ b/opentech/static_src/src/app/src/api/utils.js @@ -1,20 +1,46 @@ +import Cookies from 'js-cookie'; + const getBaseUrl = () => { return process.env.API_BASE_URL; }; -export async function apiFetch(path, method = 'GET', params, options) { +export function apiFetch({path, method = 'GET', params = new URLSearchParams, options = {}}) { const url = new URL(getBaseUrl()); url.pathname = path; - if (params !== undefined) { - for (const [paramKey, paramValue] of Object.entries(params)) { - url.searchParams.set(paramKey, paramValue); - } + for (const [paramKey, paramValue] of getIteratorForParams(params)) { + url.searchParams.append(paramKey, paramValue); + } + + if (['post', 'put', 'patch', 'delete'].includes(method.toLowerCase())) { + options.headers = { + ...options.headers, + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken(), + }; } + return fetch(url, { ...options, + headers: { + ...options.headers, + 'Accept': 'application/json', + }, method, mode: 'same-origin', credentials: 'include' }); } + +function getCSRFToken() { + return Cookies.get('csrftoken'); +} + + +function getIteratorForParams(params) { + if (params instanceof URLSearchParams) { + return params; + } + + return Object.entries(params); +} diff --git a/opentech/static_src/src/app/src/components/DetailView/index.js b/opentech/static_src/src/app/src/components/DetailView/index.js index b392e211ef577a62aaa64ad8a674b62544cf83cf..933c972c3496be0e01adcbedc313df5c989468ce 100644 --- a/opentech/static_src/src/app/src/components/DetailView/index.js +++ b/opentech/static_src/src/app/src/components/DetailView/index.js @@ -19,57 +19,31 @@ class DetailView extends Component { clearSubmission: PropTypes.func.isRequired, }; - state = { - listingShown: true, - firstRender: true, - } - isMobile = (width) => (width ? width : this.props.windowSize.windowWidth) < 1024 renderDisplay () { return <DisplayPanel /> } - componentDidUpdate (prevProps, prevState) { - if (this.isMobile()) { - const haveCleared = prevProps.submissionID && !this.props.submissionID - const haveUpdated = !prevProps.submissionID && this.props.submissionID - - if ( haveCleared ) { - this.setState({listingShown: true}) - } else if ( haveUpdated && this.state.firstRender ) { - // Listing automatically updating after update - // clear, but dont run again - this.props.clearSubmission() - this.setState({firstRender: false}) - } else if ( prevProps.submissionID !== this.props.submissionID) { - // Submission has changed and we want to show it - // reset the firstRender so that we can clear it again - this.setState({ - listingShown: false, - firstRender: true, - }) - } - } - } - render() { - const { listing } = this.props; + const { listing, submissionID } = this.props; + + const activeSubmision = !!submissionID; if (this.isMobile()) { var activeDisplay; - if (this.state.listingShown){ - activeDisplay = ( - <SlideOutLeft key={"listing"}> - {listing} - </SlideOutLeft> - ) - } else { + if (activeSubmision) { activeDisplay = ( <SlideInRight key={"display"}> { this.renderDisplay() } </SlideInRight> ) + } else { + activeDisplay = ( + <SlideOutLeft key={"listing"}> + { React.cloneElement(listing, { shouldSelectFirst: false }) } + </SlideOutLeft> + ) } return ( diff --git a/opentech/static_src/src/app/src/components/DetailView/style.scss b/opentech/static_src/src/app/src/components/DetailView/style.scss index 2c70a676efe6257473dd79fc4c59d2ae0214535e..8ef5f56405c459ec09a38d11a18317cb17ed3c31 100644 --- a/opentech/static_src/src/app/src/components/DetailView/style.scss +++ b/opentech/static_src/src/app/src/components/DetailView/style.scss @@ -1,13 +1,7 @@ .detail-view { margin: 0 -20px; - overflow-y: overlay; @include media-query(tablet-landscape) { - display: grid; - grid-template-columns: 250px 1fr; - } - - @include media-query(desktop) { // breakout of the wrapper width: 100vw; position: relative; @@ -15,6 +9,11 @@ right: 50%; margin-left: -50vw; margin-right: -50vw; + display: grid; + grid-template-columns: 250px 1fr; + } + + @include media-query(desktop) { grid-template-columns: 390px 1fr; } diff --git a/opentech/static_src/src/app/src/components/EmptyPanel/index.js b/opentech/static_src/src/app/src/components/EmptyPanel/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b2fa5274f9b8b3bbe779c86d27ab2a47ce157659 --- /dev/null +++ b/opentech/static_src/src/app/src/components/EmptyPanel/index.js @@ -0,0 +1,33 @@ +import React from 'react' +import PropTypes from 'prop-types'; + +import NoteIcon from 'images/note.svg'; + +export default class EmptyPanel extends React.Component { + static propTypes = { + column: PropTypes.string, + } + + render() { + const { column } = this.props; + + return ( + <div className={`listing__list listing__list--${column} is-blank`}> + {column === 'notes' && + <> + <div className="listing__blank-icon"> + <NoteIcon /> + </div> + <p className="listing__help-text listing__help-text--standout"> + There aren't any notes<br /> for this appication yet… + </p> + </> + } + + {column === 'applications' && + <p>No results found.</p> + } + </div> + ) + } +} diff --git a/opentech/static_src/src/app/src/components/GroupedListing/index.js b/opentech/static_src/src/app/src/components/GroupedListing/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bd796df960a714afc24048d18b7e491dca0d561d --- /dev/null +++ b/opentech/static_src/src/app/src/components/GroupedListing/index.js @@ -0,0 +1,154 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Listing from '@components/Listing'; +import ListingGroup from '@components/ListingGroup'; +import ListingItem from '@components/ListingItem'; +import ListingDropdown from '@components/ListingDropdown'; + +import './styles.scss' + +export default class GroupedListing extends React.Component { + static propTypes = { + items: PropTypes.array, + activeItem: PropTypes.number, + isLoading: PropTypes.bool, + error: PropTypes.string, + groupBy: PropTypes.string, + order: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string.isRequired, + display: PropTypes.string.isRequired, + values: PropTypes.arrayOf(PropTypes.string), + })), + onItemSelection: PropTypes.func, + shouldSelectFirst: PropTypes.bool, + }; + + static defaultProps = { + shouldSelectFirst: true, + } + + + state = { + orderedItems: [], + }; + + constructor(props) { + super(props); + this.listRef = React.createRef(); + } + + componentDidMount() { + this.orderItems(); + + // get the height of the dropdown container + this.dropdownContainerHeight = this.dropdownContainer.offsetHeight; + } + + shouldComponentUpdate(nextProps, nextState) { + const propsToCheck = ['items', 'isLoading', 'error'] + if ( propsToCheck.some(prop => nextProps[prop] !== this.props[prop])) { + return true + } + if ( nextState.orderedItems !== this.state.orderedItems ) { + return true + } + return false + } + + componentDidUpdate(prevProps, prevState) { + // Order items + if (this.props.items !== prevProps.items) { + this.orderItems(); + } + + if ( this.props.shouldSelectFirst ){ + const oldItem = prevProps.activeItem + const newItem = this.props.activeItem + + // If we have never activated a submission, get the first item + if ( !newItem && !oldItem ) { + const firstGroup = this.state.orderedItems[0] + if ( firstGroup && firstGroup.items[0] ) { + this.setState({firstUpdate: false}) + this.props.onItemSelection(firstGroup.items[0].id) + } + } + } + } + + getGroupedItems() { + const { groupBy, items } = this.props; + + return items.reduce((tmpItems, v) => { + const groupByValue = v[groupBy]; + if (!(groupByValue in tmpItems)) { + tmpItems[groupByValue] = []; + } + tmpItems[groupByValue].push({...v}); + return tmpItems; + }, {}); + } + + orderItems() { + const groupedItems = this.getGroupedItems(); + const { order = [] } = this.props; + const orderedItems = order.map(({key, display, values}) => ({ + name: display, + key, + items: values.reduce((acc, value) => acc.concat(groupedItems[value] || []), []) + })).filter(({items}) => items.length !== 0) + + this.setState({orderedItems}); + } + + renderItem = group => { + const { activeItem, onItemSelection } = this.props; + return ( + <ListingGroup key={`listing-group-${group.key}`} id={group.key} item={group}> + {group.items.map(item => { + return <ListingItem + selected={!!activeItem && activeItem===item.id} + onClick={() => onItemSelection(item.id)} + key={`listing-item-${item.id}`} + item={item}/>; + })} + </ListingGroup> + ); + } + + render() { + const { isLoading, error } = this.props; + const isError = Boolean(error); + + const passProps = { + items: this.state.orderedItems, + renderItem: this.renderItem, + isLoading, + isError, + error + }; + + // set css custom prop to allow scrolling from dropdown to last item in the list + if (this.listRef.current) { + document.documentElement.style.setProperty('--last-listing-item-height', this.listRef.current.firstChild.lastElementChild.offsetHeight + 'px'); + } + + return ( + <div className="grouped-listing"> + <div className="grouped-listing__dropdown" ref={(ref) => this.dropdownContainer = ref}> + {!error && !isLoading && + <ListingDropdown + error={error} + isLoading={isLoading} + listRef={this.listRef} + groups={this.state.orderedItems} + scrollOffset={this.dropdownContainerHeight} + /> + } + </div> + <Listing {...passProps} listRef={this.listRef} column="applications" /> + </div> + ); + } +} diff --git a/opentech/static_src/src/app/src/components/GroupedListing/styles.scss b/opentech/static_src/src/app/src/components/GroupedListing/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..2306ff7050659868426b7340760a01304183a791 --- /dev/null +++ b/opentech/static_src/src/app/src/components/GroupedListing/styles.scss @@ -0,0 +1,11 @@ +.grouped-listing { + &__dropdown { + @include submission-list-item; + height: $listing-header-height; + padding: 20px; + } + + .loading-panel__icon::after { + background: $color--light-grey; + } +} diff --git a/opentech/static_src/src/app/src/components/Listing/index.js b/opentech/static_src/src/app/src/components/Listing/index.js index 65f786726b058511d888c230700a004e3c03c841..a4d86373e65bac32b4f10ca16c9a5518f08c9c40 100644 --- a/opentech/static_src/src/app/src/components/Listing/index.js +++ b/opentech/static_src/src/app/src/components/Listing/index.js @@ -1,122 +1,89 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { TransitionGroup } from 'react-transition-group'; -import ListingGroup from '@components/ListingGroup'; -import ListingItem from '@components/ListingItem'; import LoadingPanel from '@components/LoadingPanel'; +import EmptyPanel from '@components/EmptyPanel'; + +import SadNoteIcon from 'images/sad-note.svg'; import './style.scss'; export default class Listing extends React.Component { static propTypes = { - items: PropTypes.array, - activeItem: PropTypes.number, + items: PropTypes.array.isRequired, isLoading: PropTypes.bool, + isError: PropTypes.bool, error: PropTypes.string, groupBy: PropTypes.string, order: PropTypes.arrayOf(PropTypes.string), onItemSelection: PropTypes.func, + renderItem: PropTypes.func.isRequired, + handleRetry: PropTypes.func, + listRef: PropTypes.object, + column: PropTypes.string, }; - state = { - orderedItems: [], - }; - - componentDidMount() { - this.orderItems(); - } - - componentDidUpdate(prevProps, prevState) { - // Order items - if (this.props.items !== prevProps.items) { - this.orderItems(); - } - - const oldItem = prevProps.activeItem - const newItem = this.props.activeItem - - // If we have never activated a submission, get the first item - if ( !newItem && !oldItem ) { - const firstGroup = this.state.orderedItems[0] - if ( firstGroup && firstGroup.items[0] ) { - this.setState({firstUpdate: false}) - this.props.onItemSelection(firstGroup.items[0].id) - } - } - } - renderListItems() { - const { isLoading, error, items, onItemSelection, activeItem } = this.props; + const { + isError, + isLoading, + items, + renderItem, + column, + listRef, + } = this.props; if (isLoading) { return ( - <div className="listing__list is-loading"> + <div className="listing__list"> <LoadingPanel /> </div> - ) - } else if (error) { - return ( - <div className="listing__list is-loading"> - <p>Something went wrong. Please try again later.</p> - <p>{ error }</p> - </div> - ) + ); + } else if (isError) { + return this.renderError(); } else if (items.length === 0) { - return ( - <div className="listing__list is-loading"> - <p>No results found.</p> - </div> - ) + return <EmptyPanel column={this.props.column} />; } return ( - <ul className="listing__list"> - {this.state.orderedItems.map(group => { - return ( - <ListingGroup key={`listing-group-${group.name}`} item={group}> - {group.items.map(item => { - return <ListingItem - selected={!!activeItem && activeItem===item.id} - onClick={() => onItemSelection(item.id)} - key={`listing-item-${item.id}`} - item={item}/>; - })} - </ListingGroup> - ); - })} + <ul className={`listing__list listing__list--${column}`} ref={listRef}> + <TransitionGroup> + {items.map(v => renderItem(v))} + </TransitionGroup> </ul> ); } - getGroupedItems() { - const { groupBy, items } = this.props; + renderError = () => { + const { handleRetry, error, column } = this.props; + const retryButton = <a className="listing__help-link" onClick={handleRetry}>Refresh</a>; - return items.reduce((tmpItems, v) => { - const groupByValue = v[groupBy]; - if (!(groupByValue in tmpItems)) { - tmpItems[groupByValue] = []; - } - tmpItems[groupByValue].push({...v}); - return tmpItems; - }, {}); - } + return ( + <div className={`listing__list listing__list--${column} is-blank`}> + {error && <p>{error}</p>} + + {!handleRetry && + <p>Something went wrong!</p> + } - orderItems() { - const groupedItems = this.getGroupedItems(); - const { order = [] } = this.props; - const leftOverKeys = Object.keys(groupedItems).filter(v => !order.includes(v)); - this.setState({ - orderedItems: order.concat(leftOverKeys).filter(key => groupedItems[key] ).map(key => ({ - name: key, - items: groupedItems[key] || [] - })), - }); + {handleRetry && retryButton && + <> + <div className="listing__blank-icon"> + <SadNoteIcon /> + </div> + <p className="listing__help-text listing__help-text--standout">Something went wrong!</p> + <p className="listing__help-text">Sorry we couldn't load the notes</p> + {retryButton} + </> + } + </div> + ); } render() { return ( <div className="listing"> - <div className="listing__header"></div> {this.renderListItems()} </div> ); diff --git a/opentech/static_src/src/app/src/components/Listing/style.scss b/opentech/static_src/src/app/src/components/Listing/style.scss index 97b57fb30221cf84bc81de50bb72f32c7ff9e6c3..618ac101961b8804509915306bdf585d3f97ae57 100644 --- a/opentech/static_src/src/app/src/components/Listing/style.scss +++ b/opentech/static_src/src/app/src/components/Listing/style.scss @@ -5,36 +5,48 @@ } &__header { - @include submission-list-item; height: $listing-header-height; padding: 20px; } // containing <ul> &__list { - @include media-query(tablet-landscape) { - // only allow columns to be scrolled on larger screens - height: calc(100vh - var(--header-admin-height) - #{$listing-header-height}); - overflow-y: scroll; - } + @include column-scrolling; + overflow-y: scroll; + height: calc(100vh - #{$listing-header-height}); + + // ensures the last item will be at the top of the column after navigating to it via the dropdown + &--applications { + padding-bottom: calc(100vh - var(--last-listing-item-height) - #{$listing-header-height}); - @include media-query(laptop-short) { - // allow for vertical scrolling on laptops - height: calc(100vh - #{$listing-header-height}); + @include media-query(tablet-landscape) { + padding-bottom: calc(100vh - var(--header-admin-height) - var(--last-listing-item-height) - #{$listing-header-height}); + } + + @include media-query(laptop-short) { + padding-bottom: calc(100vh - var(--last-listing-item-height) - #{$listing-header-height}); + } } - &.is-loading { - padding: 20px; - border-right: 2px solid $color--light-mid-grey; + &--notes { + box-shadow: inset 0 -20px 20px -10px $color--light-mid-grey; + overflow-y: auto; + height: 100%; - p { - margin: 20px 0 20px 20px; + @include media-query(tablet-landscape) { + height: calc(75vh - var(--header-admin-height) - #{$listing-header-height}); } - .loading-panel__icon::after { - background: $color--light-grey; + @include media-query(laptop-short) { + // allow for vertical scrolling on laptops + height: calc(60vh - #{$listing-header-height}); } } + + &.is-blank { + padding: 20px; + text-align: center; + } } // inner <li>'s @@ -102,4 +114,35 @@ border-radius: 5px; font-size: 14px; } + + &__blank-icon { + border-radius: 50%; + background-color: $color--light-mid-grey; + padding: 20px; + width: 150px; + height: 150px; + display: flex; + align-items: center; + justify-content: center; + margin: 40px auto 20px; + + svg { + fill: $color--white; + width: 74px; + height: 68px; + } + } + + &__help-text { + margin: 0 0 5px; + color: $color--dark-blue; + + &--standout { + font-weight: $weight--bold; + } + } + + &__help-link { + text-decoration: underline; + } } diff --git a/opentech/static_src/src/app/src/components/ListingDropdown/index.js b/opentech/static_src/src/app/src/components/ListingDropdown/index.js new file mode 100644 index 0000000000000000000000000000000000000000..8f8369dbf68472ac2895c16cabad2fe1ff936d01 --- /dev/null +++ b/opentech/static_src/src/app/src/components/ListingDropdown/index.js @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import smoothscroll from 'smoothscroll-polyfill'; + +export default class ListingDropdown extends React.Component { + static propTypes = { + listRef: PropTypes.object, + groups: PropTypes.array, + scrollOffset: PropTypes.number, + } + + componentDidMount() { + // polyfill element.scrollTo + smoothscroll.polyfill(); + } + + handleChange(e) { + const groupHeaderPosition = document.getElementById(e.target.value).offsetTop - this.props.scrollOffset; + + this.props.listRef.current.scrollTo({ + top: groupHeaderPosition + }) + } + + renderListDropdown() { + const { groups } = this.props; + + return ( + <form className="form form__select"> + <select onChange={(e) => this.handleChange(e)} aria-label="Jump to listing group"> + {groups.map(group => { + return ( + <option key={`listing-heading-${group.key}`} value={group.key}>{group.name}</option> + ) + })} + </select> + </form> + ) + } + + render() { + return ( + <> + {this.renderListDropdown()} + </> + ) + } +} diff --git a/opentech/static_src/src/app/src/components/ListingGroup.js b/opentech/static_src/src/app/src/components/ListingGroup.js index 6508a18b13c4c5574109451211c0fcd6c4af8f6f..901956f2bde2c6642fc5fc599e9043da86c68be9 100644 --- a/opentech/static_src/src/app/src/components/ListingGroup.js +++ b/opentech/static_src/src/app/src/components/ListingGroup.js @@ -10,17 +10,18 @@ export default class ListingGroup extends React.Component { item: PropTypes.shape({ name: PropTypes.string, }), + id: PropTypes.string, }; render() { - const {item, children} = this.props + const {id, item, children} = this.props return ( - <> - <ListingHeading title={item.name} count={children.length} /> + <li id={id}> + <ListingHeading title={item.name} count={children.length} /> <ul> {children} </ul> - </> + </li> ); } } diff --git a/opentech/static_src/src/app/src/components/ListingHeading.js b/opentech/static_src/src/app/src/components/ListingHeading.js index 996f7a933e214c8ae0aad300df9f4d1d8fda27ff..b8b05c897d14df5c0478ee2b3ea98b9a64964a54 100644 --- a/opentech/static_src/src/app/src/components/ListingHeading.js +++ b/opentech/static_src/src/app/src/components/ListingHeading.js @@ -3,11 +3,12 @@ import PropTypes from 'prop-types'; export default class ListingHeading extends React.Component { render() { + const parsedTitle = this.props.title.split(' ').join('-').toLowerCase(); return ( - <li className="listing__item listing__item--heading"> + <div className="listing__item listing__item--heading" id={parsedTitle}> <h5 className="listing__title">{this.props.title}</h5> <span className="listing__count">{this.props.count}</span> - </li> + </div> ); } } diff --git a/opentech/static_src/src/app/src/components/LoadingPanel/styles.scss b/opentech/static_src/src/app/src/components/LoadingPanel/styles.scss index aaaad97ad9b4bb64522d7e988a23995fa27d2053..1a54f514d49bbb1af93c7c127382e14fb21c2302 100644 --- a/opentech/static_src/src/app/src/components/LoadingPanel/styles.scss +++ b/opentech/static_src/src/app/src/components/LoadingPanel/styles.scss @@ -1,5 +1,6 @@ .loading-panel { text-align: center; + padding: 20px; &__icon { font-size: 10px; diff --git a/opentech/static_src/src/app/src/components/NoteListingItem/index.js b/opentech/static_src/src/app/src/components/NoteListingItem/index.js new file mode 100644 index 0000000000000000000000000000000000000000..686828008a1e1e02529e0aecd36427d2f1eb446b --- /dev/null +++ b/opentech/static_src/src/app/src/components/NoteListingItem/index.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +import './styles.scss'; + +export default class NoteListingItem extends React.Component { + static propTypes = { + user: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + timestamp: PropTypes.instanceOf(moment).isRequired, + }; + + parseUser() { + const { user } = this.props; + + if (user.length > 16) { + return `${user.substring(0, 16)}...` + } else { + return user; + } + } + + render() { + const { timestamp, message } = this.props; + + return ( + <li className="note"> + <p className="note__meta"> + <span>{this.parseUser()}</span> + <span className="note__date">{timestamp.format('ll')}</span> + </p> + <div className="note__content" dangerouslySetInnerHTML={{__html: message}} /> + </li> + ); + } +} diff --git a/opentech/static_src/src/app/src/components/NoteListingItem/styles.scss b/opentech/static_src/src/app/src/components/NoteListingItem/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..ee7e8f87d19642189f479dbd0185d1f2e1dc1756 --- /dev/null +++ b/opentech/static_src/src/app/src/components/NoteListingItem/styles.scss @@ -0,0 +1,55 @@ +.note { + margin: 20px; + font-size: 14px; + transition: opacity 200ms ease-out 200ms, transform 200ms ease-out 200ms; + + &.add-note-enter { + opacity: 0; + transform: translate(0, 10px); + } + + &.add-note-enter-done { + opacity: 1; + transform: translate(0, 0); + } + + &__meta { + display: flex; + justify-content: space-between; + font-weight: $weight--bold; + text-transform: uppercase; + margin-bottom: 10px; + color: transparentize( $color--default, .2); + letter-spacing: .3px; + font-size: 15px; + } + + &__date { + color: $color--dark-blue; + margin-left: 10px; + } + + &__content { + margin: 0; + word-break: break-all; + hyphens: auto; + + ul { + list-style: initial; + list-style-position: inside; + } + + ol { + list-style-type: decimal; + list-style-position: inside; + } + + blockquote { + margin: 0; + padding-left: 1em; + margin-top: 1em; + margin-bottom: 1em; + border-left: 2px solid $color--dark-blue; + } + } +} diff --git a/opentech/static_src/src/app/src/components/RichTextForm/index.js b/opentech/static_src/src/app/src/components/RichTextForm/index.js new file mode 100644 index 0000000000000000000000000000000000000000..5f5b42857f3d78231f8f2d64cb0761ae3920c114 --- /dev/null +++ b/opentech/static_src/src/app/src/components/RichTextForm/index.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import RichTextEditor from 'react-rte'; + +const toolbarConfig = { + display: ['INLINE_STYLE_BUTTONS', 'BLOCK_TYPE_BUTTONS', 'BLOCK_TYPE_DROPDOWN', 'LINK_BUTTONS'], + INLINE_STYLE_BUTTONS: [ + {label: 'Bold', style: 'BOLD', className: 'custom-css-class'}, + {label: 'Italic', style: 'ITALIC'}, + {label: 'Underline', style: 'UNDERLINE'}, + {label: 'Blockquote', style: 'blockquote'}, + + ], + BLOCK_TYPE_DROPDOWN: [ + {label: 'Normal', style: 'unstyled'}, + {label: 'H1', style: 'header-four'}, + {label: 'H2', style: 'header-five'}, + ], + BLOCK_TYPE_BUTTONS: [ + {label: 'UL', style: 'unordered-list-item'}, + {label: 'OL', style: 'ordered-list-item'} + ] +}; + +export default class RichTextForm extends React.Component { + static defaultProps = { + disabled: false, + initialValue: '', + }; + + static propTypes = { + disabled: PropTypes.bool.isRequired, + onValueChange: PropTypes.func, + value: PropTypes.string, + instance: PropTypes.string, + onSubmit: PropTypes.func, + }; + + state = { + value: RichTextEditor.createEmptyValue(), + }; + + resetEditor = () => { + this.setState({value: RichTextEditor.createEmptyValue()}); + } + + render() { + const { instance, disabled } = this.props; + + return ( + <div className={ instance } > + <RichTextEditor + disabled={ disabled } + onChange={ this.handleValueChange } + value={ this.state.value } + className="add-note-form__container" + toolbarClassName="add-note-form__toolbar" + editorClassName="add-note-form__editor" + toolbarConfig={toolbarConfig} + /> + <button + disabled={this.isEmpty() || disabled} + onClick={this.handleSubmit} + className={`button ${instance}__button`} + > + Submit + </button> + </div> + ); + } + + isEmpty = () => { + return !this.state.value.getEditorState().getCurrentContent().hasText(); + } + + handleValueChange = value => { + this.setState({value}); + } + + handleSubmit = () => { + this.props.onSubmit(this.state.value.toString('html'), this.resetEditor); + } +} diff --git a/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js b/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js index b917578e041a12a90ea442b59624a7b24bd73ee1..3d00eeac22975f6b0358f229ae01c1f4946d2c90 100644 --- a/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js +++ b/opentech/static_src/src/app/src/components/SubmissionDisplay/index.js @@ -64,14 +64,14 @@ export default class SubmissionDisplay extends Component { </div> ) } - const { meta_questions = [], questions = [], stage} = this.props.submission; + const { metaQuestions = [], questions = [], stage} = this.props.submission; return ( <div className="application-display"> <h3>{stage} Information</h3> <div className="grid grid--proposal-info"> - {meta_questions.map((response, index) => ( + {metaQuestions.map((response, index) => ( <MetaResponse key={index} {...response} /> ))} </div> diff --git a/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss b/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss index 5bb01974ad7f67428f6cced6b0f09f9da9ddad0d..7ad72c885e015d6c5ce5f0fd1eba44d70f5db652 100644 --- a/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss +++ b/opentech/static_src/src/app/src/components/SubmissionDisplay/styles.scss @@ -1,11 +1,4 @@ .application-display { - @include media-query(tablet-landscape) { - height: calc(100vh - var(--header-admin-height) - #{$listing-header-height} - 40px); - overflow-y: scroll; - } - - @include media-query(laptop-short) { - // allow for vertical scrolling on laptops - height: calc(100vh - #{$listing-header-height} - 40px); - } + @include column-scrolling; + padding: 20px; } diff --git a/opentech/static_src/src/app/src/components/Tabber/index.js b/opentech/static_src/src/app/src/components/Tabber/index.js index 1b1ee8a357ddc3c527d589305bed96414800805c..33d34ec6b942576389a6595c22cdedbcafb2fdb4 100644 --- a/opentech/static_src/src/app/src/components/Tabber/index.js +++ b/opentech/static_src/src/app/src/components/Tabber/index.js @@ -1,7 +1,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; - +import './styles.scss'; export const Tab = ({button, children, handleClick}) => <div>{children}</div> Tab.propTypes = { @@ -43,7 +43,12 @@ class Tabber extends Component { <div className="tabber"> <div className="tabber__navigation"> {children.map((child, i) => { - return <a onClick={child.props.handleClick ? child.props.handleClick : () => this.handleClick(i)} className="display-panel__link" key={child.key}>{child.props.button}</a> + return <a + key={child.key} + onClick={child.props.handleClick ? child.props.handleClick : () => this.handleClick(i)} + className={`tabber__link ${this.state.activeTab === i ? 'is-active' : ''}`}> + {child.props.button} + </a> }) } </div> diff --git a/opentech/static_src/src/app/src/components/Tabber/styles.scss b/opentech/static_src/src/app/src/components/Tabber/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..d72ffea8108d7fcf757831d9592c65f0809d5ca7 --- /dev/null +++ b/opentech/static_src/src/app/src/components/Tabber/styles.scss @@ -0,0 +1,52 @@ +.tabber { + &__navigation { + height: $listing-header-height; + display: flex; + justify-content: flex-start; + margin: 0 10px; + + @include media-query(tablet-landscape) { + display: block; + margin: 0 20px; + } + } + + &__link { + align-items: center; + display: inline-flex; + height: calc(100% - 2px); + padding: 0 10px; + position: relative; + color: $color--default; + font-weight: $weight--semibold; + opacity: .6 ; + transition: opacity $transition; + + @include media-query(tablet-landscape) { + margin-right: 20px; + } + + &::after { + content: ''; + height: 0; + width: 100%; + position: absolute; + bottom: 0; + left: 0; + background-color: $color--dark-blue; + transition: height $transition; + } + + &:hover, + &:focus, + &.is-active { + opacity: 1; + } + + &.is-active { + &::after { + height: 4px; + } + } + } +} diff --git a/opentech/static_src/src/app/src/containers/AddNoteForm.js b/opentech/static_src/src/app/src/containers/AddNoteForm.js new file mode 100644 index 0000000000000000000000000000000000000000..8daaf79b03d786317a25f0d1e86ece142a980a96 --- /dev/null +++ b/opentech/static_src/src/app/src/containers/AddNoteForm.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { createNoteForSubmission } from '@actions/notes'; +import RichTextForm from '@components/RichTextForm'; + +import { + getNoteCreatingErrorForSubmission, + getNoteCreatingStateForSubmission, +} from '@selectors/notes'; + +import './AddNoteForm.scss'; + +class AddNoteForm extends React.Component { + static propTypes = { + submitNote: PropTypes.func, + submissionID: PropTypes.number, + error: PropTypes.any, + isCreating: PropTypes.bool, + }; + + render() { + const { error, isCreating } = this.props; + return ( + <> + {Boolean(error) && <p>{error}</p>} + <RichTextForm + disabled={isCreating} + onSubmit={this.onSubmit} + instance="add-note-form" + /> + </> + ); + } + + onSubmit = (message, resetEditor) => { + this.props.submitNote(this.props.submissionID, { + message, + visibility: 'internal', + }).then(() => resetEditor()); + } +} + +const mapStateToProps = (state, ownProps) => ({ + error: getNoteCreatingErrorForSubmission(ownProps.submissionID)(state), + isCreating: getNoteCreatingStateForSubmission(ownProps.submissionID)(state), +}); + +const mapDispatchToProps = dispatch => ({ + submitNote: (submissionID, note) => dispatch(createNoteForSubmission(submissionID, note)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(AddNoteForm); diff --git a/opentech/static_src/src/app/src/containers/AddNoteForm.scss b/opentech/static_src/src/app/src/containers/AddNoteForm.scss new file mode 100644 index 0000000000000000000000000000000000000000..82c8ea9731d11f4acafb1b4b6e1aeb724543b6b6 --- /dev/null +++ b/opentech/static_src/src/app/src/containers/AddNoteForm.scss @@ -0,0 +1,59 @@ +$submit-button-height: 60px; + +.add-note-form { + position: relative; + + @include media-query(tablet-landscape) { + height: 25vh; // .list--notes is set to 75vh + } + + @include media-query(laptop-short) { + height: 40vh; // .list--notes is set to 60vh + } + + &__container { + width: 100%; + height: calc(100% - #{$submit-button-height}); + border: 0; + overflow: hidden; + font-family: inherit; + } + + &__toolbar { + margin: 0; + padding: 10px; + } + + &__editor { + height: 250px; + overflow-y: scroll; + padding-bottom: 60px; + + @include media-query(tablet-landscape) { + height: 100%; + padding-bottom: 95px; + } + } + + &__button { + position: absolute; + bottom: 0; + left: 0; + right: 0; + width: 100%; + border-top: 1px solid $color--mid-grey; + color: $color--dark-blue; + font-weight: $weight--bold; + font-size: 18px; + height: $submit-button-height; + opacity: 1; + } + + textarea, + &__button { + &:disabled { + cursor: not-allowed; + opacity: .5; + } + } +} diff --git a/opentech/static_src/src/app/src/containers/ByRoundListing.js b/opentech/static_src/src/app/src/containers/ByRoundListing.js new file mode 100644 index 0000000000000000000000000000000000000000..8cb617de8b8d1785d9066d9d0bbe1ef907f03d3f --- /dev/null +++ b/opentech/static_src/src/app/src/containers/ByRoundListing.js @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux' + +import GroupedListing from '@components/GroupedListing'; +import { + loadRounds, + loadSubmissionsOfStatuses, + setCurrentSubmission, +} from '@actions/submissions'; +import { + getRounds, + getRoundsFetching, + getRoundsErrored, +} from '@selectors/rounds'; +import { + getSubmissionsByGivenStatuses, + getCurrentSubmissionID, + getByGivenStatusesError, + getByGivenStatusesLoading, +} from '@selectors/submissions'; + + +const loadData = props => { + props.loadRounds() + props.loadSubmissions() +} + +class ByRoundListing extends React.Component { + static propTypes = { + submissionStatuses: PropTypes.arrayOf(PropTypes.string), + loadSubmissions: PropTypes.func, + submissions: PropTypes.arrayOf(PropTypes.object), + isErrored: PropTypes.bool, + setCurrentItem: PropTypes.func, + activeSubmission: PropTypes.number, + shouldSelectFirst: PropTypes.bool, + rounds: PropTypes.array, + isLoading: PropTypes.bool, + }; + + componentDidMount() { + // Update items if round ID is defined. + if ( this.props.submissionStatuses ) { + loadData(this.props) + } + } + + componentDidUpdate(prevProps) { + const { submissionStatuses } = this.props; + if (!submissionStatuses.every(v => prevProps.submissionStatuses.includes(v))) { + loadData(this.props) + } + } + + prepareOrder = () => { + const { isLoading, rounds, submissions } = this.props; + if (isLoading) + return [] + return submissions.map(submission => submission.round) + .filter((round, index, arr) => arr.indexOf(round) === index) + .map((round, i) => ({ + display: rounds[parseInt(round)].title, + key: `round-${round}`, + position: i, + values: [round], + })); + } + + render() { + const { isLoading, isErrored, submissions, setCurrentItem, activeSubmission, shouldSelectFirst} = this.props; + const order = this.prepareOrder(); + return <GroupedListing + isLoading={isLoading} + error={isErrored ? 'Fetching failed.' : undefined} + items={submissions || []} + activeItem={activeSubmission} + onItemSelection={setCurrentItem} + shouldSelectFirst={shouldSelectFirst} + groupBy={'round'} + order={order} + />; + } +} + +const mapStateToProps = (state, ownProps) => ({ + submissions: getSubmissionsByGivenStatuses(ownProps.submissionStatuses)(state), + isErrored: ( + getByGivenStatusesError(ownProps.submissionStatuses)(state) || + getRoundsErrored(state) + ), + isLoading: ( + getByGivenStatusesLoading(ownProps.submissionStatuses)(state) || + getRoundsFetching(state) + ), + activeSubmission: getCurrentSubmissionID(state), + rounds: getRounds(state), +}) + +const mapDispatchToProps = (dispatch, ownProps) => ({ + loadSubmissions: () => dispatch(loadSubmissionsOfStatuses(ownProps.submissionStatuses)), + loadRounds: () => dispatch(loadRounds()), + setCurrentItem: id => dispatch(setCurrentSubmission(id)), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ByRoundListing); diff --git a/opentech/static_src/src/app/src/containers/ByStatusListing.js b/opentech/static_src/src/app/src/containers/ByStatusListing.js index 873036c9467564588ebc4fba69d8bba81002c29c..4eb413c94a9ccc2ca1d7c79024d381b831822432 100644 --- a/opentech/static_src/src/app/src/containers/ByStatusListing.js +++ b/opentech/static_src/src/app/src/containers/ByStatusListing.js @@ -2,9 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux' -import Listing from '@components/Listing'; +import GroupedListing from '@components/GroupedListing'; import { loadCurrentRound, + loadCurrentRoundSubmissions, setCurrentSubmission, } from '@actions/submissions'; import { @@ -17,18 +18,21 @@ import { const loadData = props => { - props.loadSubmissions(['submissions']) + props.loadRound(['workflow']) + props.loadSubmissions() } class ByStatusListing extends React.Component { static propTypes = { - loadSubmissions: PropTypes.func, + loadRound: PropTypes.func.isRequired, + loadSubmissions: PropTypes.func.isRequired, submissions: PropTypes.arrayOf(PropTypes.object), roundID: PropTypes.number, round: PropTypes.object, error: PropTypes.string, setCurrentItem: PropTypes.func, activeSubmission: PropTypes.number, + shouldSelectFirst: PropTypes.bool, }; componentDidMount() { @@ -42,33 +46,39 @@ class ByStatusListing extends React.Component { const { roundID } = this.props; // Update entries if round ID is changed or is not null. if (roundID && prevProps.roundID !== roundID) { - console.log('wooop') loadData(this.props) } } + prepareOrder(round) { + if ( !round ) { return []} + const slugify = value => value.toLowerCase().replace(/\s/g, '-') + const workflow = round.workflow + const order = workflow.reduce((accumulator, {display, value}, idx) => { + const key = slugify(display); + const existing = accumulator[key] || {} + const existingValues = existing.values || [] + const position = existing.position || idx + accumulator[key] = {key, display, position, values: [...existingValues, value]} + return accumulator + }, {}) + const arrayOrder = Object.values(order).sort((a,b) => a.position - b.position) + return arrayOrder + } + render() { - const { error, submissions, round, setCurrentItem, activeSubmission } = this.props; - const isLoading = round && round.isFetching - return <Listing + const { error, submissions, round, setCurrentItem, activeSubmission, shouldSelectFirst } = this.props; + const isLoading = !round || ( round && (round.isFetching || round.submissions.isFetching) ) + const order = this.prepareOrder(round) + return <GroupedListing isLoading={isLoading} error={error} items={submissions} activeItem={activeSubmission} onItemSelection={setCurrentItem} + shouldSelectFirst={shouldSelectFirst} groupBy={'status'} - order={[ - // TODO: Set the proper order of statuses. - 'post_external_review_discussion', - 'in_discussion', - 'more_info', - 'internal_review', - 'post_review_discussion', - 'post_review_more_info', - 'accepted', - 'rejected', - ]} - />; + order={ order } />; } } @@ -81,7 +91,8 @@ const mapStateToProps = state => ({ }) const mapDispatchToProps = dispatch => ({ - loadSubmissions: () => dispatch(loadCurrentRound()), + loadRound: fields => dispatch(loadCurrentRound(fields)), + loadSubmissions: () => dispatch(loadCurrentRoundSubmissions()), setCurrentItem: id => dispatch(setCurrentSubmission(id)), }); diff --git a/opentech/static_src/src/app/src/containers/DisplayPanel/index.js b/opentech/static_src/src/app/src/containers/DisplayPanel/index.js index 87fa4e56cfc7178637fe7a63f91c40804878b58c..b17607d50d550a6ebf1d991368e87dc75c9e5715 100644 --- a/opentech/static_src/src/app/src/containers/DisplayPanel/index.js +++ b/opentech/static_src/src/app/src/containers/DisplayPanel/index.js @@ -13,10 +13,11 @@ import { } from '@selectors/submissions'; import CurrentSubmissionDisplay from '@containers/CurrentSubmissionDisplay' +import AddNoteForm from '@containers/AddNoteForm'; +import NoteListing from '@containers/NoteListing'; import Tabber, {Tab} from '@components/Tabber' import './style.scss'; - class DisplayPanel extends React.Component { static propTypes = { submissionID: PropTypes.number, @@ -28,18 +29,18 @@ class DisplayPanel extends React.Component { }; render() { - const { windowSize: {windowWidth: width} } = this.props; + const { windowSize: {windowWidth: width}, submissionID } = this.props; const { clearSubmission } = this.props; const isMobile = width < 1024; - const { submissionID } = this.props; const submissionLink = "/apply/submissions/" + submissionID + "/"; const submission = <CurrentSubmissionDisplay /> let tabs = [ <Tab button="Notes" key="note"> - <p>Notes</p> + <NoteListing submissionID={submissionID} /> + <AddNoteForm submissionID={submissionID} /> </Tab>, <Tab button="Status" key="status"> <p>Status</p> @@ -61,7 +62,7 @@ class DisplayPanel extends React.Component { { !isMobile && ( <div className="display-panel__column"> <div className="display-panel__header display-panel__header--spacer"></div> - <div className="display-panel__body"> + <div className="display-panel__body display-panel__body--center"> <a target="_blank" rel="noopener noreferrer" href={ submissionLink }>Open in new tab</a> { submission } </div> @@ -91,5 +92,4 @@ const mapDispatchToProps = { clearSubmission: clearCurrentSubmission } - export default connect(mapStateToProps, mapDispatchToProps)(withWindowSizeListener(DisplayPanel)); diff --git a/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss b/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss index 673a9b9266a9501e4a417f087cc8aa11e3bb97d0..52f1dce3709a245af5640445f82c182cf035b2c2 100644 --- a/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss +++ b/opentech/static_src/src/app/src/containers/DisplayPanel/style.scss @@ -3,10 +3,6 @@ @include media-query(tablet-landscape) { display: grid; - grid-template-columns: 1fr 250px; - } - - @include media-query(desktop) { grid-template-columns: 1fr 390px; grid-template-rows: 75px 1fr; } @@ -17,22 +13,23 @@ width: 100%; } - &__body, + &__column { + border-bottom: 2px solid $color--light-mid-grey; + } + &__header { - @include submission-list-item; - padding: 20px; + border-right: 2px solid $color--light-mid-grey; } - // loading container - &__loading { - @include media-query(tablet-portrait) { - height: calc(100vh - var(--header-admin-height) - #{$listing-header-height} - 40px); + &__body { + &--center { + border-right: 2px solid $color--light-mid-grey; } + } - // 100vh - listing header - display-panel__body padding - @include media-query(laptop-short) { - height: calc(100vh - #{$listing-header-height} - 40px); - } + // loading container + &__loading { + @include column-scrolling; } &__header { diff --git a/opentech/static_src/src/app/src/containers/GroupByRoundDetailView.js b/opentech/static_src/src/app/src/containers/GroupByRoundDetailView.js new file mode 100644 index 0000000000000000000000000000000000000000..657a6f871220600cce0727cdf9f1733d6fb71368 --- /dev/null +++ b/opentech/static_src/src/app/src/containers/GroupByRoundDetailView.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import DetailView from '@components/DetailView'; +import ByRoundListing from '@containers/ByRoundListing'; + +export default class GroupByRoundDetailView extends React.Component { + static propTypes = { + submissionStatuses: PropTypes.arrayOf(PropTypes.string), + }; + + render() { + const listing = <ByRoundListing submissionStatuses={this.props.submissionStatuses} />; + return ( + <DetailView listing={listing} /> + ); + } +} diff --git a/opentech/static_src/src/app/src/containers/Note.js b/opentech/static_src/src/app/src/containers/Note.js new file mode 100644 index 0000000000000000000000000000000000000000..9f297145bc34b0939cef8354166084d8db41435d --- /dev/null +++ b/opentech/static_src/src/app/src/containers/Note.js @@ -0,0 +1,34 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +import { getNoteOfID } from '@selectors/notes'; +import NoteListingItem from '@components/NoteListingItem'; + +class Note extends React.Component { + static propTypes = { + note: PropTypes.shape({ + user: PropTypes.string, + timestamp: PropTypes.string, + message: PropTypes.string, + }), + }; + + render() { + const { note } = this.props; + + return <NoteListingItem + user={note.user} + message={note.message} + timestamp={moment(note.timestamp)} + />; + } + +} + +const mapStateToProps = (state, ownProps) => ({ + note: getNoteOfID(ownProps.noteID)(state), +}); + +export default connect(mapStateToProps)(Note); diff --git a/opentech/static_src/src/app/src/containers/NoteListing.js b/opentech/static_src/src/app/src/containers/NoteListing.js new file mode 100644 index 0000000000000000000000000000000000000000..a6f80fbf6135cabc2ef70e86f5443ee42ef29756 --- /dev/null +++ b/opentech/static_src/src/app/src/containers/NoteListing.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { CSSTransition } from 'react-transition-group'; + +import { fetchNotesForSubmission } from '@actions/notes'; +import Listing from '@components/Listing'; +import Note from '@containers/Note'; +import { + getNotesErrorState, + getNoteIDsForSubmissionOfID, + getNotesFetchState, +} from '@selectors/notes'; + +class NoteListing extends React.Component { + static propTypes = { + loadNotes: PropTypes.func, + submissionID: PropTypes.number, + noteIDs: PropTypes.array, + isErrored: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentDidUpdate(prevProps) { + const { isLoading, loadNotes, submissionID } = this.props; + const prevSubmissionID = prevProps.submissionID; + + if( + submissionID !== null && submissionID !== undefined && + prevSubmissionID !== submissionID && !isLoading + ) { + loadNotes(submissionID); + } + } + + componentDidMount() { + const { isLoading, loadNotes, submissionID } = this.props; + + if (submissionID && !isLoading) { + loadNotes(submissionID); + } + } + + handleRetry = () => { + if (this.props.isLoading || !this.props.isErrored) { + return; + } + this.props.loadNotes(this.props.submissionID); + } + + renderItem = noteID => { + return ( + <CSSTransition key={`note-${noteID}`} timeout={200} classNames="add-note"> + <Note key={`note-${noteID}`} noteID={noteID} /> + </CSSTransition> + ); + } + + render() { + const { noteIDs } = this.props; + const passProps = { + isLoading: this.props.isLoading, + isError: this.props.isErrored, + handleRetry: this.handleRetry, + renderItem: this.renderItem, + items: noteIDs, + }; + return ( + <Listing {...passProps} column="notes" /> + ); + } +} + +const mapDispatchToProps = dispatch => ({ + loadNotes: submissionID => dispatch(fetchNotesForSubmission(submissionID)), +}); + +const mapStateToProps = (state, ownProps) => ({ + noteIDs: getNoteIDsForSubmissionOfID(ownProps.submissionID)(state), + isLoading: getNotesFetchState(state), + isErrored: getNotesErrorState(state), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NoteListing); diff --git a/opentech/static_src/src/app/src/datetime.js b/opentech/static_src/src/app/src/datetime.js new file mode 100644 index 0000000000000000000000000000000000000000..f906f3acee590e92f3df0b5c7a9bb66a8ca79e24 --- /dev/null +++ b/opentech/static_src/src/app/src/datetime.js @@ -0,0 +1,8 @@ +import moment from 'moment'; +import 'moment-timezone'; + +// Use GMT globally for all the dates. +moment.tz.setDefault('GMT'); + +// Use en-US locale for all the dates. +moment.locale('en'); diff --git a/opentech/static_src/src/app/src/redux/actions/notes.js b/opentech/static_src/src/app/src/redux/actions/notes.js new file mode 100644 index 0000000000000000000000000000000000000000..b0200db84a47cf27a91b4162eaa4fe1bb79c8384 --- /dev/null +++ b/opentech/static_src/src/app/src/redux/actions/notes.js @@ -0,0 +1,36 @@ +import { CALL_API } from '@middleware/api' + +import api from '@api'; + +export const FAIL_FETCHING_NOTES = 'FAIL_FETCHING_NOTES'; +export const START_FETCHING_NOTES = 'START_FETCHING_NOTES'; +export const UPDATE_NOTES = 'UPDATE_NOTES'; +export const UPDATE_NOTE = 'UPDATE_NOTE'; + +export const START_CREATING_NOTE_FOR_SUBMISSION = 'START_CREATING_NOTE_FOR_SUBMISSION'; +export const FAIL_CREATING_NOTE_FOR_SUBMISSION = 'FAIL_CREATING_NOTE_FOR_SUBMISSION'; + +export const fetchNotesForSubmission = submissionID => (dispatch, getState) => { + return dispatch(fetchNotes(submissionID)) +} + +const fetchNotes = (submissionID) => ({ + [CALL_API]: { + types: [ START_FETCHING_NOTES, UPDATE_NOTES, FAIL_FETCHING_NOTES], + endpoint: api.fetchNotesForSubmission(submissionID), + }, + submissionID, +}) + + +export const createNoteForSubmission = (submissionID, note) => (dispatch, getState) => { + return dispatch(createNote(submissionID, note)) +} + +const createNote = (submissionID, note) => ({ + [CALL_API]: { + types: [ START_CREATING_NOTE_FOR_SUBMISSION, UPDATE_NOTE, FAIL_CREATING_NOTE_FOR_SUBMISSION], + endpoint: api.createNoteForSubmission(submissionID, note), + }, + submissionID, +}) diff --git a/opentech/static_src/src/app/src/redux/actions/submissions.js b/opentech/static_src/src/app/src/redux/actions/submissions.js index db35aec0678e3239cec174980b3de9ff6a8d6459..07520243e5b9c57d07534baf3cc3f67c037ca168 100644 --- a/opentech/static_src/src/app/src/redux/actions/submissions.js +++ b/opentech/static_src/src/app/src/redux/actions/submissions.js @@ -1,18 +1,39 @@ +import { CALL_API } from '@middleware/api' + import api from '@api'; import { getCurrentSubmission, getCurrentSubmissionID, getCurrentRoundID, getCurrentRound, + getCurrentRoundSubmissionIDs, + getRounds, + getSubmissionsByGivenStatuses, } from '@selectors/submissions'; +// Round +export const UPDATE_ROUND = 'UPDATE_ROUND'; +export const START_LOADING_ROUND = 'START_LOADING_ROUND'; +export const FAIL_LOADING_ROUND = 'FAIL_LOADING_ROUND'; + + +// Rounds +export const UPDATE_ROUNDS = 'UPDATE_ROUNDS'; +export const START_LOADING_ROUNDS = 'START_LOADING_ROUNDS'; +export const FAIL_LOADING_ROUNDS = 'FAIL_LOADING_ROUNDS'; + // Submissions by round export const SET_CURRENT_SUBMISSION_ROUND = 'SET_CURRENT_SUBMISSION_ROUND'; export const UPDATE_SUBMISSIONS_BY_ROUND = 'UPDATE_SUBMISSIONS_BY_ROUND'; export const START_LOADING_SUBMISSIONS_BY_ROUND = 'START_LOADING_SUBMISSIONS_BY_ROUND'; export const FAIL_LOADING_SUBMISSIONS_BY_ROUND = 'FAIL_LOADING_SUBMISSIONS_BY_ROUND'; +// Submissions by statuses +export const UPDATE_SUBMISSIONS_BY_STATUSES = 'UPDATE_SUBMISSIONS_BY_STATUSES'; +export const START_LOADING_SUBMISSIONS_BY_STATUSES = 'START_LOADING_SUBMISSIONS_BY_STATUSES'; +export const FAIL_LOADING_SUBMISSIONS_BY_STATUSES = 'FAIL_LOADING_SUBMISSIONS_BY_STATUSES'; + // Submissions export const SET_CURRENT_SUBMISSION = 'SET_CURRENT_SUBMISSION'; export const START_LOADING_SUBMISSION = 'START_LOADING_SUBMISSION'; @@ -20,6 +41,9 @@ export const FAIL_LOADING_SUBMISSION = 'FAIL_LOADING_SUBMISSION'; export const UPDATE_SUBMISSION = 'UPDATE_SUBMISSION'; export const CLEAR_CURRENT_SUBMISSION = 'CLEAR_CURRENT_SUBMISSION'; +// Notes +export const ADD_NOTE_FOR_SUBMISSION = 'ADD_NOTE_FOR_SUBMISSION'; + export const setCurrentSubmissionRound = id => ({ type: SET_CURRENT_SUBMISSION_ROUND, id, @@ -30,53 +54,98 @@ export const setCurrentSubmission = id => ({ id, }); + export const loadCurrentRound = (requiredFields=[]) => (dispatch, getState) => { - const round = getCurrentRound(getState()) + const state = getState() + const round = getCurrentRound(state) - if (round && requiredFields.every(key => round.hasOwnProperty(key))) { + if ( round && requiredFields.every(key => round.hasOwnProperty(key)) ) { return null } - return dispatch(fetchSubmissionsByRound(getCurrentRoundID(getState()))) + return dispatch(fetchRound(getCurrentRoundID(state))) } -export const fetchSubmissionsByRound = roundID => { - return async function(dispatch) { - dispatch(startLoadingSubmissionsByRound(roundID)); - try { - const response = await api.fetchSubmissionsByRound(roundID); - const json = await response.json(); - if (response.ok) { - dispatch(updateSubmissionsByRound(roundID, json)); - } else { - dispatch(failLoadingSubmissionsByRound(json.meta.error)); - } - } catch (e) { - dispatch(failLoadingSubmissionsByRound(e.message)); - } - }; -}; +export const loadRounds = () => (dispatch, getState) => { + const state = getState() + const rounds = getRounds(state) + + if ( rounds && Object.keys(rounds).length !== 0 ) { + return null + } + return dispatch(fetchRounds()) +} +export const loadCurrentRoundSubmissions = () => (dispatch, getState) => { + const state = getState() + const submissions = getCurrentRoundSubmissionIDs(state) -const updateSubmissionsByRound = (roundID, data) => ({ - type: UPDATE_SUBMISSIONS_BY_ROUND, + if ( submissions && submissions.length !== 0 ) { + return null + } + + return dispatch(fetchSubmissionsByRound(getCurrentRoundID(state))) +} + + +const fetchRound = (roundID) => ({ + [CALL_API]: { + types: [ START_LOADING_ROUND, UPDATE_ROUND, FAIL_LOADING_ROUND], + endpoint: api.fetchRound(roundID), + }, roundID, - data, -}); +}) + +const fetchRounds = () => ({ + [CALL_API]: { + types: [ START_LOADING_ROUNDS, UPDATE_ROUNDS, FAIL_LOADING_ROUNDS], + endpoint: api.fetchRounds(), + }, +}) -const startLoadingSubmissionsByRound = (roundID) => ({ - type: START_LOADING_SUBMISSIONS_BY_ROUND, +const fetchSubmissionsByRound = (roundID) => ({ + [CALL_API]: { + types: [ START_LOADING_SUBMISSIONS_BY_ROUND, UPDATE_SUBMISSIONS_BY_ROUND, FAIL_LOADING_SUBMISSIONS_BY_ROUND], + endpoint: api.fetchSubmissionsByRound(roundID), + }, roundID, -}); +}) -const failLoadingSubmissionsByRound = (message) => ({ - type: FAIL_LOADING_SUBMISSIONS_BY_ROUND, - message, -}); +const fetchSubmissionsByStatuses = statuses => { + if(!Array.isArray(statuses)) { + throw new Error("Statuses have to be an array of statuses"); + } + return { + [CALL_API]: { + types: [ START_LOADING_SUBMISSIONS_BY_STATUSES, UPDATE_SUBMISSIONS_BY_STATUSES, FAIL_LOADING_SUBMISSIONS_BY_STATUSES], + endpoint: api.fetchSubmissionsByStatuses(statuses), + }, + statuses, + }; +}; + +export const loadSubmissionsOfStatuses = statuses => (dispatch, getState) => { + const state = getState() + const submissions = getSubmissionsByGivenStatuses(statuses)(state) + + if ( submissions && submissions.length !== 0 ) { + return null + } + + return dispatch(fetchSubmissionsByStatuses(statuses)) +} + +const fetchSubmission = (submissionID) => ({ + [CALL_API]: { + types: [ START_LOADING_SUBMISSION, UPDATE_SUBMISSION, FAIL_LOADING_SUBMISSION], + endpoint: api.fetchSubmission(submissionID), + }, + submissionID, +}) export const loadCurrentSubmission = (requiredFields=[]) => (dispatch, getState) => { const submissionID = getCurrentSubmissionID(getState()) @@ -89,46 +158,16 @@ export const loadCurrentSubmission = (requiredFields=[]) => (dispatch, getState) return null } - return dispatch(fetchSubmission(getCurrentSubmissionID(getState()))) + return dispatch(fetchSubmission(submissionID)) } -export const fetchSubmission = submissionID => { - return async function(dispatch) { - - dispatch(startLoadingSubmission(submissionID)); - try { - const response = await api.fetchSubmission(submissionID); - const json = await response.json(); - if (response.ok) { - dispatch(updateSubmission(submissionID, json)); - } else { - dispatch(failLoadingSubmission(json.meta.error)); - } - } catch (e) { - dispatch(failLoadingSubmission(e.message)); - } - }; -}; - - -const startLoadingSubmission = submissionID => ({ - type: START_LOADING_SUBMISSION, - submissionID, -}); - -const failLoadingSubmission = submissionID => ({ - type: FAIL_LOADING_SUBMISSION, - submissionID, +export const clearCurrentSubmission = () => ({ + type: CLEAR_CURRENT_SUBMISSION, }); - -const updateSubmission = (submissionID, data) => ({ - type: UPDATE_SUBMISSION, +export const appendNoteIDForSubmission = (submissionID, noteID) => ({ + type: ADD_NOTE_FOR_SUBMISSION, submissionID, - data, -}); - -export const clearCurrentSubmission = () => ({ - type: CLEAR_CURRENT_SUBMISSION, + noteID, }); diff --git a/opentech/static_src/src/app/src/redux/middleware/api.js b/opentech/static_src/src/app/src/redux/middleware/api.js new file mode 100644 index 0000000000000000000000000000000000000000..42878fd7415ddc712ccfb061d41496ba3c9813d0 --- /dev/null +++ b/opentech/static_src/src/app/src/redux/middleware/api.js @@ -0,0 +1,87 @@ +import { camelizeKeys, decamelizeKeys } from 'humps' + +import { apiFetch } from '@api/utils' + +const callApi = (endpoint) => { + // If body is an object, decamelize the keys. + const { options } = endpoint; + if (options !== undefined && typeof options.body === 'object') { + endpoint = { + ...endpoint, + options: { + ...options, + body: JSON.stringify(decamelizeKeys(options.body)) + } + } + } + + return apiFetch(endpoint) + .then(response => + response.json().then(json => { + if (!response.ok) { + return Promise.reject({message: json.error}) + } + return camelizeKeys(json) + }) + ) +} + + +export const CALL_API = 'Call API' + + +// A Redux middleware that interprets actions with CALL_API info specified. +// Performs the call and promises when such actions are dispatched. +export default store => next => action => { + const callAPI = action[CALL_API] + + if (callAPI === undefined) { + return next(action) + } + + let { endpoint } = callAPI + const { types } = callAPI + + if (typeof endpoint === 'function') { + endpoint = endpoint(store.getState()) + } + + if (typeof endpoint !== 'object' && typeof endpoint.path !== 'string') { + throw new Error('Specify a string endpoint URL.') + } + + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected an array of three action types.') + + } + if (!types.every(type => typeof type === 'string')) { + throw new Error('Expected action types to be strings.') + } + + const actionWith = data => { + const finalAction = {...action, ...data} + delete finalAction[CALL_API] + return finalAction + } + + const [ requestType, successType, failureType ] = types + next(actionWith({ type: requestType })) + + return new Promise((resolve, reject) => { + return callApi(endpoint).then( + response => { + resolve(); + return next(actionWith({ + data: response, + type: successType + })) + }, + error => { + reject(); + return next(actionWith({ + type: failureType, + error: error.message || 'Something bad happened' + })) + }) + }); +} diff --git a/opentech/static_src/src/app/src/redux/reducers/index.js b/opentech/static_src/src/app/src/redux/reducers/index.js index d1e5e237467ee34a6e305045c469b99560e3e92b..f7e4cfa44051f3fffa375327372c6fb087a8d119 100644 --- a/opentech/static_src/src/app/src/redux/reducers/index.js +++ b/opentech/static_src/src/app/src/redux/reducers/index.js @@ -2,8 +2,10 @@ import { combineReducers } from 'redux' import submissions from '@reducers/submissions'; import rounds from '@reducers/rounds'; +import notes from '@reducers/notes'; export default combineReducers({ + notes, submissions, rounds, }); diff --git a/opentech/static_src/src/app/src/redux/reducers/notes.js b/opentech/static_src/src/app/src/redux/reducers/notes.js new file mode 100644 index 0000000000000000000000000000000000000000..4c4a196fde9887c9862d70f3e6a84b7bd6b682b3 --- /dev/null +++ b/opentech/static_src/src/app/src/redux/reducers/notes.js @@ -0,0 +1,116 @@ +import { combineReducers } from 'redux'; + +import { + UPDATE_NOTE, + UPDATE_NOTES, + START_FETCHING_NOTES, + FAIL_FETCHING_NOTES, + START_CREATING_NOTE_FOR_SUBMISSION, + FAIL_CREATING_NOTE_FOR_SUBMISSION, +} from '@actions/notes'; + +function notesFetching(state = false, action) { + switch (action.type) { + case START_FETCHING_NOTES: + return true; + case UPDATE_NOTES: + case FAIL_FETCHING_NOTES: + return false; + default: + return state; + } +} + +function notesErrored(state = false, action) { + switch (action.type) { + case UPDATE_NOTES: + case START_FETCHING_NOTES: + return false; + case FAIL_FETCHING_NOTES: + return true; + default: + return state; + } +} + +function note(state, action) { + switch (action.type) { + case UPDATE_NOTE: + return { + ...state, + ...action.data, + }; + default: + return state; + } +} + +function notesCreating(state = [], action) { + switch (action.type) { + case START_CREATING_NOTE_FOR_SUBMISSION: + return [ + ...state, + action.submissionID, + ]; + case UPDATE_NOTE: + case FAIL_CREATING_NOTE_FOR_SUBMISSION: + return state.filter(v => v !== action.submissionID); + default: + return state + } +} + + +function notesFailedCreating(state = {}, action) { + switch (action.type) { + case UPDATE_NOTE: + case START_CREATING_NOTE_FOR_SUBMISSION: + return Object.entries(state).reduce((acc, [k, v]) => { + if (parseInt(k) !== action.submissionID) { + acc[k] = v; + } + return acc; + }, {}); + case FAIL_CREATING_NOTE_FOR_SUBMISSION: + return { + ...state, + [action.submissionID]: action.error, + }; + default: + return state + } +} + +function notesByID(state = {}, action) { + switch (action.type) { + case UPDATE_NOTES: + return { + ...state, + ...action.data.results.reduce((newNotesAccumulator, newNote) => { + newNotesAccumulator[newNote.id] = note(state[newNote.id], { + type: UPDATE_NOTE, + data: newNote, + }); + return newNotesAccumulator; + }, {}), + }; + case UPDATE_NOTE: + return { + ...state, + [action.data.id]: note(state[action.data.id], { + type: UPDATE_NOTE, + data: action.data, + }), + }; + default: + return state; + } +} + +export default combineReducers({ + byID: notesByID, + isFetching: notesFetching, + isErrored: notesErrored, + createError: notesFailedCreating, + isCreating: notesCreating, +}); diff --git a/opentech/static_src/src/app/src/redux/reducers/rounds.js b/opentech/static_src/src/app/src/redux/reducers/rounds.js index 0f4253b713fa386196a99315fa6221bb47e12630..fd7117a1978323b9fbd9b384497ddbf92746ae20 100644 --- a/opentech/static_src/src/app/src/redux/reducers/rounds.js +++ b/opentech/static_src/src/app/src/redux/reducers/rounds.js @@ -5,16 +5,22 @@ import { SET_CURRENT_SUBMISSION_ROUND, START_LOADING_SUBMISSIONS_BY_ROUND, UPDATE_SUBMISSIONS_BY_ROUND, + FAIL_LOADING_ROUND, + START_LOADING_ROUND, + UPDATE_ROUND, + UPDATE_ROUNDS, + FAIL_LOADING_ROUNDS, + START_LOADING_ROUNDS, } from '@actions/submissions'; +const submissionsDefaultState = {ids: [], isFetching: false}; -function round(state={id: null, submissions: [], isFetching: false}, action) { - switch(action.type) { +function submissions(state=submissionsDefaultState, action) { + switch (action.type) { case UPDATE_SUBMISSIONS_BY_ROUND: return { ...state, - id: action.roundID, - submissions: action.data.results.map(submission => submission.id), + ids: action.data.results.map(submission => submission.id), isFetching: false, }; case FAIL_LOADING_SUBMISSIONS_BY_ROUND: @@ -22,7 +28,39 @@ function round(state={id: null, submissions: [], isFetching: false}, action) { ...state, isFetching: false, }; + case START_LOADING_SUBMISSIONS_BY_ROUND: + return { + ...state, + isFetching: true, + }; + default: + return state; + } +} + + +function round(state={id: null, submissions: submissionsDefaultState, isFetching: false, workflow: []}, action) { + switch(action.type) { + case UPDATE_SUBMISSIONS_BY_ROUND: + case FAIL_LOADING_SUBMISSIONS_BY_ROUND: case START_LOADING_SUBMISSIONS_BY_ROUND: + return { + ...state, + id: action.roundID, + submissions: submissions(state.submissions, action), + }; + case UPDATE_ROUND: + return { + ...state, + ...action.data, + isFetching: false, + }; + case FAIL_LOADING_ROUND: + return { + ...state, + isFetching: false, + }; + case START_LOADING_ROUND: return { ...state, id: action.roundID, @@ -39,10 +77,24 @@ function roundsByID(state = {}, action) { case UPDATE_SUBMISSIONS_BY_ROUND: case FAIL_LOADING_SUBMISSIONS_BY_ROUND: case START_LOADING_SUBMISSIONS_BY_ROUND: + case UPDATE_ROUND: + case START_LOADING_ROUND: + case FAIL_LOADING_ROUND: return { ...state, [action.roundID]: round(state[action.roundID], action) }; + case UPDATE_ROUNDS: + return { + ...state, + ...action.data.results.reduce((acc, value) => { + acc[value.id] = round(state[value.id], { + type: UPDATE_ROUND, + data: value + }); + return acc; + }, {}), + }; default: return state; } @@ -52,9 +104,12 @@ function roundsByID(state = {}, action) { function errorMessage(state = null, action) { switch(action.type) { case FAIL_LOADING_SUBMISSIONS_BY_ROUND: + case FAIL_LOADING_ROUND: return action.message; case UPDATE_SUBMISSIONS_BY_ROUND: case START_LOADING_SUBMISSIONS_BY_ROUND: + case UPDATE_ROUND: + case START_LOADING_ROUND: return null; default: return state; @@ -62,6 +117,30 @@ function errorMessage(state = null, action) { } +function roundsErrored(state = false, action) { + switch (action.type) { + case START_LOADING_ROUNDS: + case UPDATE_ROUNDS: + return false; + case FAIL_LOADING_ROUNDS: + return true; + default: + return state; + } +} + +function roundsFetching(state = false, action) { + switch (action.type) { + case FAIL_LOADING_ROUNDS: + case UPDATE_ROUNDS: + return false; + case START_LOADING_ROUNDS: + return true; + default: + return state; + } +} + function currentRound(state = null, action) { switch(action.type) { @@ -77,6 +156,8 @@ const rounds = combineReducers({ byID: roundsByID, current: currentRound, error: errorMessage, + isFetching: roundsFetching, + isErrored: roundsErrored, }); export default rounds; diff --git a/opentech/static_src/src/app/src/redux/reducers/submissions.js b/opentech/static_src/src/app/src/redux/reducers/submissions.js index 7563d1e25f0ac19364f82d0c96af3624f2125d4c..946039d231a22cffcd039e6b96c4c212fd94df09 100644 --- a/opentech/static_src/src/app/src/redux/reducers/submissions.js +++ b/opentech/static_src/src/app/src/redux/reducers/submissions.js @@ -7,8 +7,13 @@ import { UPDATE_SUBMISSIONS_BY_ROUND, UPDATE_SUBMISSION, SET_CURRENT_SUBMISSION, + UPDATE_SUBMISSIONS_BY_STATUSES, + START_LOADING_SUBMISSIONS_BY_STATUSES, + FAIL_LOADING_SUBMISSIONS_BY_STATUSES, } from '@actions/submissions'; +import { UPDATE_NOTES, UPDATE_NOTE } from '@actions/notes' + function submission(state, action) { switch(action.type) { @@ -31,6 +36,19 @@ function submission(state, action) { isFetching: false, isErrored: false, }; + case UPDATE_NOTES: + return { + ...state, + comments: action.data.results.map(note => note.id), + }; + case UPDATE_NOTE: + return { + ...state, + comments: [ + action.data.id, + ...(state.comments || []), + ] + }; default: return state; } @@ -42,10 +60,13 @@ function submissionsByID(state = {}, action) { case START_LOADING_SUBMISSION: case FAIL_LOADING_SUBMISSION: case UPDATE_SUBMISSION: + case UPDATE_NOTE: + case UPDATE_NOTES: return { ...state, [action.submissionID]: submission(state[action.submissionID], action), }; + case UPDATE_SUBMISSIONS_BY_STATUSES: case UPDATE_SUBMISSIONS_BY_ROUND: return { ...state, @@ -78,9 +99,52 @@ function currentSubmission(state = null, action) { } +function submissionsByStatuses(state = {}, action) { + switch (action.type) { + case UPDATE_SUBMISSIONS_BY_STATUSES: + return { + ...state, + ...action.data.results.reduce((accumulator, submission) => { + const submissions = accumulator[submission.status] || [] + if ( !submissions.includes(submission.id) ) { + accumulator[submission.status] = [...submissions, submission.id] + } + return state + }, state) + }; + default: + return state + } +} + + +function submissionsFetchingState(state = {isFetching: true, isError: false}, action) { + switch (action.type) { + case FAIL_LOADING_SUBMISSIONS_BY_STATUSES: + return { + isFetching: false, + isErrored: true, + }; + case START_LOADING_SUBMISSIONS_BY_STATUSES: + return { + isFetching: true, + isErrored: false, + }; + case UPDATE_SUBMISSIONS_BY_STATUSES: + return { + isFetching: true, + isErrored: false, + }; + default: + return state + } +} + const submissions = combineReducers({ byID: submissionsByID, current: currentSubmission, + byStatuses: submissionsByStatuses, + fetchingState: submissionsFetchingState, }); export default submissions; diff --git a/opentech/static_src/src/app/src/redux/selectors/notes.js b/opentech/static_src/src/app/src/redux/selectors/notes.js new file mode 100644 index 0000000000000000000000000000000000000000..cd035999ff96834f3262b618a24f43750aa251b8 --- /dev/null +++ b/opentech/static_src/src/app/src/redux/selectors/notes.js @@ -0,0 +1,30 @@ +import { createSelector } from 'reselect'; + +import { getSubmissionOfID } from '@selectors/submissions'; + +const getNotes = state => state.notes.byID; + +export const getNoteOfID = (noteID) => createSelector( + [getNotes], notes => notes[noteID] +); + +export const getNotesFetchState = state => state.notes.isFetching === true; + +export const getNotesErrorState = state => state.notes.isErrored === true; + +export const getNoteIDsForSubmissionOfID = submissionID => createSelector( + [getSubmissionOfID(submissionID)], + submission => (submission || {}).comments || [] +); + +const getNoteCreatingErrors = state => state.notes.createError; + +export const getNoteCreatingErrorForSubmission = submissionID => createSelector( + [getNoteCreatingErrors], errors => errors[submissionID] +); + +const getNoteCreatingState = state => state.notes.isCreating; + +export const getNoteCreatingStateForSubmission = submissionID => createSelector( + [getNoteCreatingState], creatingStates => creatingStates.includes(submissionID) +); diff --git a/opentech/static_src/src/app/src/redux/selectors/rounds.js b/opentech/static_src/src/app/src/redux/selectors/rounds.js new file mode 100644 index 0000000000000000000000000000000000000000..05a7c166b677bb310bab8ae402fa58b87dd3d18f --- /dev/null +++ b/opentech/static_src/src/app/src/redux/selectors/rounds.js @@ -0,0 +1,33 @@ +import { createSelector } from 'reselect'; + +const getRounds = state => state.rounds.byID; + +const getCurrentRoundID = state => state.rounds.current; + +const getCurrentRound = createSelector( + [ getCurrentRoundID, getRounds], + (id, rounds) => { + return rounds[id]; + } +); + +const getCurrentRoundSubmissionIDs = createSelector( + [ getCurrentRound ], + (round) => { + return round ? round.submissions.ids : []; + } +); + +const getRoundsFetching = state => state.rounds.isFetching === true; + +const getRoundsErrored = state => state.rounds.isErrored === true; + +export { + getRounds, + getCurrentRound, + getCurrentRoundID, + getCurrentRoundSubmissionIDs, + getRoundsErrored, + getRoundsFetching, +}; + diff --git a/opentech/static_src/src/app/src/redux/selectors/submissions.js b/opentech/static_src/src/app/src/redux/selectors/submissions.js index 09124b6896da42c2c177f2e706a8b2528deaca54..643684d2e8d4648477eb560e69e25be39dde5f11 100644 --- a/opentech/static_src/src/app/src/redux/selectors/submissions.js +++ b/opentech/static_src/src/app/src/redux/selectors/submissions.js @@ -1,26 +1,45 @@ import { createSelector } from 'reselect'; +import { + getCurrentRound, + getCurrentRoundID, + getCurrentRoundSubmissionIDs, + getRounds, +} from '@selectors/rounds'; + const getSubmissions = state => state.submissions.byID; -const getRounds = state => state.rounds.byID; +const getSubmissionsByStatuses = state => state.submissions.byStatuses; -const getCurrentRoundID = state => state.rounds.current; +const getSubmissionsFetchingState = state => state.submissions.fetchingState; -const getCurrentRound = createSelector( - [ getCurrentRoundID, getRounds], - (id, rounds) => { - return rounds[id]; +const getCurrentSubmissionID = state => state.submissions.current; + +const getByGivenStatusesObject = statuses => createSelector( + [getSubmissionsByStatuses], (byStatuses) => { + return statuses.reduce((acc, status) => acc.concat(byStatuses[status] || []), []) } ); -const getCurrentSubmissionID = state => state.submissions.current; +const getSubmissionsByGivenStatuses = statuses => createSelector( + [getSubmissions, getByGivenStatusesObject(statuses)], + (submissions, byStatus) => byStatus.map(id => submissions[id]) +); + +const getByGivenStatusesError = statuses => createSelector( + [getSubmissionsFetchingState], + state => state.isErrored === true +); +const getByGivenStatusesLoading = statuses => createSelector( + [getByGivenStatusesObject], + state => state.isFetching === true +); const getCurrentRoundSubmissions = createSelector( - [ getCurrentRound, getSubmissions], - (round, submissions) => { - const roundSubmissions = round ? round.submissions : []; - return roundSubmissions.map(submissionID => submissions[submissionID]); + [ getCurrentRoundSubmissionIDs, getSubmissions], + (submissionIDs, submissions) => { + return submissionIDs.map(submissionID => submissions[submissionID]); } ); @@ -32,6 +51,10 @@ const getCurrentSubmission = createSelector( } ); +const getSubmissionOfID = (submissionID) => createSelector( + [getSubmissions], submissions => submissions[submissionID] +); + const getSubmissionLoadingState = state => state.submissions.itemLoading === true; const getSubmissionErrorState = state => state.submissions.itemLoadingError === true; @@ -41,13 +64,19 @@ const getSubmissionsByRoundError = state => state.rounds.error; const getSubmissionsByRoundLoadingState = state => state.submissions.itemsLoading === true; export { + getByGivenStatusesError, + getByGivenStatusesLoading, getCurrentRoundID, getCurrentRound, + getCurrentRoundSubmissionIDs, getCurrentRoundSubmissions, getCurrentSubmission, getCurrentSubmissionID, + getRounds, getSubmissionsByRoundError, getSubmissionsByRoundLoadingState, getSubmissionLoadingState, getSubmissionErrorState, + getSubmissionOfID, + getSubmissionsByGivenStatuses, }; diff --git a/opentech/static_src/src/app/src/redux/store.js b/opentech/static_src/src/app/src/redux/store.js index ed0b719d6c0ff1968140fc66689ce15d53ddaa07..a7002461574d3319ee31b524868b725189d11b00 100644 --- a/opentech/static_src/src/app/src/redux/store.js +++ b/opentech/static_src/src/app/src/redux/store.js @@ -4,9 +4,11 @@ import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly' import logger from 'redux-logger' import rootReducer from '@reducers'; +import api from '@middleware/api' const MIDDLEWARE = [ ReduxThunk, + api, ]; if (process.env.NODE_ENV === 'development') { diff --git a/opentech/static_src/src/app/src/index.js b/opentech/static_src/src/app/src/submissionsByRoundIndex.js similarity index 100% rename from opentech/static_src/src/app/src/index.js rename to opentech/static_src/src/app/src/submissionsByRoundIndex.js diff --git a/opentech/static_src/src/app/src/submissionsByStatusIndex.js b/opentech/static_src/src/app/src/submissionsByStatusIndex.js new file mode 100644 index 0000000000000000000000000000000000000000..e492499ee6a10917cf0f45a021908941ed5f752a --- /dev/null +++ b/opentech/static_src/src/app/src/submissionsByStatusIndex.js @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; + +import SubmissionsByStatusApp from './SubmissionsByStatusApp'; +import createStore from '@redux/store'; + + +const container = document.getElementById('submissions-by-status-react-app'); + +const store = createStore(); + +ReactDOM.render( + <Provider store={store}> + <SubmissionsByStatusApp pageContent={container.innerHTML} statuses={container.dataset.statuses.split(',')} /> + </Provider>, + container +); diff --git a/opentech/static_src/src/app/webpack.base.config.js b/opentech/static_src/src/app/webpack.base.config.js index b6017a03190fc4870e014265d069f79bdd48d734..9942fe66aa894b2721a11ed82524f93bac79f9d1 100644 --- a/opentech/static_src/src/app/webpack.base.config.js +++ b/opentech/static_src/src/app/webpack.base.config.js @@ -1,9 +1,14 @@ var path = require('path'); +var COMMON_ENTRY = ['@babel/polyfill', './src/datetime'] + module.exports = { context: __dirname, - entry: ['@babel/polyfill', './src/index'], + entry: { + submissionsByRound: COMMON_ENTRY.concat(['./src/submissionsByRoundIndex']), + submissionsByStatus: COMMON_ENTRY.concat(['./src/submissionsByStatusIndex']), + }, output: { filename: '[name]-[hash].js' @@ -71,6 +76,7 @@ module.exports = { '@reducers': path.resolve(__dirname, 'src/redux/reducers'), '@selectors': path.resolve(__dirname, 'src/redux/selectors'), '@actions': path.resolve(__dirname, 'src/redux/actions'), + '@middleware': path.resolve(__dirname, 'src/redux/middleware'), '@api': path.resolve(__dirname, 'src/api'), } } diff --git a/opentech/static_src/src/app/webpack.dev.config.js b/opentech/static_src/src/app/webpack.dev.config.js index e609036e52110ccc23ef1cc93158c14893846966..9f5d6d2b0a140c2ae27c3c5e1dec84003c2e234e 100644 --- a/opentech/static_src/src/app/webpack.dev.config.js +++ b/opentech/static_src/src/app/webpack.dev.config.js @@ -25,7 +25,9 @@ config.devServer = { 'Access-Control-Allow-Headers': '*', }, hotOnly: true, - port: 3000 + host: 'localhost', + port: 3000, + overlay: true } config.devtool = 'source-map' diff --git a/opentech/static_src/src/images/add-person.svg b/opentech/static_src/src/images/add-person.svg new file mode 100644 index 0000000000000000000000000000000000000000..4d2cd548b3494d17e1ba5a881de76ddd8fb753e6 --- /dev/null +++ b/opentech/static_src/src/images/add-person.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill="#0d7db0" d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg> diff --git a/opentech/static_src/src/images/arrow-split.svg b/opentech/static_src/src/images/arrow-split.svg new file mode 100644 index 0000000000000000000000000000000000000000..27ba4b500c1fd7fd6b54c479e9769ca4b4b82ef3 --- /dev/null +++ b/opentech/static_src/src/images/arrow-split.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path fill="#0d7db0" d="M14 4l2.29 2.29-2.88 2.88 1.42 1.42 2.88-2.88L20 10V4zm-4 0H4v6l2.29-2.29 4.71 4.7V20h2v-8.41l-5.29-5.3z"/></svg> diff --git a/opentech/static_src/src/images/note.svg b/opentech/static_src/src/images/note.svg new file mode 100644 index 0000000000000000000000000000000000000000..08515968b2079e49ab13d11bb269dac6e3136650 --- /dev/null +++ b/opentech/static_src/src/images/note.svg @@ -0,0 +1 @@ +<svg width="74" height="68" xmlns="http://www.w3.org/2000/svg"><g fill="#000" fill-rule="evenodd"><path d="M41.454 22H10.545C9.692 22 9 22.672 9 23.5c0 .829.692 1.5 1.545 1.5h30.909c.853 0 1.546-.671 1.546-1.5 0-.828-.693-1.5-1.546-1.5z"/><path d="M45.882 1.545c0-.853-.686-1.545-1.53-1.545-.845 0-1.528.692-1.528 1.545v3.19h-6.119v-3.19C36.705.692 36.021 0 35.176 0c-.844 0-1.53.692-1.53 1.545v3.19H27.53v-3.19c0-.853-.684-1.545-1.53-1.545-.844 0-1.53.692-1.53 1.545v3.19h-6.116v-3.19c0-.853-.685-1.545-1.53-1.545-.844 0-1.53.692-1.53 1.545v3.19H9.177v-3.19C9.176.692 8.492 0 7.647 0c-.844 0-1.53.692-1.53 1.545v3.19H0V68h52V4.735h-6.118v-3.19zm3.06 6.281V64.91H3.059V7.826h3.058v2.992c0 .853.686 1.545 1.53 1.545.845 0 1.53-.692 1.53-1.545V7.826h6.117v2.992c0 .853.685 1.545 1.53 1.545.844 0 1.529-.692 1.529-1.545V7.826h6.117v2.992c0 .853.685 1.545 1.53 1.545s1.529-.692 1.529-1.545V7.826h6.117v2.992c0 .853.686 1.545 1.53 1.545.845 0 1.53-.691 1.53-1.545V7.826h6.118v2.992c0 .853.683 1.545 1.528 1.545.844 0 1.53-.692 1.53-1.545V7.826h3.06zM74 12.21C74 8.78 71.315 6 68.001 6 64.685 6 62 8.78 62 12.21v41.92h1.861L62 60.86 68.001 65 74 60.86l-1.861-6.73H74V12.21zm-9 0C65 10.5 66.345 9.106 68 9.106c1.653 0 3 1.393 3 3.104v38.816h-6.002V12.21zm5.513 47.323L68 61.268l-2.514-1.735 1.495-5.402h2.036l1.495 5.402z" fill-rule="nonzero"/><path d="M41.454 31H10.545C9.692 31 9 31.672 9 32.5c0 .829.692 1.5 1.545 1.5h30.909c.853 0 1.546-.671 1.546-1.5 0-.828-.693-1.5-1.546-1.5zM41.454 40H10.545C9.692 40 9 40.672 9 41.5c0 .829.692 1.5 1.545 1.5h30.909c.853 0 1.546-.671 1.546-1.5 0-.828-.693-1.5-1.546-1.5zM26.417 50H10.583C9.709 50 9 50.672 9 51.5c0 .829.709 1.5 1.583 1.5h15.834c.874 0 1.583-.671 1.583-1.5 0-.828-.709-1.5-1.583-1.5z"/></g></svg> \ No newline at end of file diff --git a/opentech/static_src/src/images/sad-note.svg b/opentech/static_src/src/images/sad-note.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e60dfeb78eb23ed89b88102865261bf43125f3e --- /dev/null +++ b/opentech/static_src/src/images/sad-note.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 74 68"><g fill="#000" fill-rule="evenodd"><path d="M45.882 1.545c0-.853-.686-1.545-1.53-1.545-.845 0-1.528.692-1.528 1.545v3.19h-6.119v-3.19C36.705.692 36.021 0 35.176 0c-.844 0-1.53.692-1.53 1.545v3.19H27.53v-3.19c0-.853-.684-1.545-1.53-1.545-.844 0-1.53.692-1.53 1.545v3.19h-6.116v-3.19c0-.853-.685-1.545-1.53-1.545-.844 0-1.53.692-1.53 1.545v3.19H9.177v-3.19C9.176.692 8.492 0 7.647 0c-.844 0-1.53.692-1.53 1.545v3.19H0V68h52V4.735h-6.118v-3.19zm3.06 6.281V64.91H3.059V7.826h3.058v2.992c0 .853.686 1.545 1.53 1.545.845 0 1.53-.692 1.53-1.545V7.826h6.117v2.992c0 .853.685 1.545 1.53 1.545.844 0 1.529-.692 1.529-1.545V7.826h6.117v2.992c0 .853.685 1.545 1.53 1.545s1.529-.692 1.529-1.545V7.826h6.117v2.992c0 .853.686 1.545 1.53 1.545.845 0 1.53-.691 1.53-1.545V7.826h6.118v2.992c0 .853.683 1.545 1.528 1.545.844 0 1.53-.692 1.53-1.545V7.826h3.06zM74 12.21C74 8.78 71.315 6 68.001 6 64.685 6 62 8.78 62 12.21v41.92h1.861L62 60.86 68.001 65 74 60.86l-1.861-6.73H74V12.21zm-9 0C65 10.5 66.345 9.106 68 9.106c1.653 0 3 1.393 3 3.104v38.816h-6.002V12.21zm5.513 47.323L68 61.268l-2.514-1.735 1.495-5.402h2.036l1.495 5.402z" fill-rule="nonzero"/><path d="M34.094 37.953a1.5 1.5 0 1 1-2.41 1.787 7.985 7.985 0 0 0-6.43-3.24 7.978 7.978 0 0 0-6.11 2.834 1.5 1.5 0 1 1-2.289-1.938 10.977 10.977 0 0 1 8.399-3.896c3.527 0 6.779 1.674 8.84 4.453z" fill-rule="nonzero"/><circle cx="19" cy="26" r="2"/><circle cx="32" cy="26" r="2"/></g></svg> diff --git a/opentech/static_src/src/javascript/apply/activity-feed.js b/opentech/static_src/src/javascript/apply/activity-feed.js index 51ebf8839af492c2a2f17dea70d552a860852300..bedfb677d04535df7d04a62a98e6c827ef023d21 100644 --- a/opentech/static_src/src/javascript/apply/activity-feed.js +++ b/opentech/static_src/src/javascript/apply/activity-feed.js @@ -27,7 +27,10 @@ }); // Scroll to the top of the activity feed - $('.js-to-top').click(() => $('.js-activity-feed').animate({scrollTop: 0}, 250)); + $('.js-to-top').click((e) => { + e.preventDefault(); + $('.js-activity-feed').animate({scrollTop: 0}, 250); + }); // Collaps long comments in activity feed. $('.feed__item').each(function () { diff --git a/opentech/static_src/src/javascript/apply/batch-actions.js b/opentech/static_src/src/javascript/apply/batch-actions.js new file mode 100644 index 0000000000000000000000000000000000000000..6d2af16f8d6269e0b28086a1440e76f8f18a2fab --- /dev/null +++ b/opentech/static_src/src/javascript/apply/batch-actions.js @@ -0,0 +1,108 @@ +(function ($) { + + 'use strict'; + + const $body = $('body'); + const $checkbox = $('.js-batch-select'); + const $allCheckboxInput = $('.js-batch-select-all'); + const $batchReviewersButton = $('.js-batch-update-reviewers'); + const $batchTitlesList = $('.js-batch-titles'); + const $batchTitleCount = $('.js-batch-title-count'); + const $hiddenIDlist = $('#id_submission_ids'); + const $toggleBatchList = $('.js-toggle-batch-list'); + const activeClass = 'batch-actions-enabled'; + const closedClass = 'is-closed'; + + $(window).on('load', function () { + toggleBatchActions(); + updateCount(); + }); + + $allCheckboxInput.change(function () { + if ($(this).is(':checked')) { + $checkbox.each(function () { + this.checked = true; + }); + } + else { + $checkbox.each(function () { + this.checked = false; + }); + } + + toggleBatchActions(); + updateCount(); + }); + + $checkbox.change(function () { + // see how many checkboxes are :checked + toggleBatchActions(); + + // updates selected checbox count + updateCount(); + + // reset the check all input + if (!$(this).is(':checked') && $allCheckboxInput.is(':checked')) { + resetCheckAllInput(); + } + }); + + // append selected project titles to batch update reviewer modal + $batchReviewersButton.click(function () { + $batchTitlesList.html(''); + $batchTitleCount.html(''); + $batchTitlesList.addClass(closedClass); + $toggleBatchList.html('Show'); + + let selectedIDs = []; + + $checkbox.each(function () { + if ($(this).is(':checked')) { + const href = $(this).parents('tr').find('.js-title').find('a').attr('href'); + const title = $(this).parents('tr').find('.js-title').data('tooltip'); + + $batchTitlesList.append(` + <a href="${href}" class="modal__list-item" target="_blank" rel="noopener noreferrer" title="${title}"> + ${title} + <svg class="modal__open-link-icon"><use xlink:href="#open-in-new-tab"></use></svg> + </a> + `); + selectedIDs.push($(this).parents('tr').data('record-id')); + } + }); + + $batchTitleCount.append(`${selectedIDs.length} submissions selected`); + $hiddenIDlist.val(selectedIDs.join(',')); + }); + + // show/hide the list of actions + $toggleBatchList.click(e => { + e.preventDefault(); + + if ($('.js-batch-titles').hasClass(closedClass)) { + $toggleBatchList.html('Hide'); + } + else { + $toggleBatchList.html('Show'); + } + + $batchTitlesList.toggleClass(closedClass); + }); + + function toggleBatchActions() { + if ($('.js-batch-select:checked').length) { + $body.addClass(activeClass); + } + else { + $body.removeClass(activeClass); + } + } + + function updateCount() { + $('.js-total-actions').html($('.js-batch-select:checked').length); + } + + function resetCheckAllInput() { + $allCheckboxInput.prop('checked', false); + } +})(jQuery); diff --git a/opentech/static_src/src/sass/apply/abstracts/_mixins.scss b/opentech/static_src/src/sass/apply/abstracts/_mixins.scss index a4ac8a42d3d28b1fc951d0226fb1a445087cdc1e..ed0308e137cac385d8899c56d96e7b2f0d8f30b4 100644 --- a/opentech/static_src/src/sass/apply/abstracts/_mixins.scss +++ b/opentech/static_src/src/sass/apply/abstracts/_mixins.scss @@ -209,3 +209,35 @@ } } } + +@mixin column-scrolling { + @include media-query(tablet-landscape) { + height: calc(100vh - var(--header-admin-height) - #{$listing-header-height}); + overflow-y: scroll; + } + + @include media-query(laptop-short) { + // allow for vertical scrolling on laptops + height: calc(100vh - #{$listing-header-height}); + } +} + +@mixin checkbox-without-label { + input[type='checkbox'] { + margin: 0 auto; + display: block; + width: 20px; + height: 20px; + border: 1px solid $color--mid-grey; + -webkit-appearance: none; // sass-lint:disable-line no-vendor-prefixes + -moz-appearance: none; // sass-lint:disable-line no-vendor-prefixes + appearance: none; + background-color: $color--white; + + &:checked { + background: url('./../../images/tick.svg') $color--dark-blue center no-repeat; + background-size: 12px; + border: 1px solid $color--dark-blue; + } + } +} diff --git a/opentech/static_src/src/sass/apply/abstracts/_variables.scss b/opentech/static_src/src/sass/apply/abstracts/_variables.scss index c4e87dfd68b25855e2d32040473b05eebd7d5b49..3e4831ec425eefa82cd0bb48d50267fc1efc503e 100644 --- a/opentech/static_src/src/sass/apply/abstracts/_variables.scss +++ b/opentech/static_src/src/sass/apply/abstracts/_variables.scss @@ -1,6 +1,8 @@ // sass-lint:disable no-color-keywords; no-color-hex +// Set via JS :root { --header-admin-height: 0; + --last-listing-item-height: 0; } // Default @@ -11,7 +13,6 @@ $color--light-grey: #f7f7f7; $color--light-mid-grey: #e8e8e8; $color--mid-grey: #cfcfcf; $color--mid-dark-grey: #919191; -$color--dark-grey: #404041; // Brand $color--light-blue: #0d7db0; diff --git a/opentech/static_src/src/sass/apply/components/_actions-bar.scss b/opentech/static_src/src/sass/apply/components/_actions-bar.scss new file mode 100644 index 0000000000000000000000000000000000000000..e60aea02d6b353dda695cd0b54b49a9ae2330f2f --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_actions-bar.scss @@ -0,0 +1,54 @@ +.actions-bar { + margin: 20px 0; + width: 100%; + + @include media-query(tablet-landscape) { + display: flex; + justify-content: space-between; + } + + &__inner { + & > * { + margin-bottom: 20px; + } + + @include media-query(tablet-landscape) { + display: flex; + align-items: center; + + & > * { + margin: 0 0 0 20px; + + &:first-child { + margin-left: 0; + } + } + } + + &--left { + display: none; + + @include media-query(tablet-landscape) { + display: flex; + opacity: 0; + pointer-events: none; + transition: opacity $quick-transition; + + .batch-actions-enabled & { + opacity: 1; + pointer-events: all; + } + } + } + } + + &__total { + background-color: $color--light-blue; + color: $color--white; + padding: 6px 16px; + border-radius: 30px; + min-width: 120px; + text-align: center; + font-weight: $weight--semibold; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss index 81df77c7fe8888b7d61db64eda57aaaaa8f5850f..f7e6b5fe2420ffff8aecb5754eed5e70002a72cf 100644 --- a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss +++ b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss @@ -24,7 +24,7 @@ &.title { @include media-query($table-breakpoint) { width: 130px; - padding-left: 20px; + padding-left: 10px; } @include media-query(desktop) { @@ -37,6 +37,14 @@ width: 150px; } } + + &.selected { + @include checkbox-without-label; + + @include media-query($table-breakpoint) { + width: 60px; + } + } } tr { @@ -57,13 +65,12 @@ @include media-query($table-breakpoint) { display: flex; align-items: center; - padding-top: 20px; + padding-top: 10px; padding-left: 10px; } @include media-query(desktop) { display: table-cell; - padding-left: 20px; } &.has-tooltip { @@ -142,6 +149,16 @@ } } + // batch action checkboxes + &.selected { + @include checkbox-without-label; + display: none; + + @include media-query($table-breakpoint) { + display: table-cell; + } + } + // arrow to toggle project info - added via js @include media-query($table-breakpoint) { .arrow { diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss index 5ebcce8979c9328a65d199918924f6676d478454..1b35fecaee3e34a6f7c3126a87de812b780706dc 100644 --- a/opentech/static_src/src/sass/apply/components/_button.scss +++ b/opentech/static_src/src/sass/apply/components/_button.scss @@ -80,25 +80,10 @@ width: 100%; @include media-query(tablet-landscape) { - background: none; - padding: 0 10px; - border: 0; - align-items: center; - justify-content: flex-start; - max-width: initial; - width: auto; - &::before { content: ''; background-image: url('./../../images/filters.svg'); - background-color: transparent; - background-position: left center; transform: rotate(90deg); - background-size: 20px; - width: 20px; - height: 20px; - display: inline-block; - margin-right: 10px; } } } @@ -221,4 +206,54 @@ fill: $color--white; } } + + &--action { + display: flex; + font-weight: $weight--normal; + color: $color--default; + transition: none; + + @include media-query(tablet-landscape) { + background: none; + padding: 0; + border: 0; + align-items: center; + justify-content: flex-start; + width: auto; + transition: opacity $transition; + opacity: .7; + font-weight: $weight--semibold; + + &:hover { + opacity: 1; + } + + &::before { + content: ''; + background-image: url('./../../images/filters.svg'); + background-color: transparent; + background-position: left center; + background-size: 20px; + width: 20px; + height: 20px; + display: inline-block; + margin-right: 10px; + } + } + } + + &--change-status { + display: none; + + &::before { + background-image: url('./../../images/arrow-split.svg'); + transform: rotate(90deg); + } + } + + &--reviewers { + &::before { + background-image: url('./../../images/add-person.svg'); + } + } } diff --git a/opentech/static_src/src/sass/apply/components/_form.scss b/opentech/static_src/src/sass/apply/components/_form.scss index c7708add8d06a46a2ac14c522915aa821a09bc82..bb1fad78da89d37102766f16e9bd0e48fa2702d1 100644 --- a/opentech/static_src/src/sass/apply/components/_form.scss +++ b/opentech/static_src/src/sass/apply/components/_form.scss @@ -17,11 +17,10 @@ } } - &--search { + &--search-desktop { position: relative; max-width: 300px; margin-top: $mobile-gutter; - width: 100%; @include media-query(tablet-landscape) { max-width: 280px; diff --git a/opentech/static_src/src/sass/apply/components/_link.scss b/opentech/static_src/src/sass/apply/components/_link.scss index 9217908648e8a569ebe7e7d86b8769db2ab0d153..ad8fbec69a79fe53c36ea9cf1eb8c98bd73ea2a7 100644 --- a/opentech/static_src/src/sass/apply/components/_link.scss +++ b/opentech/static_src/src/sass/apply/components/_link.scss @@ -96,6 +96,10 @@ color: $color--white; background: url('./../../images/speech-bubble-blue.svg') no-repeat center; + .app-open & { + display: none; + } + @include media-query(tablet-portrait) { @include button($color--light-blue, $color--dark-blue); right: 5%; @@ -166,6 +170,7 @@ color: $color--black-50; opacity: 0; transition: opacity $transition; + pointer-events: none; @include media-query(tablet-portrait) { right: 30px; @@ -174,6 +179,7 @@ &.is-visible { opacity: 1; + pointer-events: all; } } diff --git a/opentech/static_src/src/sass/apply/components/_messages.scss b/opentech/static_src/src/sass/apply/components/_messages.scss index f568ead89a69a28b226f0e4c52e0642618a9a8c4..69ae151b8e57fe22635e7cd42e6df7389033a3cf 100644 --- a/opentech/static_src/src/sass/apply/components/_messages.scss +++ b/opentech/static_src/src/sass/apply/components/_messages.scss @@ -1,46 +1,73 @@ .messages { - position: relative; - right: 50%; - left: 50%; - width: 100vw; - margin-right: -50vw; - margin-left: -50vw; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 20; + pointer-events: none; &__text { position: relative; + padding: 15px 20px; + color: $color--white; + font-size: 14px; + opacity: 1; + transition: opacity, max-height, $transition; + pointer-events: all; max-height: 1000px; - padding: 15px; - padding-right: 35px; - border: solid 1px; + + @include media-query(desktop) { + padding: 15px 30px; + } &--info, &--success { - background: $color--pastel-green; - border-color: darken($color--pastel-green, 20%); + background: $color--dark-blue; } &--error, &--warning { - font-weight: bold; - color: $color--white; - background: $color--error; - border-color: darken($color--error, 20%); + background: darken($color--error, 20%); + } + + &--debug { + background: darken($color--pink, 30%); } &--hide { + opacity: 0; + pointer-events: none; max-height: 0; - padding-top: 0; - padding-bottom: 0; - border: 0 none; - transition: all $transition; // sass-lint:disable-line no-transition-all - transform-origin: top; + padding: 0; } } - &__close { - position: absolute; - top: 15px; - right: 15px; + &__inner { + display: flex; + align-items: center; + max-width: $site-width; + margin: 0 auto; + } + + &__copy { + padding-right: 20px; + margin: 0; + flex: 1; + } + + &__button { + margin-left: auto; + color: $color--dark-blue; + background-color: $color--white; + display: inline-block; + font-weight: $weight--bold; + padding: 2px 20px; + } + &__icon { + width: 25px; + height: 25px; + fill: $color--white; + margin-right: 10px; } } diff --git a/opentech/static_src/src/sass/apply/components/_modal.scss b/opentech/static_src/src/sass/apply/components/_modal.scss index beeb6c0ae4d8e7246db72fb32ba9a05671b1268d..d486cca48b00a9e2f839ecf737089c43b4cef4bd 100644 --- a/opentech/static_src/src/sass/apply/components/_modal.scss +++ b/opentech/static_src/src/sass/apply/components/_modal.scss @@ -1,10 +1,72 @@ .modal { + $root: &; display: none; width: calc(100% - 40px); padding: 20px; + &--secondary { + padding: 0; + } + @include media-query(small-tablet) { width: 580px; padding: 30px; } + + &__header-bar { + color: $color--white; + background-color: $color--dark-blue; + margin: -24px -24px 0; + padding: 15px; + text-align: center; + } + + &__list { + max-height: 200px; + overflow: scroll; + margin: 0 -24px 20px; + padding: 0; + border-bottom: 2px solid $color--light-mid-grey; + box-shadow: inset 0 -10px 20px -10px $color--mid-grey; + transition: max-height $transition; + + &.is-closed { + max-height: 0; + border-bottom: 0; + } + } + + &__list-item { + display: block; + font-size: map-get($font-sizes, zeta); + padding: 12px 28px; + border-bottom: 2px solid $color--light-mid-grey; + margin: 0; + color: $color--default; + + &--meta { + color: $color--dark-blue; + font-weight: $weight--semibold; + display: flex; + justify-content: space-between; + margin: 0 -24px; + } + } + + &__hide-link { + text-decoration: underline; + } + + &__open-link-icon { + width: 20px; + height: 20px; + fill: $color--dark-grey; + opacity: 0; + transition: opacity $quick-transition; + pointer-events: none; + + #{$root}__list-item:hover & { + opacity: 1; + } + } } diff --git a/opentech/static_src/src/sass/apply/fancybox.scss b/opentech/static_src/src/sass/apply/fancybox.scss index 440a4fc308a538505f29399ac7eb603b85581d2b..e01a47923d6f75200525d969ec325368fda585f0 100644 --- a/opentech/static_src/src/sass/apply/fancybox.scss +++ b/opentech/static_src/src/sass/apply/fancybox.scss @@ -325,6 +325,10 @@ body.fancybox-iosfix { position: relative; overflow: visible; shape-rendering: geometricPrecision; + + .modal--secondary & { + display: none; + } } .fancybox-button svg path { @@ -424,6 +428,13 @@ body.fancybox-iosfix { transition: background-color .25s; box-sizing: border-box; z-index: 2; + + .modal--secondary & { + color: white; + font-size: 40px; + margin-top: 12px; + margin-right: 8px; + } } .fancybox-close-small:focus { diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss index 4423f6ed552181b4708da92017bba897de5f1aa7..386c5f11111e127b697b8f9782045cb4176366b7 100644 --- a/opentech/static_src/src/sass/apply/main.scss +++ b/opentech/static_src/src/sass/apply/main.scss @@ -12,6 +12,7 @@ @import 'components/all-rounds-table'; @import 'components/admin-bar'; @import 'components/activity-feed'; +@import 'components/actions-bar'; @import 'components/comment'; @import 'components/button'; @import 'components/editor'; diff --git a/opentech/templates/includes/message_item.html b/opentech/templates/includes/message_item.html index 49f1a6f123431441e3cd4bd0f7346156998ee855..3a63088a9f710f0ccf12ab3753652dcfb4916a20 100644 --- a/opentech/templates/includes/message_item.html +++ b/opentech/templates/includes/message_item.html @@ -1,8 +1,9 @@ <li class="messages__text{% if tag %} messages__text--{{ tag }}{% endif %}{% if close %} js-message{% endif %}"> - {{ message }} - {% if close %} - <a href="#" class="messages__close js-close-message"> - <svg class="icon icon--close"><use xlink:href="#cross"></use></svg> - </a> - {% endif %} + <div class="messages__inner"> + <svg class="messages__icon"><use xlink:href="#exclamation-point"></use></svg> + <p class="messages__copy">{{ message }}</p> + {% if close %} + <a href="#" class="messages__button js-close-message">Ok</a> + {% endif %} + </div> </li> diff --git a/opentech/templates/includes/sprites.html b/opentech/templates/includes/sprites.html index b952c2e9570347fb06605d39f5bbe2b88d03c791..42bf4cb3d50f9fe3d9a3ac8da8fd2091430dcb80 100644 --- a/opentech/templates/includes/sprites.html +++ b/opentech/templates/includes/sprites.html @@ -296,4 +296,12 @@ <symbol id="padlock" viewBox="0 0 64 64"> <g id="Icon-Lock" transform="translate(284 430)"><path class="st0" d="M-237.7-401.3h-3v-6.4c0-6.2-5.1-11.3-11.3-11.3-6.2 0-11.3 5.1-11.3 11.3v6.4h-3v-6.4c0-7.9 6.4-14.3 14.3-14.3s14.3 6.4 14.3 14.3v6.4" id="Fill-66"/><path class="st0" d="M-239.2-374.1h-25.6c-2.6 0-4.8-2.2-4.8-4.8v-19.2c0-2.6 2.2-4.8 4.8-4.8h25.6c2.6 0 4.8 2.2 4.8 4.8v19.2c0 2.7-2.2 4.8-4.8 4.8zm-25.6-25.6c-.9 0-1.6.7-1.6 1.6v19.2c0 .9.7 1.6 1.6 1.6h25.6c.9 0 1.6-.7 1.6-1.6v-19.2c0-.9-.7-1.6-1.6-1.6h-25.6z" id="Fill-67"/><path class="st0" d="M-248.8-393.3c0 1.8-1.4 3.2-3.2 3.2s-3.2-1.4-3.2-3.2 1.4-3.2 3.2-3.2 3.2 1.5 3.2 3.2" id="Fill-68"/><path class="st0" id="Fill-69" d="M-251.2-393.3h-1.6l-1.6 9.6h4.8l-1.6-9.6"/></g> </symbol> + + <symbol id="exclamation-point" viewbox="0 0 24 24"> + <path fill="none" d="M0 0h24v24H0V0z"/><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/> + </symbol> + + <symbol id="open-in-new-tab" viewbox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/> + </symbol> </svg> diff --git a/package-lock.json b/package-lock.json index c03d14147002a5f8545753a762f00216e4b07807..d9f73a8d8ae9bbf64c4dfefce0aef828f32433ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1456,6 +1456,11 @@ "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", "dev": true }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -1604,6 +1609,22 @@ "util.promisify": "^1.0.0" } }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + } + } + }, "bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -2166,6 +2187,11 @@ "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", "dev": true }, + "class-autobind": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/class-autobind/-/class-autobind-0.1.4.tgz", + "integrity": "sha1-NFFsSRZ8+NP2Od3Bhrz6Imiv/zQ=" + }, "class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -2187,6 +2213,11 @@ } } }, + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + }, "clean-css": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz", @@ -3109,6 +3140,63 @@ "domelementtype": "1" } }, + "draft-js": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.10.5.tgz", + "integrity": "sha512-LE6jSCV9nkPhfVX2ggcRLA4FKs6zWq9ceuO/88BpXdNCS7mjRTgs0NsV6piUCJX9YxMsB9An33wnkMmU2sD2Zg==", + "requires": { + "fbjs": "^0.8.15", + "immutable": "~3.7.4", + "object-assign": "^4.1.0" + } + }, + "draft-js-export-html": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/draft-js-export-html/-/draft-js-export-html-1.2.0.tgz", + "integrity": "sha1-HL4reOH+10/CnHzcv9dUBGjsogk=", + "requires": { + "draft-js-utils": "^1.2.0" + } + }, + "draft-js-export-markdown": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/draft-js-export-markdown/-/draft-js-export-markdown-1.3.0.tgz", + "integrity": "sha512-kOiDGQ9KehcbYYcwzlkR+Gja6svEwIgId1gz3EtEVsZ09cxZaV13Qlkydm0J5wPy5Omthvdpj0Iw1B2E4BZRZQ==", + "requires": { + "draft-js-utils": "^1.2.0" + } + }, + "draft-js-import-element": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/draft-js-import-element/-/draft-js-import-element-1.2.2.tgz", + "integrity": "sha512-atwfQFg5YWsKdBiOIkIYxYh9lOsS5gzzDaqV89GgG1UIb/E1689FI9PsH2OmuJ4DUhHouzBWAAPSa5DerGNnBQ==", + "requires": { + "draft-js-utils": "^1.2.4", + "synthetic-dom": "^1.2.0" + } + }, + "draft-js-import-html": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/draft-js-import-html/-/draft-js-import-html-1.2.1.tgz", + "integrity": "sha512-FP1y9kdmOVDvOxoI4ny+H0g4CVoTQwdW++Zjf+qMsnz07NsYOCLcQ34j7TiwuPfArFAcOjBOc41Mn+qOa1G14w==", + "requires": { + "draft-js-import-element": "^1.2.1" + } + }, + "draft-js-import-markdown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/draft-js-import-markdown/-/draft-js-import-markdown-1.2.3.tgz", + "integrity": "sha512-NPcXwWSsIA+uwASzdJWLQM4y+xW1vTDtDdIDHCHfP76i9cx8zYpH75GW8Ezz8L9SW2qetNcFW056Hj2yxRZ+2g==", + "requires": { + "draft-js-import-element": "^1.2.1", + "synthetic-dom": "^1.2.0" + } + }, + "draft-js-utils": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/draft-js-utils/-/draft-js-utils-1.2.4.tgz", + "integrity": "sha512-oy7WL6VCcSJ1WOUVCxkU0t/nGoLs/Kv0+zKalC61WjFTN4I2Lt1I8Oj5m4oUFBxfF7K9+0C0U5ilgvb4F4rovg==" + }, "duplexer": { "version": "0.1.1", "resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", @@ -3180,6 +3268,14 @@ "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", "dev": true }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "requires": { + "iconv-lite": "~0.4.13" + } + }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -3921,6 +4017,27 @@ "websocket-driver": ">=0.5.1" } }, + "fbjs": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.17.tgz", + "integrity": "sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90=", + "requires": { + "core-js": "^1.0.0", + "isomorphic-fetch": "^2.1.1", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^0.7.18" + }, + "dependencies": { + "core-js": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", + "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY=" + } + } + }, "figgy-pudding": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.1.tgz", @@ -5707,11 +5824,15 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -5749,6 +5870,11 @@ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true }, + "immutable": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", + "integrity": "sha1-E7TTyxK++hVIKib+Gy665kAHHks=" + }, "import-fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", @@ -6260,8 +6386,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-symbol": { "version": "1.0.2", @@ -6320,6 +6445,15 @@ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" }, + "isomorphic-fetch": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", + "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", + "requires": { + "node-fetch": "^1.0.1", + "whatwg-fetch": ">=0.10.0" + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -6330,6 +6464,11 @@ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.0.tgz", "integrity": "sha512-wlEBIZ5LP8usDylWbDNhKPEFVFdI5hCHpnVoT/Ysvoi/PRhJENm/Rlh9TvjYB38HFfKZN7OzEbRjmjvLkFw11g==" }, + "js-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz", + "integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s=" + }, "js-levenshtein": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.4.tgz", @@ -7133,6 +7272,19 @@ } } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, + "moment-timezone": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.23.tgz", + "integrity": "sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==", + "requires": { + "moment": ">= 2.9.0" + } + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -7231,6 +7383,15 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "^0.1.11", + "is-stream": "^1.0.1" + } + }, "node-forge": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.5.tgz", @@ -8133,6 +8294,14 @@ "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=", "dev": true }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -8393,6 +8562,30 @@ } } }, + "react-rte": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/react-rte/-/react-rte-0.16.1.tgz", + "integrity": "sha512-CD5kf+6CHqOgJ1yB0i9tkMMch13wOXW5/FQx60gb7nzhXC1ZeFJjtW9dYfCVlfw1AvksHf+lMmKTjhIwyfZR7w==", + "requires": { + "babel-runtime": "^6.23.0", + "class-autobind": "^0.1.4", + "classnames": "^2.2.5", + "draft-js": ">=0.10.0", + "draft-js-export-html": ">=0.6.0", + "draft-js-export-markdown": ">=0.3.0", + "draft-js-import-html": ">=0.4.0", + "draft-js-import-markdown": ">=0.3.0", + "draft-js-utils": ">=0.2.0", + "immutable": "^3.8.1" + }, + "dependencies": { + "immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=" + } + } + }, "react-transition-group": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.3.tgz", @@ -9417,8 +9610,7 @@ "setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", - "dev": true + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=" }, "setprototypeof": { "version": "1.1.0", @@ -9504,6 +9696,11 @@ } } }, + "smoothscroll-polyfill": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.3.tgz", + "integrity": "sha512-aUg0sY8XlWw9reC3VGlVdmC9W4K565alN4t8Cm50kULz53NB4GvsZbrinWPLqYqLolY60NBdqHDyh89MqDUc/Q==" + }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", @@ -10054,6 +10251,11 @@ "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" }, + "synthetic-dom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/synthetic-dom/-/synthetic-dom-1.2.0.tgz", + "integrity": "sha1-81iar+K14pnzN7sylzqb5C3VYl4=" + }, "table": { "version": "4.0.3", "resolved": "http://registry.npmjs.org/table/-/table-4.0.3.tgz", @@ -10485,6 +10687,11 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "ua-parser-js": { + "version": "0.7.19", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz", + "integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==" + }, "uglify-js": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", @@ -11531,6 +11738,11 @@ "integrity": "sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==", "dev": true }, + "whatwg-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", + "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==" + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index 7ad0fcfa4597981cdb4b18bee2f73c21963125a2..ec640e8844fe3bdaffbea8c408ce7f7f8213e7b2 100644 --- a/package.json +++ b/package.json @@ -23,17 +23,23 @@ "gulp-size": "^3.0.0", "gulp-touch-cmd": "0.0.1", "gulp-uglify": "^3.0.1", + "humps": "^2.0.1", + "js-cookie": "^2.2.0", + "moment": "^2.24.0", + "moment-timezone": "^0.5.23", "node-sass-import-once": "^1.2.0", "prop-types": "^15.6.2", "react": "^16.7.0", "react-dom": "^16.7.0", "react-redux": "^6.0.0", + "react-rte": "^0.16.1", "react-transition-group": "^2.5.3", "react-window-size-listener": "^1.2.3", "redux": "^4.0.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.3.0", - "reselect": "^4.0.0" + "reselect": "^4.0.0", + "smoothscroll-polyfill": "^0.4.3" }, "devDependencies": { "@babel/core": "^7.2.2",