diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index a7cb75e0afddeb0094c57b55fb515547cb29ac91..9ff1e0251ce0fb6d896c849eaa4a8889bf30f15d 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, **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=None, 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 TypeError: + 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 6366f0f98ca9aaf61b063efe22b4fd2c8fcd5ea2..4712437346508a82cdb629f273f222fb41caa843 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -134,6 +134,18 @@ 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): + 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 +156,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..d0d920c3e8aec1c95b52c45ed62a3afde57b4a37 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.values_list('id', flat=True)), ) @@ -42,8 +42,8 @@ def send_mail_task(**kwargs): @app.task -def update_message_status(response, message_id): +def update_message_status(response, message_ids): from .models import Message - message = Message.objects.get(id=message_id) + message = Message.objects.filter(id__in=message_ids) message.external_id = response['id'] message.update_status(response['status']) 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/funds/forms.py b/opentech/apply/funds/forms.py index 0b1df3d3b4a79c45b485b0cff5600b80a4a3961a..5e4e7de599fbd619d93ab181ae4f9a0e22ba6eb3 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 + submitted_reviewers = User.objects.filter(id__in=self.instance.reviews.values('author')) - 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, submitted_reviewers) + if self.can_alter_external_reviewers(self.instance, self.user): + self.prepare_field('reviewer_reviewers', reviewers, 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/tables.py b/opentech/apply/funds/tables.py index 369024b2b94bb10d39ab0e48897eb2a30553110d..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 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/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/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/views.py b/opentech/apply/funds/views.py index 78df28b2b8b3a3c0aef24fef6a7a6821b3b3a462..8b498fa78a8d686da67037ddf9676c8562b21363 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, @@ -69,15 +76,48 @@ class BaseAdminSubmissionsTable(SingleTableMixin, FilterView): 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): @@ -107,12 +147,18 @@ class SubmissionOverviewView(AllActivityContextMixin, BaseAdminSubmissionsTable) ) -class SubmissionListView(AllActivityContextMixin, BaseAdminSubmissionsTable): +class SubmissionListView(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions.html' + form_views = [ + BatchUpdateReviewersView + ] -class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable): +class SubmissionsByRound(AllActivityContextMixin, BaseAdminSubmissionsTable, DelegateableListView): template_name = 'funds/submissions_by_round.html' + form_views = [ + BatchUpdateReviewersView + ] excluded_fields = ('round', 'lead', 'fund') @@ -238,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 = [ 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/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/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 b4dd94038021114927a8d1fa8ba106e9728cc191..ed0308e137cac385d8899c56d96e7b2f0d8f30b4 100644 --- a/opentech/static_src/src/sass/apply/abstracts/_mixins.scss +++ b/opentech/static_src/src/sass/apply/abstracts/_mixins.scss @@ -221,3 +221,23 @@ 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/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/_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/sprites.html b/opentech/templates/includes/sprites.html index f97fe2b51ba63be09caed92a276cc3481e976769..42bf4cb3d50f9fe3d9a3ac8da8fd2091430dcb80 100644 --- a/opentech/templates/includes/sprites.html +++ b/opentech/templates/includes/sprites.html @@ -300,4 +300,8 @@ <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>