onboarding_steps: Add 'OneTimeNotice' dataclass.

This commit adds a 'OneTimeNotice' dataclass to
support one time banner and similar UI elements.
This commit is contained in:
Prakhar Pratyush 2023-12-01 19:06:24 +05:30 committed by Tim Abbott
parent df379b5e86
commit dde3d72100
4 changed files with 61 additions and 26 deletions

View File

@ -1,9 +1,9 @@
from zerver.lib.hotspots import get_next_hotspots
from zerver.lib.hotspots import get_next_onboarding_steps
from zerver.models import OnboardingStep, UserProfile
from zerver.tornado.django_api import send_event
def do_mark_onboarding_step_as_read(user: UserProfile, onboarding_step: str) -> None:
OnboardingStep.objects.get_or_create(user=user, onboarding_step=onboarding_step)
event = dict(type="hotspots", hotspots=get_next_hotspots(user))
event = dict(type="hotspots", hotspots=get_next_onboarding_steps(user))
send_event(user.realm, event, [user.id])

View File

@ -18,7 +18,7 @@ from zerver.lib.compatibility import is_outdated_server
from zerver.lib.default_streams import get_default_streams_for_realm_as_dicts
from zerver.lib.exceptions import JsonableError
from zerver.lib.external_accounts import get_default_external_accounts
from zerver.lib.hotspots import get_next_hotspots
from zerver.lib.hotspots import get_next_onboarding_steps
from zerver.lib.integrations import (
EMBEDDED_BOTS,
WEBHOOK_INTEGRATIONS,
@ -188,7 +188,7 @@ def fetch_initial_state_data(
# Even if we offered special hotspots for guests without an
# account, we'd maybe need to store their state using cookies
# or local storage, rather than in the database.
state["hotspots"] = [] if user_profile is None else get_next_hotspots(user_profile)
state["hotspots"] = [] if user_profile is None else get_next_onboarding_steps(user_profile)
if want("message"):
# Since the introduction of `anchor="latest"` in the API,

View File

@ -1,7 +1,7 @@
# See https://zulip.readthedocs.io/en/latest/subsystems/hotspots.html
# for documentation on this subsystem.
from dataclasses import dataclass
from typing import Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Union
from django.conf import settings
from django.utils.translation import gettext_lazy
@ -67,14 +67,28 @@ INTRO_HOTSPOTS: List[Hotspot] = [
NON_INTRO_HOTSPOTS: List[Hotspot] = []
@dataclass
class OneTimeNotice:
name: str
def to_dict(self) -> Dict[str, str]:
return {
"type": "one_time_notice",
"name": self.name,
}
ONE_TIME_NOTICES: List[OneTimeNotice] = []
# We would most likely implement new hotspots in the future that aren't
# a part of the initial tutorial. To that end, classifying them into
# categories which are aggregated in ALL_HOTSPOTS, seems like a good start.
ALL_HOTSPOTS = [*INTRO_HOTSPOTS, *NON_INTRO_HOTSPOTS]
ALL_ONBOARDING_STEPS = ALL_HOTSPOTS
ALL_ONBOARDING_STEPS: List[Union[Hotspot, OneTimeNotice]] = [*ALL_HOTSPOTS, *ONE_TIME_NOTICES]
def get_next_hotspots(user: UserProfile) -> List[Dict[str, Union[str, float, bool]]]:
def get_next_onboarding_steps(user: UserProfile) -> List[Dict[str, Any]]:
# For manual testing, it can be convenient to set
# ALWAYS_SEND_ALL_HOTSPOTS=True in `zproject/dev_settings.py` to
# make it easy to click on all of the hotspots.
@ -88,25 +102,30 @@ def get_next_hotspots(user: UserProfile) -> List[Dict[str, Union[str, float, boo
if not settings.TUTORIAL_ENABLED:
return []
seen_hotspots = frozenset(
seen_onboarding_steps = frozenset(
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
)
hotspots = [hotspot.to_dict() for hotspot in NON_INTRO_HOTSPOTS]
onboarding_steps: List[Dict[str, Any]] = [hotspot.to_dict() for hotspot in NON_INTRO_HOTSPOTS]
for one_time_notice in ONE_TIME_NOTICES:
if one_time_notice.name in seen_onboarding_steps:
continue
onboarding_steps.append(one_time_notice.to_dict())
if user.tutorial_status == UserProfile.TUTORIAL_FINISHED:
return hotspots
return onboarding_steps
for hotspot in INTRO_HOTSPOTS:
if hotspot.name in seen_hotspots:
if hotspot.name in seen_onboarding_steps:
continue
hotspots.append(hotspot.to_dict(delay=0.5))
return hotspots
onboarding_steps.append(hotspot.to_dict(delay=0.5))
return onboarding_steps
user.tutorial_status = UserProfile.TUTORIAL_FINISHED
user.save(update_fields=["tutorial_status"])
return hotspots
return onboarding_steps
def copy_hotspots(source_profile: UserProfile, target_profile: UserProfile) -> None:

View File

@ -2,14 +2,20 @@ from typing_extensions import override
from zerver.actions.create_user import do_create_user
from zerver.actions.hotspots import do_mark_onboarding_step_as_read
from zerver.lib.hotspots import ALL_HOTSPOTS, INTRO_HOTSPOTS, NON_INTRO_HOTSPOTS, get_next_hotspots
from zerver.lib.hotspots import (
ALL_HOTSPOTS,
INTRO_HOTSPOTS,
NON_INTRO_HOTSPOTS,
ONE_TIME_NOTICES,
get_next_onboarding_steps,
)
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import OnboardingStep, UserProfile, get_realm
# Splitting this out, since I imagine this will eventually have most of the
# complicated hotspots logic.
class TestGetNextHotspots(ZulipTestCase):
# complicated onboarding steps logic.
class TestGetNextOnboardingSteps(ZulipTestCase):
@override
def setUp(self) -> None:
super().setUp()
@ -18,37 +24,47 @@ class TestGetNextHotspots(ZulipTestCase):
)
def test_first_hotspot(self) -> None:
hotspots = get_next_hotspots(self.user)
for hotspot in NON_INTRO_HOTSPOTS: # nocoverage
do_mark_onboarding_step_as_read(self.user, hotspot.name)
for one_time_notice in ONE_TIME_NOTICES: # nocoverage
do_mark_onboarding_step_as_read(self.user, one_time_notice.name)
hotspots = get_next_onboarding_steps(self.user)
self.assert_length(hotspots, 1)
self.assertEqual(hotspots[0]["name"], "intro_streams")
def test_some_done_some_not(self) -> None:
do_mark_onboarding_step_as_read(self.user, "intro_streams")
do_mark_onboarding_step_as_read(self.user, "intro_compose")
hotspots = get_next_hotspots(self.user)
self.assert_length(hotspots, 1)
self.assertEqual(hotspots[0]["name"], "intro_topics")
onboarding_steps = get_next_onboarding_steps(self.user)
self.assert_length(onboarding_steps, 1)
self.assertEqual(onboarding_steps[0]["name"], "intro_topics")
def test_all_hotspots_done(self) -> None:
def test_all_onboarding_steps_done(self) -> None:
with self.settings(TUTORIAL_ENABLED=True):
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
for hotspot in NON_INTRO_HOTSPOTS: # nocoverage
do_mark_onboarding_step_as_read(self.user, hotspot.name)
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
for one_time_notice in ONE_TIME_NOTICES: # nocoverage
do_mark_onboarding_step_as_read(self.user, one_time_notice.name)
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
for hotspot in INTRO_HOTSPOTS:
do_mark_onboarding_step_as_read(self.user, hotspot.name)
self.assertEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
self.assertEqual(get_next_hotspots(self.user), [])
self.assertEqual(get_next_onboarding_steps(self.user), [])
def test_send_all(self) -> None:
def test_send_all_hotspots(self) -> None:
with self.settings(DEVELOPMENT=True, ALWAYS_SEND_ALL_HOTSPOTS=True):
self.assert_length(ALL_HOTSPOTS, len(get_next_hotspots(self.user)))
self.assert_length(ALL_HOTSPOTS, len(get_next_onboarding_steps(self.user)))
def test_tutorial_disabled(self) -> None:
with self.settings(TUTORIAL_ENABLED=False):
self.assertEqual(get_next_hotspots(self.user), [])
self.assertEqual(get_next_onboarding_steps(self.user), [])
class TestOnboardingSteps(ZulipTestCase):