diff --git a/kitsune/groups/jinja2/groups/profile.html b/kitsune/groups/jinja2/groups/profile.html index 3a62b1f031b..4436037deb0 100755 --- a/kitsune/groups/jinja2/groups/profile.html +++ b/kitsune/groups/jinja2/groups/profile.html @@ -18,10 +18,12 @@ {{ _('Edit in admin') }} {% endif %}

{{ profile.group.name }}

- - {% if user_can_edit %} - {{ _('Edit group profile') }} - {% endif %} + {% if in_staff_group(user) %} +

{{ _('Private message group members') }}

+ {% endif %} + {% if user_can_edit %} + {{ _('Edit group profile') }} + {% endif %}
{{ profile.information_html|safe }}
diff --git a/kitsune/messages/api.py b/kitsune/messages/api.py new file mode 100644 index 00000000000..7a8c372f7c5 --- /dev/null +++ b/kitsune/messages/api.py @@ -0,0 +1,51 @@ +from django.conf import settings + +from django.contrib.auth.models import Group, User +from django.db.models import Q +from django.views.decorators.http import require_GET + +from kitsune.access.decorators import login_required +from kitsune.sumo.decorators import json_view +from kitsune.sumo.utils import webpack_static +from kitsune.users.templatetags.jinja_helpers import profile_avatar + + +@login_required +@require_GET +@json_view +def get_autocomplete_suggestions(request): + """An API to provide auto-complete data for user names or groups.""" + pre = request.GET.get("term", "") or request.GET.get("query", "") + if not pre or not request.user.is_authenticated: + return [] + + def create_suggestion(item): + """Create a dictionary object for the autocomplete suggestion.""" + is_user = isinstance(item, User) + return { + "type": "User" if is_user else "Group", + "type_icon": webpack_static( + settings.DEFAULT_USER_ICON if is_user else settings.DEFAULT_GROUP_ICON + ), + "name": item.username if is_user else item.name, + "display_name": item.profile.name if is_user else item.name, + "avatar": profile_avatar(item, 24) + if is_user + else webpack_static(settings.DEFAULT_AVATAR), + } + + suggestions = [] + user_criteria = Q(username__istartswith=pre) | Q(profile__name__istartswith=pre) + users = User.objects.filter( + user_criteria, is_active=True, profile__is_fxa_migrated=True + ).select_related("profile")[:10] + + for user in users: + suggestions.append(create_suggestion(user)) + + if request.user.profile.in_staff_group: + groups = Group.objects.filter(name__istartswith=pre)[:10] + for group in groups: + suggestions.append(create_suggestion(group)) + + return suggestions diff --git a/kitsune/messages/forms.py b/kitsune/messages/forms.py index 8c6257393e8..75eff721216 100644 --- a/kitsune/messages/forms.py +++ b/kitsune/messages/forms.py @@ -1,24 +1,30 @@ from django import forms from django.utils.translation import gettext_lazy as _lazy -from kitsune.sumo.form_fields import MultiUsernameField - - -TO_PLACEHOLDER = _lazy("username1, username2,...") +from kitsune.sumo.form_fields import MultiUsernameOrGroupnameField class MessageForm(forms.Form): """Form send a private message.""" - to = MultiUsernameField( + to = MultiUsernameOrGroupnameField( label=_lazy("To:"), widget=forms.TextInput( - attrs={"placeholder": TO_PLACEHOLDER, "class": "user-autocomplete"} + attrs={"placeholder": "Search for Users", "class": "user-autocomplete"} ), ) message = forms.CharField(label=_lazy("Message:"), max_length=10000, widget=forms.Textarea) in_reply_to = forms.IntegerField(widget=forms.HiddenInput, required=False) + def __init__(self, *args, **kwargs): + # Grab the user + self.user = kwargs.pop("user") + super(MessageForm, self).__init__(*args, **kwargs) + + # If the user is_staff, the placholder text needs to be updated + if self.user and self.user.profile.in_staff_group: + self.fields["to"].widget.attrs["placeholder"] = "Search for Users or Groups" + class ReplyForm(forms.Form): """Form to reply to a private message.""" diff --git a/kitsune/messages/jinja2/messages/inbox.html b/kitsune/messages/jinja2/messages/inbox.html index 4adb0bc2d8f..3711e4f6841 100644 --- a/kitsune/messages/jinja2/messages/inbox.html +++ b/kitsune/messages/jinja2/messages/inbox.html @@ -38,12 +38,11 @@

{{ title }}

{{ name_link(message.sender) }} {{ datetimeformat(message.created) }} - - - - {{ message.content_parsed|striptags|truncate(length=160) }} - + + {{ message.content_parsed|striptags|truncate(length=160) }} + + {% endfor %} diff --git a/kitsune/messages/jinja2/messages/includes/macros.html b/kitsune/messages/jinja2/messages/includes/macros.html index c4e8c02255c..c251ea978b4 100644 --- a/kitsune/messages/jinja2/messages/includes/macros.html +++ b/kitsune/messages/jinja2/messages/includes/macros.html @@ -59,18 +59,30 @@ {% endif %} - {% if message.recipients > 1 -%} - {% set comma = joiner(', ') %} - {% for user in message.to.all() -%} - {{ comma() }} - {{ name_link(user) }} - {%- endfor %} - {% else %}{# Save a query! #} - {{ name_link(message.recipient) }} +

{{ _('To') }}: + {% if message.recipients > 1 -%} + {% set comma = joiner(', ') %} + {% for user in message.to.all() -%} + {{ comma() }} + {{ name_link(user) }} + {%- endfor %} +

+
+ {% if in_staff_group(request.user) and message.to_group %} + +

{{ _('To Groups') }}: + {% set comma = joiner(', ') %} + {% for group in message.to_group.all() -%} + {{ comma() }} + {{ group_link(group) }} + {% endfor %} +

+
+ {% endif %} {% endif %} - {{ datetimeformat(message.created) }} + {{ datetimeformat(message.created) }} -
{{ message.content_parsed }}
+
{{ message.content_parsed }}
{%- endmacro %} @@ -87,6 +99,7 @@ {% else %} {{ field|safe }} + {{ field.errors }} {% endif %} {% endfor %} diff --git a/kitsune/messages/jinja2/messages/outbox.html b/kitsune/messages/jinja2/messages/outbox.html index 54c4c221af9..4414996f89a 100644 --- a/kitsune/messages/jinja2/messages/outbox.html +++ b/kitsune/messages/jinja2/messages/outbox.html @@ -8,13 +8,41 @@ {% block content %}
-

{{ title }}

+

{{ title }}

+ {% if msgs %}
    +
  1. +
    +
    +
    + + + + {{ _('Sent') }} + + + {{ _('To') }} + + {% if in_staff_group(request.user) %} + + {{ _('To Groups') }} + + {% endif %} + + {{ _('Message') }} + + + + +
    +
  2. {% for message in msgs.object_list %}
  3. @@ -29,19 +57,55 @@

    {{ title }}

    {{ avatar_link(message.recipient) }} {% endif %} + +

    {{ datetimeformat(message.created) }}

    +
    {% if message.recipients > 1 -%} - {{ name_link(request.user, name=_('You')) }} + {% for user in message.to.all()[:3] -%} + + {%- if name -%} + {{ name }} + {%- else -%} + {{ display_name(user) }} + {%- endif %} + {%- if not loop.last -%},{%- endif -%} + + {% endfor %} + {% if message.recipients > 3 -%} + ... + {% endif %} {% else %} {{ name_link(message.recipient) }} {%- endif %} - {{ datetimeformat(message.created) }} + {% if in_staff_group(request.user) %} + + {% if message.to_group %} + {% if message.to_groups_count > 0 -%} + {%- for group in message.to_group.all()[:3] -%} + {{ group_link(group) }} + {%- if not loop.last -%},{%- endif -%} + {% endfor %} + {%- if message.to_groups_count > 3 -%} + ... + {% endif %} + {% endif %} + {% if message.to_groups_count < 1 -%} + {{ _('None') }} + {% endif %} + {% endif %} + + {% endif %} + + + {{ message.content_parsed|striptags|truncate(length=160) }} + + + + +
    - - {{ message.content_parsed|striptags|truncate(length=160) }} - -
  4. {% endfor %}
diff --git a/kitsune/messages/migrations/0002_inboxmessage_to_group_outboxmessage_to_group.py b/kitsune/messages/migrations/0002_inboxmessage_to_group_outboxmessage_to_group.py new file mode 100644 index 00000000000..92d34cab7f9 --- /dev/null +++ b/kitsune/messages/migrations/0002_inboxmessage_to_group_outboxmessage_to_group.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-03-18 11:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("kitsune_messages", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="inboxmessage", + name="to_group", + field=models.ManyToManyField(blank=True, null=True, to="auth.group"), + ), + migrations.AddField( + model_name="outboxmessage", + name="to_group", + field=models.ManyToManyField(blank=True, null=True, to="auth.group"), + ), + ] diff --git a/kitsune/messages/models.py b/kitsune/messages/models.py index 6fddf644931..8a9e9209b97 100644 --- a/kitsune/messages/models.py +++ b/kitsune/messages/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from django.db import models from kitsune.sumo.models import ModelBase @@ -34,6 +34,7 @@ class InboxMessage(ModelBase): """A message in a user's private message inbox.""" to = models.ForeignKey(User, on_delete=models.CASCADE, related_name="inbox") + to_group = models.ManyToManyField(Group, null=True, blank=True) sender = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) message = models.TextField() created = models.DateTimeField(default=datetime.now, db_index=True) @@ -59,12 +60,14 @@ class Meta: class OutboxMessage(ModelBase): sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name="outbox") to = models.ManyToManyField(User) + to_group = models.ManyToManyField(Group, null=True, blank=True) message = models.TextField() created = models.DateTimeField(default=datetime.now, db_index=True) def __str__(self): to = ", ".join([u.username for u in self.to.all()]) - return "from:%s to:%s %s" % (self.sender, to, self.message[0:30]) + to_group = ", ".join([g.name for g in self.to_group.all()]) or None + return "from:%s to:%s groups:%s %s" % (self.sender, to, to_group, self.message[0:30]) @property def content_parsed(self): diff --git a/kitsune/messages/tests/test_internal_api.py b/kitsune/messages/tests/test_internal_api.py index 941407c99a1..734a82396d1 100644 --- a/kitsune/messages/tests/test_internal_api.py +++ b/kitsune/messages/tests/test_internal_api.py @@ -11,7 +11,7 @@ def test_send_message(self): to = UserFactory.create_batch(2) sender = UserFactory() msg_text = "hi there!" - send_message(to=to, text=msg_text, sender=sender) + send_message(to=to, to_group="", text=msg_text, sender=sender) msgs_in = InboxMessage.objects.all() msgs_out = OutboxMessage.objects.all() diff --git a/kitsune/messages/tests/test_templates.py b/kitsune/messages/tests/test_templates.py index fd50a9e6f9d..429b774c3c7 100644 --- a/kitsune/messages/tests/test_templates.py +++ b/kitsune/messages/tests/test_templates.py @@ -23,7 +23,7 @@ def test_send_message_page(self): def _test_send_message_to(self, to): # Post a new message and verify it was sent. - data = {"to": to, "message": "hi there"} + data = {"to": to, "to_group": "", "message": "hi there"} response = self.client.post(reverse("messages.new", locale="en-US"), data, follow=True) self.assertEqual(200, response.status_code) self.assertEqual("Your message was sent!", pq(response.content)("ul.user-messages").text()) diff --git a/kitsune/messages/urls.py b/kitsune/messages/urls.py index 4d4667056e6..6d153fbbdd0 100644 --- a/kitsune/messages/urls.py +++ b/kitsune/messages/urls.py @@ -1,9 +1,18 @@ -from django.urls import re_path +from django.urls import include, re_path -from kitsune.messages import views +from kitsune.messages import api, views + +api_patterns = [ + re_path( + r"^autocomplete", + api.get_autocomplete_suggestions, + name="messages.api.get_autocomplete_suggestions", + ), +] urlpatterns = [ re_path(r"^$", views.inbox, name="messages.inbox"), + re_path(r"^api/", include(api_patterns)), re_path(r"^bulk_action$", views.bulk_action, name="messages.bulk_action"), re_path(r"^read/(?P\d+)$", views.read, name="messages.read"), re_path(r"^read/(?P\d+)/delete$", views.delete, name="messages.delete"), diff --git a/kitsune/messages/utils.py b/kitsune/messages/utils.py index 9ce07f3ce95..5865a52344e 100644 --- a/kitsune/messages/utils.py +++ b/kitsune/messages/utils.py @@ -1,25 +1,39 @@ from kitsune.messages.models import InboxMessage, OutboxMessage +from kitsune.users.models import User from kitsune.messages.signals import message_sent from kitsune.messages.tasks import email_private_message from kitsune.users.models import Setting -def send_message(to, text, sender=None): +def send_message(to, to_group=None, text=None, sender=None): """Send a private message. :arg to: a list of Users to send the message to + :arg to_groups: Groups to send the message to :arg sender: the User who is sending the message :arg text: the message text """ - if sender: - msg = OutboxMessage.objects.create(sender=sender, message=text) - msg.to.add(*to) - for user in to: - im = InboxMessage.objects.create(sender=sender, to=user, message=text) - if Setting.get_for_user(user, "email_private_messages"): - email_private_message(inbox_message_id=im.id) - - message_sent.send(sender=InboxMessage, to=to, text=text, msg_sender=sender) + if not sender or not text: + return + + # Assuming OutboxMessage doesn't necessarily need to be created for every recipient + if to: + outbox_message = OutboxMessage.objects.create(sender=sender, message=text) + outbox_message.to.set(to) + if to_group: + outbox_message.to_group.set(to_group) + + for recipient in to: + if isinstance(recipient, User): + inbox_message = InboxMessage.objects.create(sender=sender, to=recipient, message=text) + # If we had a user, and we made them an inbox message, + # we should also add the to_groups to their message as well + if to_group: + inbox_message.to_group.set(to_group) + if Setting.get_for_user(recipient, "email_private_messages"): + email_private_message(inbox_message_id=inbox_message.id) + + message_sent.send(sender=InboxMessage, to=to, to_group=to_group, text=text, msg_sender=sender) def unread_count_for(user): diff --git a/kitsune/messages/views.py b/kitsune/messages/views.py index 1713b2ffd76..3daefa2e52b 100644 --- a/kitsune/messages/views.py +++ b/kitsune/messages/views.py @@ -1,7 +1,7 @@ import json from django.contrib import messages as contrib_messages -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext as _ @@ -43,7 +43,15 @@ def read(request, msgid): @login_required def read_outbox(request, msgid): message = get_object_or_404(OutboxMessage, pk=msgid, sender=request.user) - return render(request, "messages/read-outbox.html", {"message": _add_recipients(message)}) + return render( + request, + "messages/read-outbox.html", + { + "message": _add_recipients(message), + "to_users": message.to.all(), + "to_groups": message.to_group.all(), + }, + ) @login_required @@ -63,40 +71,32 @@ def outbox(request): @login_required def new_message(request): """Send a new private message.""" - to = request.GET.get("to") - if to: - try: - for username in to.split(","): - User.objects.get(username=username) - except User.DoesNotExist: + if request.method == "GET": + form = MessageForm(initial=request.GET.dict(), user=request.user) + elif request.method == "POST": + form = MessageForm(request.POST, user=request.user) + if form.is_valid() and not is_ratelimited(request, "private-message-day", "50/d"): + receivers = {user for user in form.cleaned_data["to"] if isinstance(user, User)} + groups = [group for group in form.cleaned_data["to"] if isinstance(group, Group)] + + for group in groups: + receivers.update(group.user_set.all()) + + send_message( + list(receivers), + to_group=groups, + text=form.cleaned_data["message"], + sender=request.user, + ) + if "in_reply_to" in form.cleaned_data and form.cleaned_data["in_reply_to"]: + InboxMessage.objects.filter( + pk=form.cleaned_data["in_reply_to"], to=request.user + ).update(replied=True) + contrib_messages.add_message( - request, - contrib_messages.ERROR, - _("Invalid username provided. Enter a new username below."), + request, contrib_messages.SUCCESS, _("Your message was sent!") ) - return HttpResponseRedirect(reverse("messages.new")) - - message = request.GET.get("message") - - form = MessageForm(request.POST or None, initial={"to": to, "message": message}) - - if ( - request.method == "POST" - and form.is_valid() - and not is_ratelimited(request, "primate-message-day", "50/d") - ): - send_message(form.cleaned_data["to"], form.cleaned_data["message"], request.user) - if form.cleaned_data["in_reply_to"]: - irt = form.cleaned_data["in_reply_to"] - try: - m = InboxMessage.objects.get(pk=irt, to=request.user) - m.update(replied=True) - except InboxMessage.DoesNotExist: - pass - contrib_messages.add_message( - request, contrib_messages.SUCCESS, _("Your message was sent!") - ) - return HttpResponseRedirect(reverse("messages.inbox")) + return HttpResponseRedirect(reverse("messages.outbox")) return render(request, "messages/new.html", {"form": form}) @@ -172,8 +172,16 @@ def preview_async(request): def _add_recipients(msg): msg.recipients = msg.to.count() + msg.to_groups_count = msg.to_group.count() + if msg.recipients == 1: msg.recipient = msg.to.all()[0] else: msg.recipient = None + + if msg.to_groups_count == 1: + msg.to_groups = msg.to_group.all()[0] + else: + msg.to_groups = msg.to_group.all() + return msg diff --git a/kitsune/settings.py b/kitsune/settings.py index 7292da2e987..03222c96a7b 100644 --- a/kitsune/settings.py +++ b/kitsune/settings.py @@ -608,6 +608,8 @@ def _username_algo(email): AUTH_PROFILE_MODULE = "users.Profile" USER_AVATAR_PATH = "uploads/avatars/" DEFAULT_AVATAR = "sumo/img/avatar.png" +DEFAULT_GROUP_ICON = "sumo/img/group-icon.svg" +DEFAULT_USER_ICON = "sumo/img/user-icon.svg" AVATAR_SIZE = 200 # in pixels MAX_AVATAR_FILE_SIZE = 1310720 # 1MB, in bytes GROUP_AVATAR_PATH = "uploads/groupavatars/" @@ -728,6 +730,10 @@ def JINJA_CONFIG(): "SESSION_SERIALIZER", default="django.contrib.sessions.serializers.PickleSerializer" ) + +# Our group with permissions to see hidden docs and message groups +STAFF_GROUP = "Staff" + # CSRF CSRF_COOKIE_SECURE = config("CSRF_COOKIE_SECURE", default=not DEBUG, cast=bool) # diff --git a/kitsune/sumo/form_fields.py b/kitsune/sumo/form_fields.py index 21d56ec6ad1..f89a56904c1 100644 --- a/kitsune/sumo/form_fields.py +++ b/kitsune/sumo/form_fields.py @@ -1,7 +1,7 @@ from pathlib import Path from django import forms -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User from django.core import validators from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ @@ -83,6 +83,41 @@ def to_python(self, value): return users +class MultiUsernameOrGroupnameField(forms.Field): + """Form field that takes a comma-separated list of usernames or groupnames + and validates that users/groups exist for each one, and returns the list of + users/groups.""" + + def to_python(self, value): + if not value: + if self.required: + raise ValidationError(_("To field is required.")) + else: + return [] + + # Split names, strip whitespace, and filter out any empty strings + names = [name.strip() for name in value.split(",") if name.strip()] + + # Find users and groups in a single query each + users = User.objects.filter(username__in=names) + groups = Group.objects.filter(name__in=names) + + users_and_groups = list(users) + list(groups) + + # Check if all names were found + found_names = set([obj.username for obj in users] + [obj.name for obj in groups]) + missing_names = set(names) - found_names + + if missing_names: + raise ValidationError( + _("The following are not valid usernames or group names: {names}").format( + names=", ".join(missing_names) + ) + ) + + return users_and_groups + + class ImagePlusField(forms.ImageField): """ Same as django.forms.ImageField but with support for trusted SVG images as well. diff --git a/kitsune/sumo/jinja2/base.html b/kitsune/sumo/jinja2/base.html index fcb2761b03c..8967c2ca8b8 100644 --- a/kitsune/sumo/jinja2/base.html +++ b/kitsune/sumo/jinja2/base.html @@ -69,6 +69,7 @@ + + + + + + + + + \ No newline at end of file diff --git a/kitsune/sumo/static/sumo/img/user-icon.svg b/kitsune/sumo/static/sumo/img/user-icon.svg new file mode 100644 index 00000000000..09412de798d --- /dev/null +++ b/kitsune/sumo/static/sumo/img/user-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/kitsune/sumo/static/sumo/js/libs/jquery.tokeninput.js b/kitsune/sumo/static/sumo/js/libs/jquery.tokeninput.js index 4a1a141a240..dc2a6a55006 100644 --- a/kitsune/sumo/static/sumo/js/libs/jquery.tokeninput.js +++ b/kitsune/sumo/static/sumo/js/libs/jquery.tokeninput.js @@ -1,901 +1,1112 @@ /* * jQuery Plugin: Tokenizing Autocomplete Text Entry - * Version 1.6.0 + * Version 1.6.2 * * Copyright (c) 2009 James Smith (http://loopj.com) * Licensed jointly under the GPL and MIT licenses, * choose which one suits your project best! * */ - -(function ($) { -// Default settings -var DEFAULT_SETTINGS = { - // Search settings - method: "GET", - queryParam: "q", - searchDelay: 300, - minChars: 1, - propertyToSearch: "name", - jsonContainer: null, - contentType: "json", - - // Prepopulation settings - prePopulate: null, - processPrePopulate: false, - - // Display settings - hintText: "Type in a search term", - noResultsText: "No results", - searchingText: "Searching...", - deleteText: "×", - animateDropdown: true, - theme: null, - resultsFormatter: function(item){ return "
  • " + item[this.propertyToSearch]+ "
  • " }, - tokenFormatter: function(item) { return "
  • " + item[this.propertyToSearch] + "

  • " }, - - // Tokenization settings - tokenLimit: null, - tokenDelimiter: ",", - preventDuplicates: false, - tokenValue: "id", - - // Callbacks - onResult: null, - onAdd: null, - onDelete: null, - onReady: null, - - // Other settings - idPrefix: "token-input-", - - // Keep track if the input is currently in disabled mode - disabled: false -}; - -// Default classes to use when theming -var DEFAULT_CLASSES = { - tokenList: "token-input-list", - token: "token-input-token", - tokenDelete: "token-input-delete-token", - selectedToken: "token-input-selected-token", - highlightedToken: "token-input-highlighted-token", - dropdown: "token-input-dropdown", - dropdownItem: "token-input-dropdown-item", - dropdownItem2: "token-input-dropdown-item2", - selectedDropdownItem: "token-input-selected-dropdown-item", - inputToken: "token-input-input-token", - disabled: "token-input-disabled" -}; - -// Input box position "enum" -var POSITION = { - BEFORE: 0, - AFTER: 1, - END: 2 -}; - -// Keys "enum" -var KEY = { - BACKSPACE: 8, - TAB: 9, - ENTER: 13, - ESCAPE: 27, - SPACE: 32, - PAGE_UP: 33, - PAGE_DOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - NUMPAD_ENTER: 108, - COMMA: 188 -}; - -// Additional public (exposed) methods -var methods = { - init: function(url_or_data_or_function, options) { - var settings = $.extend({}, DEFAULT_SETTINGS, options || {}); - - return this.each(function () { - $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, settings)); - }); - }, - clear: function() { - this.data("tokenInputObject").clear(); - return this; - }, - add: function(item) { - this.data("tokenInputObject").add(item); - return this; - }, - remove: function(item) { - this.data("tokenInputObject").remove(item); - return this; - }, - get: function() { - return this.data("tokenInputObject").getTokens(); - }, - toggleDisabled: function(disable) { - this.data("tokenInputObject").toggleDisabled(disable); - return this; +;(function ($) { + var DEFAULT_SETTINGS = { + // Search settings + method: "GET", + queryParam: "q", + searchDelay: 300, + minChars: 1, + propertyToSearch: "name", + jsonContainer: null, + contentType: "json", + excludeCurrent: false, + excludeCurrentParameter: "x", + + // Prepopulation settings + prePopulate: null, + processPrePopulate: false, + + // Display settings + hintText: "Type in a search term", + noResultsText: "No results", + searchingText: "Searching...", + deleteText: "×", + animateDropdown: true, + placeholder: null, + theme: null, + zindex: 999, + resultsLimit: null, + + enableHTML: false, + + resultsFormatter: function(item) { + var string = item[this.propertyToSearch]; + return "
  • " + (this.enableHTML ? string : _escapeHTML(string)) + "
  • "; + }, + + tokenFormatter: function(item) { + var string = item[this.propertyToSearch]; + return "
  • " + (this.enableHTML ? string : _escapeHTML(string)) + "

  • "; + }, + + // Tokenization settings + tokenLimit: null, + tokenDelimiter: ",", + preventDuplicates: false, + tokenValue: "id", + + // Behavioral settings + allowFreeTagging: false, + allowTabOut: false, + autoSelectFirstResult: false, + + // Callbacks + onResult: null, + onCachedResult: null, + onAdd: null, + onFreeTaggingAdd: null, + onDelete: null, + onReady: null, + + // Other settings + idPrefix: "token-input-", + + // Keep track if the input is currently in disabled mode + disabled: false + }; + + // Default classes to use when theming + var DEFAULT_CLASSES = { + tokenList : "token-input-list", + token : "token-input-token", + tokenReadOnly : "token-input-token-readonly", + tokenDelete : "token-input-delete-token", + selectedToken : "token-input-selected-token", + highlightedToken : "token-input-highlighted-token", + dropdown : "token-input-dropdown", + dropdownItem : "token-input-dropdown-item", + dropdownItem2 : "token-input-dropdown-item2", + selectedDropdownItem : "token-input-selected-dropdown-item", + inputToken : "token-input-input-token", + focused : "token-input-focused", + disabled : "token-input-disabled" + }; + + // Input box position "enum" + var POSITION = { + BEFORE : 0, + AFTER : 1, + END : 2 + }; + + // Keys "enum" + var KEY = { + BACKSPACE : 8, + TAB : 9, + ENTER : 13, + ESCAPE : 27, + SPACE : 32, + PAGE_UP : 33, + PAGE_DOWN : 34, + END : 35, + HOME : 36, + LEFT : 37, + UP : 38, + RIGHT : 39, + DOWN : 40, + NUMPAD_ENTER : 108, + COMMA : 188 + }; + + var HTML_ESCAPES = { + '&' : '&', + '<' : '<', + '>' : '>', + '"' : '"', + "'" : ''', + '/' : '/' + }; + + var HTML_ESCAPE_CHARS = /[&<>"'\/]/g; + + function coerceToString(val) { + return String((val === null || val === undefined) ? '' : val); } -} - -// Expose the .tokenInput function to jQuery as a plugin -$.fn.tokenInput = function (method) { - // Method calling and initialization logic - if(methods[method]) { - return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); - } else { - return methods.init.apply(this, arguments); + + function _escapeHTML(text) { + return coerceToString(text).replace(HTML_ESCAPE_CHARS, function(match) { + return HTML_ESCAPES[match]; + }); } -}; - -// TokenList class for each input -$.TokenList = function (input, url_or_data, settings) { - // - // Initialization - // - - // Configure the data source - if($.type(url_or_data) === "string" || $.type(url_or_data) === "function") { - // Set the url to query against - settings.url = url_or_data; - - // If the URL is a function, evaluate it here to do our initalization work - var url = computeURL(); - - // Make a smart guess about cross-domain if it wasn't explicitly specified - if(settings.crossDomain === undefined) { - if(url.indexOf("://") === -1) { - settings.crossDomain = false; - } else { - settings.crossDomain = (location.href.split(/\/+/g)[1] !== url.split(/\/+/g)[1]); - } + + // Additional public (exposed) methods + var methods = { + init: function(url_or_data_or_function, options) { + var settings = $.extend({}, DEFAULT_SETTINGS, options || {}); + + return this.each(function () { + $(this).data("settings", settings); + $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, settings)); + }); + }, + clear: function() { + this.data("tokenInputObject").clear(); + return this; + }, + add: function(item) { + this.data("tokenInputObject").add(item); + return this; + }, + remove: function(item) { + this.data("tokenInputObject").remove(item); + return this; + }, + get: function() { + return this.data("tokenInputObject").getTokens(); + }, + toggleDisabled: function(disable) { + this.data("tokenInputObject").toggleDisabled(disable); + return this; + }, + setOptions: function(options){ + $(this).data("settings", $.extend({}, $(this).data("settings"), options || {})); + return this; + }, + destroy: function () { + if (this.data("tokenInputObject")) { + this.data("tokenInputObject").clear(); + var tmpInput = this; + var closest = this.parent(); + closest.empty(); + tmpInput.show(); + closest.append(tmpInput); + return tmpInput; + } } - } else if(typeof(url_or_data) === "object") { - // Set the local data to search through - settings.local_data = url_or_data; - } - - // Build class names - if(settings.classes) { - // Use custom class names - settings.classes = $.extend({}, DEFAULT_CLASSES, settings.classes); - } else if(settings.theme) { - // Use theme-suffixed default class names - settings.classes = {}; - $.each(DEFAULT_CLASSES, function(key, value) { - settings.classes[key] = value + "-" + settings.theme; - }); - } else { - settings.classes = DEFAULT_CLASSES; - } - - - // Save the tokens - var saved_tokens = []; - - // Keep track of the number of tokens in the list - var token_count = 0; - - // Basic cache to save on db hits - var cache = new $.TokenList.Cache(); - - // Keep track of the timeout, old vals - var timeout; - var input_val; - - // Create a new text input an attach keyup events - var input_box = $("") - .css({ - outline: "none" - }) - .attr("id", settings.idPrefix + input.id) - .focus(function () { - if (settings.disabled) { - return false; - } else - if (settings.tokenLimit === null || settings.tokenLimit !== token_count) { - show_dropdown_hint(); + }; + + // Expose the .tokenInput function to jQuery as a plugin + $.fn.tokenInput = function (method) { + // Method calling and initialization logic + if (methods[method]) { + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); + } else { + return methods.init.apply(this, arguments); + } + }; + + // TokenList class for each input + $.TokenList = function (input, url_or_data, settings) { + // + // Initialization + // + + // Configure the data source + if (typeof(url_or_data) === "string" || typeof(url_or_data) === "function") { + // Set the url to query against + $(input).data("settings").url = url_or_data; + + // If the URL is a function, evaluate it here to do our initalization work + var url = computeURL(); + + // Make a smart guess about cross-domain if it wasn't explicitly specified + if ($(input).data("settings").crossDomain === undefined && typeof url === "string") { + if(url.indexOf("://") === -1) { + $(input).data("settings").crossDomain = false; + } else { + $(input).data("settings").crossDomain = (location.href.split(/\/+/g)[1] !== url.split(/\/+/g)[1]); + } } - }) - .blur(function () { - hide_dropdown(); - $(this).val(""); - }) - .bind("keyup keydown blur update", resize_input) - .on('keydown', function (event) { - var previous_token; - var next_token; - - switch(event.keyCode) { - case KEY.LEFT: - case KEY.RIGHT: - case KEY.UP: - case KEY.DOWN: - if(!$(this).val()) { - previous_token = input_token.prev(); - next_token = input_token.next(); - - if((previous_token.length && previous_token.get(0) === selected_token) || (next_token.length && next_token.get(0) === selected_token)) { - // Check if there is a previous/next token and it is selected - if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) { - deselect_token($(selected_token), POSITION.BEFORE); - } else { - deselect_token($(selected_token), POSITION.AFTER); - } - } else if((event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) && previous_token.length) { - // We are moving left, select the previous token if it exists - select_token($(previous_token.get(0))); - } else if((event.keyCode === KEY.RIGHT || event.keyCode === KEY.DOWN) && next_token.length) { - // We are moving right, select the next token if it exists - select_token($(next_token.get(0))); - } - } else { + } else if (typeof(url_or_data) === "object") { + // Set the local data to search through + $(input).data("settings").local_data = url_or_data; + } + + // Build class names + if($(input).data("settings").classes) { + // Use custom class names + $(input).data("settings").classes = $.extend({}, DEFAULT_CLASSES, $(input).data("settings").classes); + } else if($(input).data("settings").theme) { + // Use theme-suffixed default class names + $(input).data("settings").classes = {}; + $.each(DEFAULT_CLASSES, function(key, value) { + $(input).data("settings").classes[key] = value + "-" + $(input).data("settings").theme; + }); + } else { + $(input).data("settings").classes = DEFAULT_CLASSES; + } + + // Save the tokens + var saved_tokens = []; + + // Keep track of the number of tokens in the list + var token_count = 0; + + // Basic cache to save on db hits + var cache = new $.TokenList.Cache(); + + // Keep track of the timeout, old vals + var timeout; + var input_val; + + // Create a new text input an attach keyup events + var input_box = $("") + .css({ + outline: "none" + }) + .attr("id", $(input).data("settings").idPrefix + input.id) + .focus(function () { + if ($(input).data("settings").disabled) { + return false; + } else + if ($(input).data("settings").tokenLimit === null || $(input).data("settings").tokenLimit !== token_count) { + show_dropdown_hint(); + } + token_list.addClass($(input).data("settings").classes.focused); + }) + .blur(function () { + hide_dropdown(); + + if ($(input).data("settings").allowFreeTagging) { + add_freetagging_tokens(); + } + + $(this).val(""); + token_list.removeClass($(input).data("settings").classes.focused); + }) + .bind("keyup keydown blur update", resize_input) + .keydown(function (event) { + var previous_token; + var next_token; + + switch(event.keyCode) { + case KEY.LEFT: + case KEY.RIGHT: + case KEY.UP: + case KEY.DOWN: + if(this.value.length === 0) { + previous_token = input_token.prev(); + next_token = input_token.next(); + + if((previous_token.length && previous_token.get(0) === selected_token) || + (next_token.length && next_token.get(0) === selected_token)) { + // Check if there is a previous/next token and it is selected + if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) { + deselect_token($(selected_token), POSITION.BEFORE); + } else { + deselect_token($(selected_token), POSITION.AFTER); + } + } else if((event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) && previous_token.length) { + // We are moving left, select the previous token if it exists + select_token($(previous_token.get(0))); + } else if((event.keyCode === KEY.RIGHT || event.keyCode === KEY.DOWN) && next_token.length) { + // We are moving right, select the next token if it exists + select_token($(next_token.get(0))); + } + } else { var dropdown_item = null; - - if(event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) { + + if (event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) { + dropdown_item = $(dropdown).find('li').first(); + + if (selected_dropdown_item) { dropdown_item = $(selected_dropdown_item).next(); + } } else { + dropdown_item = $(dropdown).find('li').last(); + + if (selected_dropdown_item) { dropdown_item = $(selected_dropdown_item).prev(); + } } - - if(dropdown_item.length) { - select_dropdown_item(dropdown_item); - } - return false; - } - break; - - case KEY.BACKSPACE: - previous_token = input_token.prev(); - - if(!$(this).val().length) { - if(selected_token) { + + select_dropdown_item(dropdown_item); + } + + break; + + case KEY.BACKSPACE: + previous_token = input_token.prev(); + + if (this.value.length === 0) { + if (selected_token) { delete_token($(selected_token)); - hidden_input.trigger('change'); - } else if(previous_token.length) { + hiddenInput.change(); + } else if(previous_token.length) { select_token($(previous_token.get(0))); + } + + return false; + } else if($(this).val().length === 1) { + hide_dropdown(); + } else { + // set a timeout just long enough to let this function finish. + setTimeout(function(){ do_search(); }, 5); } - - return false; - } else if($(this).val().length === 1) { - hide_dropdown(); - } else { - // set a timeout just long enough to let this function finish. - setTimeout(function(){do_search();}, 5); - } - break; - - case KEY.TAB: - case KEY.ENTER: - case KEY.NUMPAD_ENTER: - case KEY.COMMA: - if(selected_dropdown_item) { - add_token($(selected_dropdown_item).data("tokeninput")); - hidden_input.trigger('change'); - return false; - } - break; - - case KEY.ESCAPE: - hide_dropdown(); - return true; - - default: - if(String.fromCharCode(event.which)) { + break; + + case KEY.TAB: + case KEY.ENTER: + case KEY.NUMPAD_ENTER: + case KEY.COMMA: + if(selected_dropdown_item) { + add_token($(selected_dropdown_item).data("tokeninput")); + hiddenInput.change(); + } else { + if ($(input).data("settings").allowFreeTagging) { + if($(input).data("settings").allowTabOut && $(this).val() === "") { + return true; + } else { + add_freetagging_tokens(); + } + } else { + $(this).val(""); + if($(input).data("settings").allowTabOut) { + return true; + } + } + event.stopPropagation(); + event.preventDefault(); + } + return false; + + case KEY.ESCAPE: + hide_dropdown(); + return true; + + default: + if (String.fromCharCode(event.which)) { // set a timeout just long enough to let this function finish. - setTimeout(function(){do_search();}, 5); - } - break; - } - }); - - // Keep a reference to the original input box - var hidden_input = $(input) - .hide() - .val("") - .focus(function () { - input_box.focus(); - }) - .blur(function () { - input_box.blur(); - }); - - // Keep a reference to the selected token and dropdown item - var selected_token = null; - var selected_token_index = 0; - var selected_dropdown_item = null; - - // The list to store the token items in - var token_list = $("
      ") - .addClass(settings.classes.tokenList) - .on("click", function (event) { - var li = $(event.target).closest("li"); - if(li && li.get(0) && $.data(li.get(0), "tokeninput")) { - toggle_select_token(li); - } else { - // Deselect selected token - if(selected_token) { - deselect_token($(selected_token), POSITION.END); + setTimeout(function(){ do_search(); }, 5); + } + break; } + }); + + // Copy original placeholder unless overridden + if (!settings.placeholder) { + settings.placeholder = $(input).attr('placeholder') + } - // Focus input box - input_box.focus(); - } - }) - .mouseover(function (event) { - var li = $(event.target).closest("li"); - if(li && selected_token !== this) { - li.addClass(settings.classes.highlightedToken); - } - }) - .mouseout(function (event) { - var li = $(event.target).closest("li"); - if(li && selected_token !== this) { - li.removeClass(settings.classes.highlightedToken); - } - }) - .insertBefore(hidden_input); - - // The token holding the input box - var input_token = $("
    • ") - .addClass(settings.classes.inputToken) - .appendTo(token_list) - .append(input_box); - - // The list to store the dropdown items in - var dropdown = $("
      ") - .addClass(settings.classes.dropdown) - .appendTo("body") - .hide(); - - // Magic element to help us resize the text input - var input_resizer = $("") - .insertAfter(input_box) - .css({ - position: "absolute", - top: -9999, - left: -9999, - width: "auto", - fontSize: input_box.css("fontSize"), - fontFamily: input_box.css("fontFamily"), - fontWeight: input_box.css("fontWeight"), - letterSpacing: input_box.css("letterSpacing"), - whiteSpace: "nowrap" - }); - - // Pre-populate list if items exist - hidden_input.val(""); - var li_data = settings.prePopulate || hidden_input.data("pre"); - if(settings.processPrePopulate && $.isFunction(settings.onResult)) { - li_data = settings.onResult.call(hidden_input, li_data); - } - if(li_data && li_data.length) { - $.each(li_data, function (index, value) { - insert_token(value); - checkTokenLimit(); - }); - } - - // Check if widget should initialize as disabled - if (settings.disabled) { - toggleDisabled(true); - } - - // Initialization is done - if($.isFunction(settings.onReady)) { - settings.onReady.call(); - } - - // - // Public functions - // - - this.clear = function() { - token_list.children("li").each(function() { - if ($(this).children("input").length === 0) { - delete_token($(this)); - } - }); - } - - this.add = function(item) { - add_token(item); - } - - this.remove = function(item) { - token_list.children("li").each(function() { - if ($(this).children("input").length === 0) { - var currToken = $(this).data("tokeninput"); - var match = true; - for (var prop in item) { - if (item[prop] !== currToken[prop]) { - match = false; - break; + // Keep reference for placeholder + if (settings.placeholder) { + input_box.attr("placeholder", settings.placeholder); + } + + // Keep a reference to the original input box + var hiddenInput = $(input) + .hide() + .val("") + .focus(function () { + focusWithTimeout(input_box); + }) + .blur(function () { + input_box.blur(); + + //return the object to this can be referenced in the callback functions. + return hiddenInput; + }) + ; + + // Keep a reference to the selected token and dropdown item + var selected_token = null; + var selected_token_index = 0; + var selected_dropdown_item = null; + + // The list to store the token items in + var token_list = $("
        ") + .addClass($(input).data("settings").classes.tokenList) + .click(function (event) { + var li = $(event.target).closest("li"); + if(li && li.get(0) && $.data(li.get(0), "tokeninput")) { + toggle_select_token(li); + } else { + // Deselect selected token + if(selected_token) { + deselect_token($(selected_token), POSITION.END); } + + // Focus input box + focusWithTimeout(input_box); } - if (match) { - delete_token($(this)); + }) + .mouseover(function (event) { + var li = $(event.target).closest("li"); + if(li && selected_token !== this) { + li.addClass($(input).data("settings").classes.highlightedToken); } - } - }); - } - - this.getTokens = function() { - return saved_tokens; - } - - this.toggleDisabled = function(disable) { - toggleDisabled(disable); - } - - // - // Private functions - // - - // Toggles the widget between enabled and disabled state, or according - // to the [disable] parameter. - function toggleDisabled(disable) { - if (typeof disable === 'boolean') { - settings.disabled = disable - } else { - settings.disabled = !settings.disabled; + }) + .mouseout(function (event) { + var li = $(event.target).closest("li"); + if(li && selected_token !== this) { + li.removeClass($(input).data("settings").classes.highlightedToken); + } + }) + .insertBefore(hiddenInput); + + // The token holding the input box + var input_token = $("
      • ") + .addClass($(input).data("settings").classes.inputToken) + .appendTo(token_list) + .append(input_box); + + // The list to store the dropdown items in + var dropdown = $("
        ") + .addClass($(input).data("settings").classes.dropdown) + .appendTo("body") + .hide(); + + // Magic element to help us resize the text input + var input_resizer = $("") + .insertAfter(input_box) + .css({ + position: "absolute", + top: -9999, + left: -9999, + width: "auto", + fontSize: input_box.css("fontSize"), + fontFamily: input_box.css("fontFamily"), + fontWeight: input_box.css("fontWeight"), + letterSpacing: input_box.css("letterSpacing"), + whiteSpace: "nowrap" + }); + + // Pre-populate list if items exist + hiddenInput.val(""); + var li_data = $(input).data("settings").prePopulate || hiddenInput.data("pre"); + + if ($(input).data("settings").processPrePopulate && $.isFunction($(input).data("settings").onResult)) { + li_data = $(input).data("settings").onResult.call(hiddenInput, li_data); } - input_box.prop('disabled', settings.disabled); - token_list.toggleClass(settings.classes.disabled, settings.disabled); - // if there is any token selected we deselect it - if(selected_token) { - deselect_token($(selected_token), POSITION.END); + + if (li_data && li_data.length) { + $.each(li_data, function (index, value) { + insert_token(value); + checkTokenLimit(); + input_box.attr("placeholder", null) + }); } - hidden_input.prop('disabled', settings.disabled); - } - - function checkTokenLimit() { - if(settings.tokenLimit !== null && token_count >= settings.tokenLimit) { - input_box.hide(); - hide_dropdown(); - return; + + // Check if widget should initialize as disabled + if ($(input).data("settings").disabled) { + toggleDisabled(true); } - } - - function resize_input() { - if(input_val === (input_val = input_box.val())) {return;} - - // Enter new content into resizer and resize input accordingly - var escaped = input_val.replace(/&/g, '&').replace(/\s/g,' ').replace(//g, '>'); - input_resizer.html(escaped); - input_box.width(input_resizer.width() + 30); - } - - function is_printable_character(keycode) { - return ((keycode >= 48 && keycode <= 90) || // 0-1a-z - (keycode >= 96 && keycode <= 111) || // numpad 0-9 + - / * . - (keycode >= 186 && keycode <= 192) || // ; = , - . / ^ - (keycode >= 219 && keycode <= 222)); // ( \ ) ' - } - - // Inner function to a token to the list - function insert_token(item) { - var this_token = settings.tokenFormatter(item); - this_token = $(this_token) - .addClass(settings.classes.token) - .insertBefore(input_token); - - // The 'delete token' button - $("" + settings.deleteText + "") - .addClass(settings.classes.tokenDelete) - .appendTo(this_token) - .on("click", function () { - if (!settings.disabled) { - delete_token($(this).parent()); - hidden_input.trigger('change'); - return false; + + // Initialization is done + if (typeof($(input).data("settings").onReady) === "function") { + $(input).data("settings").onReady.call(); + } + + // + // Public functions + // + + this.clear = function() { + token_list.children("li").each(function() { + if ($(this).children("input").length === 0) { + delete_token($(this)); } }); - - // Store data on the token - var token_data = item; - $.data(this_token.get(0), "tokeninput", item); - - // Save this token for duplicate checking - saved_tokens = saved_tokens.slice(0,selected_token_index).concat([token_data]).concat(saved_tokens.slice(selected_token_index)); - selected_token_index++; - - // Update the hidden input - update_hidden_input(saved_tokens, hidden_input); - - token_count += 1; - - // Check the token limit - if(settings.tokenLimit !== null && token_count >= settings.tokenLimit) { - input_box.hide(); - hide_dropdown(); - } - - return this_token; - } - - // Add a token to the token list based on user input - function add_token (item) { - var callback = settings.onAdd; - - // See if the token already exists and select it if we don't want duplicates - if(token_count > 0 && settings.preventDuplicates) { - var found_existing_token = null; - token_list.children().each(function () { - var existing_token = $(this); - var existing_data = $.data(existing_token.get(0), "tokeninput"); - if(existing_data && existing_data.id === item.id) { - found_existing_token = existing_token; - return false; + }; + + this.add = function(item) { + add_token(item); + }; + + this.remove = function(item) { + token_list.children("li").each(function() { + if ($(this).children("input").length === 0) { + var currToken = $(this).data("tokeninput"); + var match = true; + for (var prop in item) { + if (item[prop] !== currToken[prop]) { + match = false; + break; + } + } + if (match) { + delete_token($(this)); + } } }); - - if(found_existing_token) { - select_token(found_existing_token); - input_token.insertAfter(found_existing_token); - input_box.focus(); + }; + + this.getTokens = function() { + return saved_tokens; + }; + + this.toggleDisabled = function(disable) { + toggleDisabled(disable); + }; + + // Resize input to maximum width so the placeholder can be seen + resize_input(); + + // + // Private functions + // + + function escapeHTML(text) { + return $(input).data("settings").enableHTML ? text : _escapeHTML(text); + } + + // Toggles the widget between enabled and disabled state, or according + // to the [disable] parameter. + function toggleDisabled(disable) { + if (typeof disable === 'boolean') { + $(input).data("settings").disabled = disable + } else { + $(input).data("settings").disabled = !$(input).data("settings").disabled; + } + input_box.attr('disabled', $(input).data("settings").disabled); + token_list.toggleClass($(input).data("settings").classes.disabled, $(input).data("settings").disabled); + // if there is any token selected we deselect it + if(selected_token) { + deselect_token($(selected_token), POSITION.END); + } + hiddenInput.attr('disabled', $(input).data("settings").disabled); + } + + function checkTokenLimit() { + if($(input).data("settings").tokenLimit !== null && token_count >= $(input).data("settings").tokenLimit) { + input_box.hide(); + hide_dropdown(); return; } } - - // Insert the new tokens - if(settings.tokenLimit == null || token_count < settings.tokenLimit) { - insert_token(item); - checkTokenLimit(); + + function resize_input() { + if(input_val === (input_val = input_box.val())) {return;} + + // Get width left on the current line + var width_left = token_list.width() - input_box.offset().left - token_list.offset().left; + // Enter new content into resizer and resize input accordingly + input_resizer.html(_escapeHTML(input_val) || _escapeHTML(settings.placeholder)); + // Get maximum width, minimum the size of input and maximum the widget's width + input_box.width(Math.min(token_list.width(), + Math.max(width_left, input_resizer.width() + 30))); } - - // Clear input box - input_box.val(""); - - // Don't show the help dropdown, they've got the idea - hide_dropdown(); - - // Execute the onAdd callback if defined - if($.isFunction(callback)) { - callback.call(hidden_input,item); + + function add_freetagging_tokens() { + var value = $.trim(input_box.val()); + var tokens = value.split($(input).data("settings").tokenDelimiter); + $.each(tokens, function(i, token) { + if (!token) { + return; + } + + if ($.isFunction($(input).data("settings").onFreeTaggingAdd)) { + token = $(input).data("settings").onFreeTaggingAdd.call(hiddenInput, token); + } + var object = {}; + object[$(input).data("settings").tokenValue] = object[$(input).data("settings").propertyToSearch] = token; + add_token(object); + }); } - } - - // Select a token in the token list - function select_token (token) { - if (!settings.disabled) { - token.addClass(settings.classes.selectedToken); - selected_token = token.get(0); - - // Hide input box + + // Inner function to a token to the list + function insert_token(item) { + var $this_token = $($(input).data("settings").tokenFormatter(item)); + var readonly = item.readonly === true; + + if(readonly) $this_token.addClass($(input).data("settings").classes.tokenReadOnly); + + $this_token.addClass($(input).data("settings").classes.token).insertBefore(input_token); + + // The 'delete token' button + if(!readonly) { + $("" + $(input).data("settings").deleteText + "") + .addClass($(input).data("settings").classes.tokenDelete) + .appendTo($this_token) + .click(function () { + if (!$(input).data("settings").disabled) { + delete_token($(this).parent()); + hiddenInput.change(); + return false; + } + }); + } + + // Store data on the token + var token_data = item; + $.data($this_token.get(0), "tokeninput", item); + + // Save this token for duplicate checking + saved_tokens = saved_tokens.slice(0,selected_token_index).concat([token_data]).concat(saved_tokens.slice(selected_token_index)); + selected_token_index++; + + // Update the hidden input + update_hiddenInput(saved_tokens, hiddenInput); + + token_count += 1; + + // Check the token limit + if($(input).data("settings").tokenLimit !== null && token_count >= $(input).data("settings").tokenLimit) { + input_box.hide(); + hide_dropdown(); + } + + return $this_token; + } + + // Add a token to the token list based on user input + function add_token (item) { + var callback = $(input).data("settings").onAdd; + + // See if the token already exists and select it if we don't want duplicates + if(token_count > 0 && $(input).data("settings").preventDuplicates) { + var found_existing_token = null; + token_list.children().each(function () { + var existing_token = $(this); + var existing_data = $.data(existing_token.get(0), "tokeninput"); + if(existing_data && existing_data[settings.tokenValue] === item[settings.tokenValue]) { + found_existing_token = existing_token; + return false; + } + }); + + if(found_existing_token) { + select_token(found_existing_token); + input_token.insertAfter(found_existing_token); + focusWithTimeout(input_box); + return; + } + } + + // Squeeze input_box so we force no unnecessary line break + input_box.width(1); + + // Insert the new tokens + if($(input).data("settings").tokenLimit == null || token_count < $(input).data("settings").tokenLimit) { + insert_token(item); + // Remove the placeholder so it's not seen after you've added a token + input_box.attr("placeholder", null); + checkTokenLimit(); + } + + // Clear input box input_box.val(""); - - // Hide dropdown if it is visible (eg if we clicked to select token) + + // Don't show the help dropdown, they've got the idea hide_dropdown(); + + // Execute the onAdd callback if defined + if($.isFunction(callback)) { + callback.call(hiddenInput,item); + } } - } - - // Deselect a token in the token list - function deselect_token (token, position) { - token.removeClass(settings.classes.selectedToken); - selected_token = null; - - if(position === POSITION.BEFORE) { - input_token.insertBefore(token); - selected_token_index--; - } else if(position === POSITION.AFTER) { - input_token.insertAfter(token); - selected_token_index++; - } else { - input_token.appendTo(token_list); - selected_token_index = token_count; - } - - // Show the input box and give it focus again - input_box.focus(); - } - - // Toggle selection of a token in the token list - function toggle_select_token(token) { - var previous_selected_token = selected_token; - - if(selected_token) { - deselect_token($(selected_token), POSITION.END); + + // Select a token in the token list + function select_token (token) { + if (!$(input).data("settings").disabled) { + token.addClass($(input).data("settings").classes.selectedToken); + selected_token = token.get(0); + + // Hide input box + input_box.val(""); + + // Hide dropdown if it is visible (eg if we clicked to select token) + hide_dropdown(); + } } - - if(previous_selected_token === token.get(0)) { - deselect_token(token, POSITION.END); - } else { - select_token(token); + + // Deselect a token in the token list + function deselect_token (token, position) { + token.removeClass($(input).data("settings").classes.selectedToken); + selected_token = null; + + if(position === POSITION.BEFORE) { + input_token.insertBefore(token); + selected_token_index--; + } else if(position === POSITION.AFTER) { + input_token.insertAfter(token); + selected_token_index++; + } else { + input_token.appendTo(token_list); + selected_token_index = token_count; + } + + // Show the input box and give it focus again + focusWithTimeout(input_box); } - } - - // Delete a token from the token list - function delete_token (token) { - // Remove the id from the saved list - var token_data = $.data(token.get(0), "tokeninput"); - var callback = settings.onDelete; - - var index = token.prevAll().length; - if(index > selected_token_index) index--; - - // Delete the token - token.remove(); - selected_token = null; - - // Show the input box and give it focus again - input_box.focus(); - - // Remove this token from the saved list - saved_tokens = saved_tokens.slice(0,index).concat(saved_tokens.slice(index+1)); - if(index < selected_token_index) selected_token_index--; - - // Update the hidden input - update_hidden_input(saved_tokens, hidden_input); - - token_count -= 1; - - if(settings.tokenLimit !== null) { - input_box - .show() - .val("") - .focus(); + + // Toggle selection of a token in the token list + function toggle_select_token(token) { + var previous_selected_token = selected_token; + + if(selected_token) { + deselect_token($(selected_token), POSITION.END); + } + + if(previous_selected_token === token.get(0)) { + deselect_token(token, POSITION.END); + } else { + select_token(token); + } } - - // Execute the onDelete callback if defined - if($.isFunction(callback)) { - callback.call(hidden_input,token_data); + + // Delete a token from the token list + function delete_token (token) { + // Remove the id from the saved list + var token_data = $.data(token.get(0), "tokeninput"); + var callback = $(input).data("settings").onDelete; + + var index = token.prevAll().length; + if(index > selected_token_index) index--; + + // Delete the token + token.remove(); + selected_token = null; + + // Show the input box and give it focus again + focusWithTimeout(input_box); + + // Remove this token from the saved list + saved_tokens = saved_tokens.slice(0,index).concat(saved_tokens.slice(index+1)); + if (saved_tokens.length == 0) { + input_box.attr("placeholder", settings.placeholder) + } + if(index < selected_token_index) selected_token_index--; + + // Update the hidden input + update_hiddenInput(saved_tokens, hiddenInput); + + token_count -= 1; + + if($(input).data("settings").tokenLimit !== null) { + input_box + .show() + .val(""); + focusWithTimeout(input_box); + } + + // Execute the onDelete callback if defined + if($.isFunction(callback)) { + callback.call(hiddenInput,token_data); + } } - } - - // Update the hidden input box value - function update_hidden_input(saved_tokens, hidden_input) { - var token_values = $.map(saved_tokens, function (el) { - if(typeof settings.tokenValue == 'function') - return settings.tokenValue.call(this, el); - - return el[settings.tokenValue]; - }); - hidden_input.val(token_values.join(settings.tokenDelimiter)); - - } - - // Hide and clear the results dropdown - function hide_dropdown () { - dropdown.hide().empty(); - selected_dropdown_item = null; - } - - function show_dropdown() { - dropdown - .css({ - position: "absolute", - top: $(token_list).offset().top + $(token_list).outerHeight(), - left: $(token_list).offset().left, - width: $(token_list).width(), - 'z-index': 999 - }) - .show(); - } - - function show_dropdown_searching () { - if(settings.searchingText) { - dropdown.html("

        "+settings.searchingText+"

        "); - show_dropdown(); + + // Update the hidden input box value + function update_hiddenInput(saved_tokens, hiddenInput) { + var token_values = $.map(saved_tokens, function (el) { + if(typeof $(input).data("settings").tokenValue == 'function') + return $(input).data("settings").tokenValue.call(this, el); + + return el[$(input).data("settings").tokenValue]; + }); + hiddenInput.val(token_values.join($(input).data("settings").tokenDelimiter)); + } - } - - function show_dropdown_hint () { - if(settings.hintText) { - dropdown.html("

        "+settings.hintText+"

        "); - show_dropdown(); + + // Hide and clear the results dropdown + function hide_dropdown () { + dropdown.hide().empty(); + selected_dropdown_item = null; } - } - - // Highlight the query part of the search term - function highlight_term(value, term) { - return value.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + term + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); - } - - function find_value_and_highlight_term(template, value, term) { - return template.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + value + ")(?![^<>]*>)(?![^&;]+;)", "g"), highlight_term(value, term)); - } - - // Populate the results dropdown with some results - function populate_dropdown (query, results) { - if(results && results.length) { - dropdown.empty(); - var dropdown_ul = $("
          ") - .appendTo(dropdown) - .mouseover(function (event) { - select_dropdown_item($(event.target).closest("li")); - }) - .mousedown(function (event) { - add_token($(event.target).closest("li").data("tokeninput")); - hidden_input.trigger('change'); - return false; + + function show_dropdown() { + dropdown + .css({ + position: "absolute", + top: token_list.offset().top + token_list.outerHeight(true), + left: token_list.offset().left, + width: token_list.width(), + 'z-index': $(input).data("settings").zindex }) - .hide(); - - $.each(results, function(index, value) { - var this_li = settings.resultsFormatter(value); - - this_li = find_value_and_highlight_term(this_li ,value[settings.propertyToSearch], query); - - this_li = $(this_li).appendTo(dropdown_ul); - - if(index % 2) { - this_li.addClass(settings.classes.dropdownItem); - } else { - this_li.addClass(settings.classes.dropdownItem2); - } - - if(index === 0) { - select_dropdown_item(this_li); - } - - $.data(this_li.get(0), "tokeninput", value); - }); - - show_dropdown(); - - if(settings.animateDropdown) { - dropdown_ul.slideDown("fast"); - } else { - dropdown_ul.show(); - } - } else { - if(settings.noResultsText) { - dropdown.html("

          "+settings.noResultsText+"

          "); + .show(); + } + + function show_dropdown_searching () { + if($(input).data("settings").searchingText) { + dropdown.html("

          " + escapeHTML($(input).data("settings").searchingText) + "

          "); show_dropdown(); } } - } - - // Highlight an item in the results dropdown - function select_dropdown_item (item) { - if(item) { - if(selected_dropdown_item) { - deselect_dropdown_item($(selected_dropdown_item)); + + function show_dropdown_hint () { + if($(input).data("settings").hintText) { + dropdown.html("

          " + escapeHTML($(input).data("settings").hintText) + "

          "); + show_dropdown(); } - - item.addClass(settings.classes.selectedDropdownItem); - selected_dropdown_item = item.get(0); } - } - - // Remove highlighting from an item in the results dropdown - function deselect_dropdown_item (item) { - item.removeClass(settings.classes.selectedDropdownItem); - selected_dropdown_item = null; - } - - // Do a search and show the "searching" dropdown if the input is longer - // than settings.minChars - function do_search() { - var query = input_box.val(); - - if(query && query.length) { - if(selected_token) { - deselect_token($(selected_token), POSITION.AFTER); + + var regexp_special_chars = new RegExp('[.\\\\+*?\\[\\^\\]$(){}=!<>|:\\-]', 'g'); + function regexp_escape(term) { + return term.replace(regexp_special_chars, '\\$&'); + } + + // Highlight the query part of the search term + function highlight_term(value, term) { + return value.replace( + new RegExp( + "(?![^&;]+;)(?!<[^<>]*)(" + regexp_escape(term) + ")(?![^<>]*>)(?![^&;]+;)", + "gi" + ), function(match, p1) { + return "" + escapeHTML(p1) + ""; + } + ); + } + + function find_value_and_highlight_term(template, value, term) { + return template.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + regexp_escape(value) + ")(?![^<>]*>)(?![^&;]+;)", "g"), highlight_term(value, term)); + } + + // exclude existing tokens from dropdown, so the list is clearer + function excludeCurrent(results) { + if ($(input).data("settings").excludeCurrent) { + var currentTokens = $(input).data("tokenInputObject").getTokens(), + trimmedList = []; + if (currentTokens.length) { + $.each(results, function(index, value) { + var notFound = true; + $.each(currentTokens, function(cIndex, cValue) { + if (value[$(input).data("settings").propertyToSearch] == cValue[$(input).data("settings").propertyToSearch]) { + notFound = false; + return false; + } + }); + + if (notFound) { + trimmedList.push(value); + } + }); + results = trimmedList; + } } - - if(query.length >= settings.minChars) { - show_dropdown_searching(); - clearTimeout(timeout); - - timeout = setTimeout(function(){ - run_search(query); - }, settings.searchDelay); + + return results; + } + + // Populate the results dropdown with some results + function populateDropdown (query, results) { + // exclude current tokens if configured + results = excludeCurrent(results); + + if(results && results.length) { + dropdown.empty(); + var dropdown_ul = $("
            ") + .appendTo(dropdown) + .mouseover(function (event) { + select_dropdown_item($(event.target).closest("li")); + }) + .mousedown(function (event) { + add_token($(event.target).closest("li").data("tokeninput")); + hiddenInput.change(); + return false; + }) + .hide(); + + if ($(input).data("settings").resultsLimit && results.length > $(input).data("settings").resultsLimit) { + results = results.slice(0, $(input).data("settings").resultsLimit); + } + + $.each(results, function(index, value) { + var this_li = $(input).data("settings").resultsFormatter(value); + + this_li = find_value_and_highlight_term(this_li ,value[$(input).data("settings").propertyToSearch], query); + this_li = $(this_li).appendTo(dropdown_ul); + + if(index % 2) { + this_li.addClass($(input).data("settings").classes.dropdownItem); + } else { + this_li.addClass($(input).data("settings").classes.dropdownItem2); + } + + if(index === 0 && $(input).data("settings").autoSelectFirstResult) { + select_dropdown_item(this_li); + } + + $.data(this_li.get(0), "tokeninput", value); + }); + + show_dropdown(); + + if($(input).data("settings").animateDropdown) { + dropdown_ul.slideDown("fast"); + } else { + dropdown_ul.show(); + } } else { - hide_dropdown(); + if($(input).data("settings").noResultsText) { + dropdown.html("

            " + escapeHTML($(input).data("settings").noResultsText) + "

            "); + show_dropdown(); + } } } - } - - // Do the actual search - function run_search(query) { - var cache_key = query + computeURL(); - var cached_results = cache.get(cache_key); - if(cached_results) { - populate_dropdown(query, cached_results); - } else { - // Are we doing an ajax search or local data search? - if(settings.url) { - var url = computeURL(); - // Extract exisiting get params - var ajax_params = {}; - ajax_params.data = {}; - if(url.indexOf("?") > -1) { - var parts = url.split("?"); - ajax_params.url = parts[0]; - - var param_array = parts[1].split("&"); - $.each(param_array, function (index, value) { - var kv = value.split("="); - ajax_params.data[kv[0]] = kv[1]; - }); + + // Highlight an item in the results dropdown + function select_dropdown_item (item) { + if(item) { + if(selected_dropdown_item) { + deselect_dropdown_item($(selected_dropdown_item)); + } + + item.addClass($(input).data("settings").classes.selectedDropdownItem); + selected_dropdown_item = item.get(0); + } + } + + // Remove highlighting from an item in the results dropdown + function deselect_dropdown_item (item) { + item.removeClass($(input).data("settings").classes.selectedDropdownItem); + selected_dropdown_item = null; + } + + // Do a search and show the "searching" dropdown if the input is longer + // than $(input).data("settings").minChars + function do_search() { + var query = input_box.val(); + + if(query && query.length) { + if(selected_token) { + deselect_token($(selected_token), POSITION.AFTER); + } + + if(query.length >= $(input).data("settings").minChars) { + show_dropdown_searching(); + clearTimeout(timeout); + + timeout = setTimeout(function(){ + run_search(query); + }, $(input).data("settings").searchDelay); } else { - ajax_params.url = url; + hide_dropdown(); } - - // Prepare the request - ajax_params.data[settings.queryParam] = query; - ajax_params.type = settings.method; - ajax_params.dataType = settings.contentType; - if(settings.crossDomain) { - ajax_params.dataType = "jsonp"; + } + } + + // Do the actual search + function run_search(query) { + var cache_key = query + computeURL(); + var cached_results = cache.get(cache_key); + if (cached_results) { + if ($.isFunction($(input).data("settings").onCachedResult)) { + cached_results = $(input).data("settings").onCachedResult.call(hiddenInput, cached_results); } - - // Attach the success callback - ajax_params.success = function(results) { - if($.isFunction(settings.onResult)) { - results = settings.onResult.call(hidden_input, results); - } - cache.add(cache_key, settings.jsonContainer ? results[settings.jsonContainer] : results); - - // only populate the dropdown if the results are associated with the active search query - if(input_box.val() === query) { - populate_dropdown(query, settings.jsonContainer ? results[settings.jsonContainer] : results); - } - }; - - // Make the request - $.ajax(ajax_params); - } else if(settings.local_data) { - // Do the search through local data - var results = $.grep(settings.local_data, function (row) { - return row[settings.propertyToSearch].toLowerCase().indexOf(query.toLowerCase()) > -1; - }); - - if($.isFunction(settings.onResult)) { - results = settings.onResult.call(hidden_input, results); + populateDropdown(query, cached_results); + } else { + // Are we doing an ajax search or local data search? + if($(input).data("settings").url) { + var url = computeURL(); + // Extract existing get params + var ajax_params = {}; + ajax_params.data = {}; + if(url.indexOf("?") > -1) { + var parts = url.split("?"); + ajax_params.url = parts[0]; + + var param_array = parts[1].split("&"); + $.each(param_array, function (index, value) { + var kv = value.split("="); + ajax_params.data[kv[0]] = kv[1]; + }); + } else { + ajax_params.url = url; + } + + // Prepare the request + ajax_params.data[$(input).data("settings").queryParam] = query; + ajax_params.type = $(input).data("settings").method; + ajax_params.dataType = $(input).data("settings").contentType; + if ($(input).data("settings").crossDomain) { + ajax_params.dataType = "jsonp"; + } + + // exclude current tokens? + // send exclude list to the server, so it can also exclude existing tokens + if ($(input).data("settings").excludeCurrent) { + var currentTokens = $(input).data("tokenInputObject").getTokens(); + var tokenList = $.map(currentTokens, function (el) { + if(typeof $(input).data("settings").tokenValue == 'function') + return $(input).data("settings").tokenValue.call(this, el); + + return el[$(input).data("settings").tokenValue]; + }); + + ajax_params.data[$(input).data("settings").excludeCurrentParameter] = tokenList.join($(input).data("settings").tokenDelimiter); + } + + // Attach the success callback + ajax_params.success = function(results) { + cache.add(cache_key, $(input).data("settings").jsonContainer ? results[$(input).data("settings").jsonContainer] : results); + if($.isFunction($(input).data("settings").onResult)) { + results = $(input).data("settings").onResult.call(hiddenInput, results); + } + + // only populate the dropdown if the results are associated with the active search query + if(input_box.val() === query) { + populateDropdown(query, $(input).data("settings").jsonContainer ? results[$(input).data("settings").jsonContainer] : results); + } + }; + + // Provide a beforeSend callback + if (settings.onSend) { + settings.onSend(ajax_params); + } + + // Make the request + $.ajax(ajax_params); + } else if($(input).data("settings").local_data) { + // Do the search through local data + var results = $.grep($(input).data("settings").local_data, function (row) { + return row[$(input).data("settings").propertyToSearch].toLowerCase().indexOf(query.toLowerCase()) > -1; + }); + + cache.add(cache_key, results); + if($.isFunction($(input).data("settings").onResult)) { + results = $(input).data("settings").onResult.call(hiddenInput, results); + } + populateDropdown(query, results); } - cache.add(cache_key, results); - populate_dropdown(query, results); } } - } - - // compute the dynamic URL - function computeURL() { - var url = settings.url; - if(typeof settings.url == 'function') { - url = settings.url.call(settings); + + // compute the dynamic URL + function computeURL() { + var settings = $(input).data("settings"); + return typeof settings.url == 'function' ? settings.url.call(settings) : settings.url; } - return url; - } -}; - -// Really basic cache for the results -$.TokenList.Cache = function (options) { - var settings = $.extend({ - max_size: 500 - }, options); - - var data = {}; - var size = 0; - - var flush = function () { + + // Bring browser focus to the specified object. + // Use of setTimeout is to get around an IE bug. + // (See, e.g., http://stackoverflow.com/questions/2600186/focus-doesnt-work-in-ie) + // + // obj: a jQuery object to focus() + function focusWithTimeout(object) { + setTimeout( + function() { + object.focus(); + }, + 50 + ); + } + }; + + // Really basic cache for the results + $.TokenList.Cache = function (options) { + var settings, data = {}, size = 0, flush; + + settings = $.extend({ max_size: 500 }, options); + + flush = function () { data = {}; size = 0; - }; - - this.add = function (query, results) { - if(size > settings.max_size) { - flush(); + }; + + this.add = function (query, results) { + if (size > settings.max_size) { + flush(); } - - if(!data[query]) { - size += 1; + + if (!data[query]) { + size += 1; } - + data[query] = results; - }; - - this.get = function (query) { + }; + + this.get = function (query) { return data[query]; + }; }; -}; -}(jQuery)); + + }(jQuery)); + \ No newline at end of file diff --git a/kitsune/sumo/static/sumo/js/messages.autocomplete.js b/kitsune/sumo/static/sumo/js/messages.autocomplete.js new file mode 100644 index 00000000000..36340eb0cc9 --- /dev/null +++ b/kitsune/sumo/static/sumo/js/messages.autocomplete.js @@ -0,0 +1,75 @@ +import "sumo/js/libs/jquery.tokeninput"; +import { safeString, safeInterpolate } from "sumo/js/main"; + +/* + * autocomplete.js + * A generic autocomplete widget for both groups and users. + */ + +(function($) { + + 'use strict'; + + function initAutocomplete(options) { + function wrapTerm(string, term) { + term = (term + '').replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1'); + var regex = new RegExp( '(' + term + ')', 'gi' ); + return string.replace(regex, '$1'); + } + + var prefill = []; + var selector = options.selector; + var valueField = options.valueField; + + if ($(selector).val()) { + prefill = $(selector).val().split(',').map(function(value) { + var item = {}; + item[valueField] = safeString(value); + if (options.displayField) { + item[options.displayField] = safeString(value); + } + return item; + }); + } + + var tokenInputSettings = { + theme: 'facebook', + hintText: gettext(options.hintText), + queryParam: 'term', + propertyToSearch: valueField, + tokenValue: valueField, + prePopulate: prefill, + resultsFormatter: function(item) { + var term = $(`token-input-${selector}`).val(); + if (options.resultsFormatter) { + return options.resultsFormatter(item, term); + } + return safeInterpolate('
          • ', {value: item['type']}, true); + }, + onAdd: function (item) { + $(this).closest('.single').closest('form').submit(); + } + }; + + $(`input${selector}`).tokenInput(options.apiEndpoint, tokenInputSettings); + } + + // Initialize autocomplete for users or groups + $(function() { + initAutocomplete({ + selector: '.user-autocomplete', + apiEndpoint: $('body').data('messages-api'), + valueField: 'name', + displayField: 'name', + hintText: 'Search for a user or group. Group mail requires Staff group membership.', + placeholder: 'Type a user or group name', + resultsFormatter: function(item) { + if ((item.display_name) && (item.type === 'user')) { + return safeInterpolate('
          • icon for %(type)s
          • ', item, true); + } + return safeInterpolate('
          • icon for %(type)s
          • ', item, true); + } + }); + }); + +})(jQuery); diff --git a/kitsune/sumo/static/sumo/scss/components/_inbox.scss b/kitsune/sumo/static/sumo/scss/components/_inbox.scss index 3cfefece11a..19be628d01e 100644 --- a/kitsune/sumo/static/sumo/scss/components/_inbox.scss +++ b/kitsune/sumo/static/sumo/scss/components/_inbox.scss @@ -66,7 +66,7 @@ } .avatar-details { - width: 190px; + width: 100%; flex: 0 0 auto; } } @@ -81,6 +81,16 @@ .avatar-details { display: flex; align-items: center; + gap: 12px; + + span { + flex: 2; + text-align: left; + } + + .message-view { + flex: 2; + } .avatar { display: block; @@ -94,11 +104,9 @@ } } - .user { - flex: 1 1 auto; + .user, .group { a { - font-weight: bold; color: var(--color-heading); text-decoration: none; @@ -108,11 +116,11 @@ } } - time { - display: block; - @include c.text-body-xs; - font-weight: normal; - color: var(--color-text); + p { + font-weight: bold; + color: var(--color-heading); + text-decoration: none; + padding-right:5px; } } } @@ -122,3 +130,31 @@ @include c.ulol; } } + +@media (max-width: 767px) { + .group, .user { + display: none; + } +} + +time { + display: block; + @include c.text-body-xs; + font-weight: normal; + color: var(--color-text); +} + +.to, +.to-group { + p { + margin: 0; + padding: 0; + font-weight: bold; + color: var(--color-heading); + text-decoration: none; + } +} + +.read-message { + margin-top: 20px; +} \ No newline at end of file diff --git a/kitsune/sumo/templatetags/jinja_helpers.py b/kitsune/sumo/templatetags/jinja_helpers.py index 88add0261b1..dd2faa27b58 100644 --- a/kitsune/sumo/templatetags/jinja_helpers.py +++ b/kitsune/sumo/templatetags/jinja_helpers.py @@ -30,6 +30,7 @@ from kitsune.sumo.urlresolvers import reverse from kitsune.sumo.utils import is_trusted_user as is_trusted_user_func from kitsune.sumo.utils import webpack_static as webpack_static_func +from kitsune.sumo.utils import in_staff_group from kitsune.users.models import Profile from kitsune.wiki.showfor import showfor_data as _showfor_data @@ -567,3 +568,6 @@ def show_header_fx_download(context): @library.global_function def is_trusted_user(user): return is_trusted_user_func(user) + + +library.global_function(in_staff_group) diff --git a/kitsune/sumo/utils.py b/kitsune/sumo/utils.py index b6569040fa8..2f97630940f 100644 --- a/kitsune/sumo/utils.py +++ b/kitsune/sumo/utils.py @@ -368,3 +368,8 @@ def is_trusted_user(user: User) -> bool: user.is_staff, ] ) + + +def in_staff_group(user: User | None) -> bool: + """Check if a user is in the Staff group.""" + return bool(user and user.is_authenticated and user.profile.in_staff_group) diff --git a/kitsune/users/models.py b/kitsune/users/models.py index 8585a3b6993..b48e4e424d3 100644 --- a/kitsune/users/models.py +++ b/kitsune/users/models.py @@ -1,6 +1,7 @@ import logging import re from datetime import datetime +from functools import cached_property from django.conf import settings from django.contrib.auth.models import User @@ -227,6 +228,10 @@ def answer_helpfulness(self): def is_subscriber(self): return self.products.exists() + @cached_property + def in_staff_group(self): + return self.user.groups.filter(name=settings.STAFF_GROUP).exists() + class Setting(ModelBase): """User specific value per setting""" diff --git a/kitsune/users/tests/test_tasks.py b/kitsune/users/tests/test_tasks.py index b44d59a76d5..016255336d0 100644 --- a/kitsune/users/tests/test_tasks.py +++ b/kitsune/users/tests/test_tasks.py @@ -30,8 +30,8 @@ def test_process_delete_user(self): # Populate inboxes and outboxes with messages between the user and other users. other_users = UserFactory.create_batch(2) for sender in other_users: - send_message([user], "foo", sender=sender) - send_message(other_users, "bar", sender=user) + send_message([user], to_group="", text="foo", sender=sender) + send_message(other_users, to_group="", text="bar", sender=user) # Confirm the expected initial state. self.assertTrue(user.is_active) diff --git a/kitsune/users/tests/test_views.py b/kitsune/users/tests/test_views.py index d666c2e9782..e0e8a02c343 100644 --- a/kitsune/users/tests/test_views.py +++ b/kitsune/users/tests/test_views.py @@ -438,8 +438,8 @@ def setUp(self): # Populate inboxes and outboxes with messages between the user and other users. self.other_users = UserFactory.create_batch(2) for sender in self.other_users: - send_message([self.user], "foo", sender=sender) - send_message(self.other_users, "bar", sender=self.user) + send_message([self.user], to_group="", text="foo", sender=sender) + send_message(self.other_users, to_group="", text="bar", sender=self.user) super(UserCloseAccountTests, self).setUp() def tearDown(self): diff --git a/webpack/entrypoints.js b/webpack/entrypoints.js index 7325381c199..d559f733f8a 100644 --- a/webpack/entrypoints.js +++ b/webpack/entrypoints.js @@ -90,7 +90,7 @@ const entrypoints = { "sumo/js/reportabuse.js", ], messages: [ - "sumo/js/users.autocomplete.js", + "sumo/js/messages.autocomplete.js", "sumo/js/messages.js", ], groups: [