hotspots: Add backend support for tutorial hotspots.

This commit adds the backend support for a new style of tutorial which
allows for highlighting of multiple areas of the page with hotspots that
disappear when clicked by the user.
This commit is contained in:
Amy Liu 2017-01-23 16:48:35 -08:00 committed by Tim Abbott
parent 33c130a603
commit 6f061beb46
16 changed files with 220 additions and 2 deletions

View File

@ -119,7 +119,8 @@
"emoji_codes": false, "emoji_codes": false,
"drafts": false, "drafts": false,
"katex": false, "katex": false,
"Clipboard": false "Clipboard": false,
"hotspots": false
}, },
"rules": { "rules": {
"no-restricted-syntax": 0, "no-restricted-syntax": 0,

60
static/js/hotspots.js Normal file
View File

@ -0,0 +1,60 @@
var hotspots = (function () {
var exports = {};
exports.show = function (hotspot_list) {
$('.hotspot').hide();
for (var i = 0; i < hotspot_list.length; i += 1) {
$("#hotspot_".concat(hotspot_list[i])).show();
}
};
exports.initialize = function () {
exports.show(page_params.hotspots);
};
function mark_hotspot_as_read(hotspot) {
channel.post({
url: '/json/users/me/hotspots',
data: {hotspot: JSON.stringify(hotspot)},
});
}
$(function () {
$("#hotspot_welcome").on('click', function (e) {
mark_hotspot_as_read("welcome");
e.preventDefault();
e.stopPropagation();
});
$("#hotspot_streams").on('click', function (e) {
mark_hotspot_as_read("streams");
e.preventDefault();
e.stopPropagation();
});
$("#hotspot_topics").on('click', function (e) {
mark_hotspot_as_read("topics");
e.preventDefault();
e.stopPropagation();
});
$("#hotspot_narrowing").on('click', function (e) {
mark_hotspot_as_read("narrowing");
e.preventDefault();
e.stopPropagation();
});
$("#hotspot_replying").on('click', function (e) {
mark_hotspot_as_read("replying");
e.preventDefault();
e.stopPropagation();
});
$("#hotspot_get_started").on('click', function (e) {
mark_hotspot_as_read("get_started");
e.preventDefault();
e.stopPropagation();
});
});
return exports;
}());
if (typeof module !== 'undefined') {
module.exports = hotspots;
}

View File

@ -23,6 +23,11 @@ function dispatch_normal_event(event) {
admin.update_default_streams_table(); admin.update_default_streams_table();
break; break;
case 'hotspots':
hotspots.show(event.hotspots);
page_params.hotspots = event.hotspots;
break;
case 'muted_topics': case 'muted_topics':
muting_ui.handle_updates(event.muted_topics); muting_ui.handle_updates(event.muted_topics);
break; break;

View File

@ -256,6 +256,7 @@ $(function () {
unread_ui.initialize(); unread_ui.initialize();
activity.initialize(); activity.initialize();
emoji.initialize(); emoji.initialize();
hotspots.initialize();
}); });

View File

@ -20,6 +20,7 @@ from zerver.lib.cache import (
to_dict_cache_key_id, to_dict_cache_key_id,
) )
from zerver.lib.context_managers import lockfile from zerver.lib.context_managers import lockfile
from zerver.lib.hotspots import get_next_hotspots
from zerver.lib.message import ( from zerver.lib.message import (
access_message, access_message,
MessageDict, MessageDict,
@ -28,7 +29,7 @@ from zerver.lib.message import (
) )
from zerver.lib.realm_icon import realm_icon_url from zerver.lib.realm_icon import realm_icon_url
from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, RealmAlias, \ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, RealmAlias, \
Subscription, Recipient, Message, Attachment, UserMessage, RealmAuditLog, \ Subscription, Recipient, Message, Attachment, UserMessage, RealmAuditLog, UserHotspot, \
Client, DefaultStream, UserPresence, Referral, PushDeviceToken, MAX_SUBJECT_LENGTH, \ Client, DefaultStream, UserPresence, Referral, PushDeviceToken, MAX_SUBJECT_LENGTH, \
MAX_MESSAGE_LENGTH, get_client, get_stream, get_recipient, get_huddle, \ MAX_MESSAGE_LENGTH, get_client, get_stream, get_recipient, get_huddle, \
get_user_profile_by_id, PreregistrationUser, get_display_recipient, \ get_user_profile_by_id, PreregistrationUser, get_display_recipient, \
@ -3174,6 +3175,12 @@ def do_update_muted_topic(user_profile, stream, topic, op):
event = dict(type="muted_topics", muted_topics=muted_topics) event = dict(type="muted_topics", muted_topics=muted_topics)
send_event(event, [user_profile.id]) send_event(event, [user_profile.id])
def do_mark_hotspot_as_read(user, hotspot):
# type: (UserProfile, str) -> None
UserHotspot.objects.get_or_create(user=user, hotspot=hotspot)
event = dict(type="hotspots", hotspots=get_next_hotspots(user))
send_event(event, [user.id])
def notify_realm_filters(realm): def notify_realm_filters(realm):
# type: (Realm) -> None # type: (Realm) -> None
realm_filters = realm_filters_for_realm(realm.id) realm_filters = realm_filters_for_realm(realm.id)

View File

@ -20,6 +20,7 @@ session_engine = import_module(settings.SESSION_ENGINE)
from zerver.lib.alert_words import user_alert_words from zerver.lib.alert_words import user_alert_words
from zerver.lib.attachments import user_attachments from zerver.lib.attachments import user_attachments
from zerver.lib.avatar import get_avatar_url from zerver.lib.avatar import get_avatar_url
from zerver.lib.hotspots import get_next_hotspots
from zerver.lib.narrow import check_supported_events_narrow_filter from zerver.lib.narrow import check_supported_events_narrow_filter
from zerver.lib.realm_icon import realm_icon_url from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.request import JsonableError from zerver.lib.request import JsonableError
@ -72,6 +73,9 @@ def fetch_initial_state_data(user_profile, event_types, queue_id,
if want('attachments'): if want('attachments'):
state['attachments'] = user_attachments(user_profile) state['attachments'] = user_attachments(user_profile)
if want('hotspots'):
state['hotspots'] = get_next_hotspots(user_profile)
if want('message'): if want('message'):
# The client should use get_messages() to fetch messages # The client should use get_messages() to fetch messages
# starting with the max_message_id. They will get messages # starting with the max_message_id. They will get messages
@ -181,6 +185,8 @@ def apply_event(state, event, user_profile, include_subscribers):
# type: (Dict[str, Any], Dict[str, Any], UserProfile, bool) -> None # type: (Dict[str, Any], Dict[str, Any], UserProfile, bool) -> None
if event['type'] == "message": if event['type'] == "message":
state['max_message_id'] = max(state['max_message_id'], event['message']['id']) state['max_message_id'] = max(state['max_message_id'], event['message']['id'])
elif event['type'] == "hotspots":
state['hotspots'] = event['hotspots']
elif event['type'] == "pointer": elif event['type'] == "pointer":
state['pointer'] = max(state['pointer'], event['pointer']) state['pointer'] = max(state['pointer'], event['pointer'])
elif event['type'] == "realm_user": elif event['type'] == "realm_user":

12
zerver/lib/hotspots.py Normal file
View File

@ -0,0 +1,12 @@
from zerver.models import UserProfile, UserHotspot
from typing import List, Text
ALL_HOTSPOTS = ['welcome', 'streams', 'topics', 'narrowing', 'replying', 'get_started']
def get_next_hotspots(user):
# type: (UserProfile) -> List[Text]
seen_hotspots = frozenset(UserHotspot.objects.filter(user=user).values_list('hotspot', flat=True))
for hotspot in ALL_HOTSPOTS:
if hotspot not in seen_hotspots:
return [hotspot]
return []

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-03-28 00:22
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('zerver', '0069_realmauditlog_extra_data'),
]
operations = [
migrations.CreateModel(
name='UserHotspot',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hotspot', models.CharField(max_length=30)),
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AlterUniqueTogether(
name='userhotspot',
unique_together=set([('user', 'hotspot')]),
),
]

View File

@ -1553,3 +1553,11 @@ class RealmAuditLog(models.Model):
event_time = models.DateTimeField() # type: datetime.datetime event_time = models.DateTimeField() # type: datetime.datetime
backfilled = models.BooleanField(default=False) # type: bool backfilled = models.BooleanField(default=False) # type: bool
extra_data = models.TextField(null=True) # type: Text extra_data = models.TextField(null=True) # type: Text
class UserHotspot(models.Model):
user = models.ForeignKey(UserProfile) # type: UserProfile
hotspot = models.CharField(max_length=30) # type: Text
timestamp = models.DateTimeField(default=timezone.now) # type: datetime.datetime
class Meta(object):
unique_together = ("user", "hotspot")

View File

@ -38,6 +38,7 @@ from zerver.lib.actions import (
do_create_user, do_create_user,
do_deactivate_stream, do_deactivate_stream,
do_deactivate_user, do_deactivate_user,
do_mark_hotspot_as_read,
do_reactivate_user, do_reactivate_user,
do_refer_friend, do_refer_friend,
do_regenerate_api_key, do_regenerate_api_key,
@ -1451,6 +1452,16 @@ class EventsRegisterTest(ZulipTestCase):
error = bot_reactivate_checker('events[1]', events[1]) error = bot_reactivate_checker('events[1]', events[1])
self.assert_on_error(error) self.assert_on_error(error)
def test_do_mark_hotspot_as_read(self):
# type: () -> None
schema_checker = check_dict([
('type', equals('hotspots')),
('hotspots', check_list(check_string)),
])
events = self.do_test(lambda: do_mark_hotspot_as_read(self.user_profile, 'welcome'))
error = schema_checker('events[0]', events[0])
self.assert_on_error(error)
def test_rename_stream(self): def test_rename_stream(self):
# type: () -> None # type: () -> None
stream = self.make_stream('old_name') stream = self.make_stream('old_name')

View File

@ -68,6 +68,7 @@ class HomeTest(ZulipTestCase):
"furthest_read_time", "furthest_read_time",
"has_mobile_devices", "has_mobile_devices",
"have_initial_messages", "have_initial_messages",
"hotspots",
"initial_pointer", "initial_pointer",
"initial_presences", "initial_presences",
"initial_servertime", "initial_servertime",

View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from zerver.lib.actions import do_mark_hotspot_as_read
from zerver.lib.hotspots import ALL_HOTSPOTS, get_next_hotspots
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import UserProfile, UserHotspot
from zerver.views.hotspots import mark_hotspot_as_read
from typing import Any, Dict
import ujson
# Splitting this out, since I imagine this will eventually have most of the
# complicated hotspots logic.
class TestGetNextHotspots(ZulipTestCase):
def test_first_hotspot(self):
# type: () -> None
user = UserProfile.objects.get(email='hamlet@zulip.com')
self.assertEqual(get_next_hotspots(user), ['welcome'])
def test_some_done_some_not(self):
# type: () -> None
user = UserProfile.objects.get(email='hamlet@zulip.com')
do_mark_hotspot_as_read(user, 'welcome')
do_mark_hotspot_as_read(user, 'topics')
self.assertEqual(get_next_hotspots(user), ['streams'])
def test_all_done(self):
# type: () -> None
user = UserProfile.objects.get(email='hamlet@zulip.com')
for hotspot in ALL_HOTSPOTS:
do_mark_hotspot_as_read(user, hotspot)
self.assertEqual(get_next_hotspots(user), [])
class TestHotspots(ZulipTestCase):
def test_do_mark_hotspot_as_read(self):
# type: () -> None
user = UserProfile.objects.get(email='hamlet@zulip.com')
do_mark_hotspot_as_read(user, 'streams')
self.assertEqual(list(UserHotspot.objects.filter(user=user)
.values_list('hotspot', flat=True)), ['streams'])
def test_hotspots_url_endpoint(self):
# type: () -> None
email = 'hamlet@zulip.com'
user = UserProfile.objects.get(email=email)
self.login(email)
result = self.client_post('/json/users/me/hotspots',
{'hotspot': ujson.dumps('welcome')})
self.assert_json_success(result)
self.assertEqual(list(UserHotspot.objects.filter(user=user)
.values_list('hotspot', flat=True)), ['welcome'])

View File

@ -283,6 +283,7 @@ def home_real(request):
'attachments', 'attachments',
'default_language', 'default_language',
'emoji_alt_code', 'emoji_alt_code',
'hotspots',
'last_event_id', 'last_event_id',
'left_side_userlist', 'left_side_userlist',
'max_icon_file_size', 'max_icon_file_size',

17
zerver/views/hotspots.py Normal file
View File

@ -0,0 +1,17 @@
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _
from zerver.decorator import has_request_variables, REQ
from zerver.lib.actions import do_mark_hotspot_as_read
from zerver.lib.hotspots import ALL_HOTSPOTS
from zerver.lib.response import json_error, json_success
from zerver.lib.validator import check_string
from zerver.models import UserProfile
@has_request_variables
def mark_hotspot_as_read(request, user, hotspot=REQ(validator=check_string)):
# type: (HttpRequest, UserProfile, str) -> HttpResponse
if hotspot not in ALL_HOTSPOTS:
return json_error(_('Unknown hotspot: %s') % (hotspot,))
do_mark_hotspot_as_read(user, hotspot)
return json_success()

View File

@ -895,6 +895,7 @@ JS_SPECS = {
'js/colorspace.js', 'js/colorspace.js',
'js/timerender.js', 'js/timerender.js',
'js/tutorial.js', 'js/tutorial.js',
'js/hotspots.js',
'js/templates.js', 'js/templates.js',
'js/upload_widget.js', 'js/upload_widget.js',
'js/avatar.js', 'js/avatar.js',

View File

@ -298,6 +298,10 @@ v1_api_and_json_patterns = [
{'PUT': 'zerver.views.user_settings.set_avatar_backend', {'PUT': 'zerver.views.user_settings.set_avatar_backend',
'DELETE': 'zerver.views.user_settings.delete_avatar_backend'}), 'DELETE': 'zerver.views.user_settings.delete_avatar_backend'}),
# users/me/hotspots -> zerver.views.hotspots
url(r'^users/me/hotspots$', rest_dispatch,
{'POST': 'zerver.views.hotspots.mark_hotspot_as_read'}),
# settings -> zerver.views.user_settings # settings -> zerver.views.user_settings
url(r'^settings/display$', rest_dispatch, url(r'^settings/display$', rest_dispatch,
{'PATCH': 'zerver.views.user_settings.update_display_settings_backend'}), {'PATCH': 'zerver.views.user_settings.update_display_settings_backend'}),