feat(ui-switcher): add ui_switcher component (sync_ui_cookie, switch_ui, render_banner, render_settings_toggle)
This commit is contained in:
parent
0acde6d199
commit
5f7e7ee912
2 changed files with 273 additions and 0 deletions
167
app/components/ui_switcher.py
Normal file
167
app/components/ui_switcher.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""UI switcher component for Peregrine.
|
||||
|
||||
Manages the prgn_ui cookie (Caddy routing signal) and user.yaml
|
||||
ui_preference (durability across browser clears).
|
||||
|
||||
Cookie mechanics
|
||||
----------------
|
||||
Streamlit cannot read HTTP cookies server-side. Instead:
|
||||
- sync_ui_cookie() injects a JS snippet that sets document.cookie.
|
||||
- Vue SPA switch-back appends ?prgn_switch=streamlit to the redirect URL.
|
||||
sync_ui_cookie() reads this param via st.query_params and uses it as
|
||||
an override signal, then writes user.yaml to match.
|
||||
|
||||
Call sync_ui_cookie() in the app.py render pass (after pg.run()).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
import streamlit.components.v1 as components
|
||||
|
||||
from scripts.user_profile import UserProfile
|
||||
from app.wizard.tiers import can_use
|
||||
|
||||
_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
_COOKIE_JS = """
|
||||
<script>
|
||||
(function() {{
|
||||
document.cookie = 'prgn_ui={value}; path=/; SameSite=Lax';
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def _set_cookie_js(value: str) -> None:
|
||||
components.html(_COOKIE_JS.format(value=value), height=0)
|
||||
|
||||
|
||||
def sync_ui_cookie(yaml_path: Path, tier: str) -> None:
|
||||
"""Sync the prgn_ui cookie to match user.yaml ui_preference.
|
||||
|
||||
Also handles:
|
||||
- ?prgn_switch=<value> param (Vue SPA switch-back signal): overrides yaml,
|
||||
writes yaml to match, clears the param.
|
||||
- Tier downgrade: resets vue preference to streamlit for ineligible users.
|
||||
- ?ui_fallback=1 param: shows a toast (Vue SPA was unreachable).
|
||||
"""
|
||||
# ── ?ui_fallback=1 — Vue SPA was down, Caddy bounced us back ──────────────
|
||||
if st.query_params.get("ui_fallback"):
|
||||
st.toast("⚠️ New UI temporarily unavailable — switched back to Classic", icon="⚠️")
|
||||
st.query_params.pop("ui_fallback", None)
|
||||
|
||||
# ── ?prgn_switch param — Vue SPA sent us here to switch back ──────────────
|
||||
switch_param = st.query_params.get("prgn_switch")
|
||||
if switch_param in ("streamlit", "vue"):
|
||||
try:
|
||||
profile = UserProfile(yaml_path)
|
||||
profile.ui_preference = switch_param
|
||||
profile.save()
|
||||
except Exception:
|
||||
pass
|
||||
st.query_params.pop("prgn_switch", None)
|
||||
_set_cookie_js(switch_param)
|
||||
return
|
||||
|
||||
# ── Normal path: read yaml, enforce tier, inject cookie ───────────────────
|
||||
try:
|
||||
profile = UserProfile(yaml_path)
|
||||
pref = profile.ui_preference
|
||||
except Exception:
|
||||
pref = "streamlit"
|
||||
|
||||
# Tier downgrade protection (skip in demo — demo bypasses tier gate)
|
||||
if pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
|
||||
try:
|
||||
profile = UserProfile(yaml_path)
|
||||
profile.ui_preference = "streamlit"
|
||||
profile.save()
|
||||
except Exception:
|
||||
pass
|
||||
pref = "streamlit"
|
||||
|
||||
_set_cookie_js(pref)
|
||||
|
||||
|
||||
def switch_ui(yaml_path: Path, to: str, tier: str) -> None:
|
||||
"""Write user.yaml, sync cookie, rerun.
|
||||
|
||||
to: "vue" | "streamlit"
|
||||
"""
|
||||
if to not in ("vue", "streamlit"):
|
||||
return
|
||||
try:
|
||||
profile = UserProfile(yaml_path)
|
||||
profile.ui_preference = to
|
||||
profile.save()
|
||||
except Exception:
|
||||
pass
|
||||
sync_ui_cookie(yaml_path, tier=tier)
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render_banner(yaml_path: Path, tier: str) -> None:
|
||||
"""Show the 'Try the new UI' banner once per session.
|
||||
|
||||
Dismissed flag stored in user.yaml dismissed_banners list so it
|
||||
persists across sessions (uses the existing dismissed_banners pattern).
|
||||
Eligible: paid+ tier, OR demo mode. Not shown if already on vue.
|
||||
"""
|
||||
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
||||
if not eligible:
|
||||
return
|
||||
|
||||
try:
|
||||
profile = UserProfile(yaml_path)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if profile.ui_preference == "vue":
|
||||
return
|
||||
if "ui_switcher_beta" in (profile.dismissed_banners or []):
|
||||
return
|
||||
|
||||
col1, col2, col3 = st.columns([8, 1, 1])
|
||||
with col1:
|
||||
st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta, Paid tier)")
|
||||
with col2:
|
||||
if st.button("Try it", key="_ui_banner_try"):
|
||||
switch_ui(yaml_path, to="vue", tier=tier)
|
||||
with col3:
|
||||
if st.button("Dismiss", key="_ui_banner_dismiss"):
|
||||
profile.dismissed_banners = list(profile.dismissed_banners or []) + ["ui_switcher_beta"]
|
||||
profile.save()
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render_settings_toggle(yaml_path: Path, tier: str) -> None:
|
||||
"""Toggle in Settings → System → Deployment expander."""
|
||||
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
|
||||
if not eligible:
|
||||
return
|
||||
|
||||
try:
|
||||
profile = UserProfile(yaml_path)
|
||||
current = profile.ui_preference
|
||||
except Exception:
|
||||
current = "streamlit"
|
||||
|
||||
options = ["streamlit", "vue"]
|
||||
labels = ["Classic (Streamlit)", "✨ New UI (Vue, Beta)"]
|
||||
current_idx = options.index(current) if current in options else 0
|
||||
|
||||
st.markdown("**UI Version**")
|
||||
chosen = st.radio(
|
||||
"UI Version",
|
||||
options=labels,
|
||||
index=current_idx,
|
||||
key="_ui_toggle_radio",
|
||||
label_visibility="collapsed",
|
||||
)
|
||||
chosen_val = options[labels.index(chosen)]
|
||||
|
||||
if chosen_val != current:
|
||||
switch_ui(yaml_path, to=chosen_val, tier=tier)
|
||||
106
tests/test_ui_switcher.py
Normal file
106
tests/test_ui_switcher.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Tests for app/components/ui_switcher.py.
|
||||
|
||||
Streamlit is not running during tests — mock all st.* calls.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_yaml(tmp_path):
|
||||
data = {"name": "Test", "ui_preference": "streamlit", "wizard_complete": True}
|
||||
p = tmp_path / "user.yaml"
|
||||
p.write_text(yaml.dump(data))
|
||||
return p
|
||||
|
||||
|
||||
def test_sync_cookie_injects_vue_js(profile_yaml, monkeypatch):
|
||||
"""When ui_preference is vue, JS sets prgn_ui=vue."""
|
||||
import yaml as _yaml
|
||||
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
|
||||
|
||||
injected = []
|
||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
||||
|
||||
from app.components.ui_switcher import sync_ui_cookie
|
||||
sync_ui_cookie(profile_yaml, tier="paid")
|
||||
|
||||
assert any("prgn_ui=vue" in s for s in injected)
|
||||
|
||||
|
||||
def test_sync_cookie_injects_streamlit_js(profile_yaml, monkeypatch):
|
||||
"""When ui_preference is streamlit, JS sets prgn_ui=streamlit."""
|
||||
injected = []
|
||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
||||
|
||||
from app.components.ui_switcher import sync_ui_cookie
|
||||
sync_ui_cookie(profile_yaml, tier="paid")
|
||||
|
||||
assert any("prgn_ui=streamlit" in s for s in injected)
|
||||
|
||||
|
||||
def test_sync_cookie_prgn_switch_param_overrides_yaml(profile_yaml, monkeypatch):
|
||||
"""?prgn_switch=streamlit in query params resets ui_preference to streamlit."""
|
||||
import yaml as _yaml
|
||||
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
|
||||
|
||||
injected = []
|
||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
||||
monkeypatch.setattr("streamlit.query_params", {"prgn_switch": "streamlit"}, raising=False)
|
||||
|
||||
from importlib import reload
|
||||
import app.components.ui_switcher as m
|
||||
reload(m)
|
||||
|
||||
m.sync_ui_cookie(profile_yaml, tier="paid")
|
||||
|
||||
# user.yaml should now say streamlit
|
||||
saved = _yaml.safe_load(profile_yaml.read_text())
|
||||
assert saved["ui_preference"] == "streamlit"
|
||||
# JS should set cookie to streamlit
|
||||
assert any("prgn_ui=streamlit" in s for s in injected)
|
||||
|
||||
|
||||
def test_sync_cookie_downgrades_tier_resets_to_streamlit(profile_yaml, monkeypatch):
|
||||
"""Free-tier user with vue preference gets reset to streamlit."""
|
||||
import yaml as _yaml
|
||||
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
|
||||
|
||||
injected = []
|
||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
|
||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
||||
|
||||
from importlib import reload
|
||||
import app.components.ui_switcher as m
|
||||
reload(m)
|
||||
|
||||
m.sync_ui_cookie(profile_yaml, tier="free")
|
||||
|
||||
saved = _yaml.safe_load(profile_yaml.read_text())
|
||||
assert saved["ui_preference"] == "streamlit"
|
||||
assert any("prgn_ui=streamlit" in s for s in injected)
|
||||
|
||||
|
||||
def test_switch_ui_writes_yaml_and_calls_sync(profile_yaml, monkeypatch):
|
||||
"""switch_ui(to='vue') writes user.yaml and calls sync."""
|
||||
import yaml as _yaml
|
||||
synced = []
|
||||
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: synced.append(html))
|
||||
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
|
||||
monkeypatch.setattr("streamlit.rerun", lambda: None)
|
||||
|
||||
from importlib import reload
|
||||
import app.components.ui_switcher as m
|
||||
reload(m)
|
||||
|
||||
m.switch_ui(profile_yaml, to="vue", tier="paid")
|
||||
|
||||
saved = _yaml.safe_load(profile_yaml.read_text())
|
||||
assert saved["ui_preference"] == "vue"
|
||||
assert any("prgn_ui=vue" in s for s in synced)
|
||||
Loading…
Reference in a new issue