Compare commits

..

2 Commits

33 changed files with 613 additions and 304 deletions

View File

@@ -0,0 +1,23 @@
const initGearMenus = () => {
document.querySelectorAll('.gear-btn').forEach(gear => {
const menuId = gear.dataset.menuTarget;
gear.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById(menuId);
if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (e) => {
const menu = document.getElementById(menuId);
if (!menu || menu.style.display === 'none') return;
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
menu.style.display = 'none';
}
});
})
};
document.addEventListener('DOMContentLoaded', initGearMenus);

View File

@@ -2,6 +2,7 @@ from django.db.utils import IntegrityError
from django.test import TestCase from django.test import TestCase
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.lyric.models import User from apps.lyric.models import User
@@ -25,6 +26,7 @@ class AppletModelTest(TestCase):
self.assertEqual(self.applet.grid_cols, 12) self.assertEqual(self.applet.grid_cols, 12)
self.assertEqual(self.applet.grid_rows, 3) self.assertEqual(self.applet.grid_rows, 3)
class UserAppletModelTest(TestCase): class UserAppletModelTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="a@b.cde") self.user = User.objects.create(email="a@b.cde")
@@ -38,3 +40,26 @@ class UserAppletModelTest(TestCase):
UserApplet.objects.create(user=self.user, applet=self.applet, visible=True) UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
UserApplet.objects.create(user=self.user, applet=self.applet, visible=False) UserApplet.objects.create(user=self.user, applet=self.applet, visible=False)
class AppletContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="a@b.cde")
self.dash_applet = Applet.objects.create(slug="username", name="Username", context="dashboard")
self.game_applet = Applet.objects.create(slug="new-game", name="New Game", context="gameboard")
def test_filters_by_context(self):
result = applet_context(self.user, "dashboard")
slugs = [e["applet"].slug for e in result]
self.assertIn("username", slugs)
self.assertNotIn("new-game", slugs)
def test_defaults_to_applet_default_visible(self):
result = applet_context(self.user, "dashboard")
[entry] = [e for e in result if e["applet"].slug == "username"]
self.assertTrue(entry["visible"])
def test_respects_user_applet_visible_false(self):
UserApplet.objects.create(user=self.user, applet=self.dash_applet, visible=False)
result = applet_context(self.user, "dashboard")
[entry] = [e for e in result if e["applet"].slug == "username"]
self.assertFalse(entry["visible"])

11
src/apps/applets/utils.py Normal file
View File

@@ -0,0 +1,11 @@
from apps.applets.models import Applet, UserApplet
def applet_context(user, context):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in applets
if slug in applets
]

View File

@@ -8,23 +8,3 @@ const initialize = (inputSelector) => {
textInput.classList.remove("is-invalid"); textInput.classList.remove("is-invalid");
}; };
}; };
const initGearMenu = () => {
const gear = document.getElementById('id_dash_gear');
if (!gear) return;
gear.addEventListener('click', (e) => {
e.stopPropagation();
const menu = document.getElementById('id_applet_menu');
if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (e) => {
const menu = document.getElementById('id_applet_menu');
if (!menu || menu.style.display === 'none') return;
if (e.target.closest('#id_applet_menu_cancel') || !menu.contains(e.target)) {
menu.style.display = 'none';
}
});
};

View File

@@ -66,6 +66,7 @@ class NewListTest(TestCase):
response = self.post_invalid_input() response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR)) self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
@override_settings(COMPRESS_ENABLED=False)
class ListViewTest(TestCase): class ListViewTest(TestCase):
def test_uses_list_template(self): def test_uses_list_template(self):
mylist = List.objects.create() mylist = List.objects.create()
@@ -358,7 +359,7 @@ class ProfileViewTest(TestCase):
[username_input] = parsed.cssselect("#id_new_username") [username_input] = parsed.cssselect("#id_new_username")
self.assertEqual("discoman", username_input.get("value")) self.assertEqual("discoman", username_input.get("value"))
class ToggleAppletsViewTest(TestCase): class ToggleDashAppletsViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="disco@test.io") self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -402,6 +403,13 @@ class ToggleAppletsViewTest(TestCase):
self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1) self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0) self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)
def test_toggle_applets_does_not_affect_gameboard_applets(self):
game_applet, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.client.post(self.url, {"applets": ["username", "palette"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=game_applet). exists())
class AppletVisibilityContextTest(TestCase): class AppletVisibilityContextTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="disco@test.io") self.user = User.objects.create(email="disco@test.io")

View File

@@ -1,10 +1,11 @@
import lxml.html import lxml.html
from django.test import TestCase from django.test import override_settings, TestCase
from apps.lyric.models import Token, User, Wallet from apps.lyric.models import Token, User, Wallet
@override_settings(COMPRESS_ENABLED=False)
class WalletViewTest(TestCase): class WalletViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="capman@test.io") self.user = User.objects.create(email="capman@test.io")

View File

@@ -9,6 +9,7 @@ from django.shortcuts import redirect, render
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.dashboard.forms import ExistingListItemForm, ItemForm from apps.dashboard.forms import ExistingListItemForm, ItemForm
from apps.dashboard.models import Item, List from apps.dashboard.models import Item, List
from apps.lyric.models import PaymentMethod, Token, User, Wallet from apps.lyric.models import PaymentMethod, Token, User, Wallet
@@ -36,16 +37,6 @@ def _recent_lists(user, limit=3):
.distinct()[:limit] .distinct()[:limit]
) )
def _applet_context(user):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.all()}
return [
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
for slug in APPLET_ORDER
if slug in applets
]
def home_page(request): def home_page(request):
context = { context = {
"form": ItemForm(), "form": ItemForm(),
@@ -53,7 +44,7 @@ def home_page(request):
"page_class": "page-dashboard", "page_class": "page-dashboard",
} }
if request.user.is_authenticated: if request.user.is_authenticated:
context["applets"] = _applet_context(request.user) context["applets"] = applet_context(request.user, "dashboard")
context["recent_lists"] = _recent_lists(request.user) context["recent_lists"] = _recent_lists(request.user)
return render(request, "apps/dashboard/home.html", context) return render(request, "apps/dashboard/home.html", context)
@@ -73,7 +64,7 @@ def new_list(request):
"page_class": "page-dashboard", "page_class": "page-dashboard",
} }
if request.user.is_authenticated: if request.user.is_authenticated:
context["applets"] = _applet_context(request.user) context["applets"] = applet_context(request.user, "dashboard")
context["recent_lists"] = _recent_lists(request.user) context["recent_lists"] = _recent_lists(request.user)
return render(request, "apps/dashboard/home.html", context) return render(request, "apps/dashboard/home.html", context)
@@ -135,7 +126,7 @@ def set_profile(request):
@login_required(login_url="/") @login_required(login_url="/")
def toggle_applets(request): def toggle_applets(request):
checked = request.POST.getlist("applets") checked = request.POST.getlist("applets")
for applet in Applet.objects.all(): for applet in Applet.objects.filter(context="dashboard"):
UserApplet.objects.update_or_create( UserApplet.objects.update_or_create(
user=request.user, user=request.user,
applet=applet, applet=applet,
@@ -143,7 +134,7 @@ def toggle_applets(request):
) )
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
return render(request, "apps/dashboard/_partials/_applets.html", { return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": _applet_context(request.user), "applets": applet_context(request.user, "dashboard"),
"palettes": PALETTES, "palettes": PALETTES,
"form": ItemForm(), "form": ItemForm(),
"recent_lists": _recent_lists(request.user), "recent_lists": _recent_lists(request.user),

View File

@@ -1,14 +1,19 @@
import lxml.html import lxml.html
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import User from apps.lyric.models import User
class GameboardViewTest(TestCase): class GameboardViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="capman@test.io") self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user) self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
response = self.client.get("/gameboard/") response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content) self.parsed = lxml.html.fromstring(response.content)
@@ -29,11 +34,11 @@ class GameboardViewTest(TestCase):
def test_gameboard_shows_new_game_applet(self): def test_gameboard_shows_new_game_applet(self):
[_] = self.parsed.cssselect("#id_applet_new_game") [_] = self.parsed.cssselect("#id_applet_new_game")
def test_gameboard_shows_game_kit_btn(self): def test_gameboard_shows_game_kit(self):
[_] = self.parsed.cssselect("#id_game_kit_btn") [_] = self.parsed.cssselect("#id_game_kit")
def test_gameboard_shows_game_gear(self): def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect("#id_game_gear") [_] = self.parsed.cssselect(".gear-btn")
def test_my_games_has_no_game_items_for_new_user(self): def test_my_games_has_no_game_items_for_new_user(self):
game_items = self.parsed.cssselect("#id_applet_my_games .game-item") game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
@@ -50,3 +55,49 @@ class GameboardViewTest(TestCase):
def test_game_kit_has_dice_set_placeholder(self): def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set") [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
class ToggleGameAppletsViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.new_game, _ = Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
self.my_games, _ = Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
self.url = reverse("toggle_game_applets")
def test_unauthenticated_user_is_redirected(self):
self.client.logout()
response = self.client.post(self.url)
self.assertRedirects(
response, f"/?next={self.url}", fetch_redirect_response=False
)
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
self.client.post(self.url, {"applets": ["new-game"]})
ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
self.assertFalse(ua.visible)
def test_redirects_on_normal_post(self):
response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertRedirects(
response, reverse("gameboard"), fetch_redirect_response=False
)
def test_returns_200_on_htmx_post(self):
response = self.client.post(
self.url,
{"applets": ["new-game", "my-games"]},
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_does_not_affect_dash_applets(self):
dash_applet, _ = Applet.objects.get_or_create(
slug="username", defaults={"name": "Username", "context": "dashboard"}
)
self.client.post(self.url, {"applets": ["new-game", "my-games"]})
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())

View File

@@ -5,5 +5,6 @@ from . import views
urlpatterns = [ urlpatterns = [
path('', views.gameboard, name='gameboard'), path('', views.gameboard, name='gameboard'),
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
] ]

View File

@@ -1,9 +1,18 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import render from django.shortcuts import redirect, render
from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet
from apps.lyric.models import Token from apps.lyric.models import Token
GAMEBOARD_APPLET_ORDER = [
"new-game",
"my-games",
"game-kit",
]
@login_required(login_url="/") @login_required(login_url="/")
def gameboard(request): def gameboard(request):
coin = request.user.tokens.filter(token_type=Token.COIN).first() coin = request.user.tokens.filter(token_type=Token.COIN).first()
@@ -12,5 +21,24 @@ def gameboard(request):
request, "apps/gameboard/gameboard.html", { request, "apps/gameboard/gameboard.html", {
"coin": coin, "coin": coin,
"free_tokens": free_tokens, "free_tokens": free_tokens,
"applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard",
} }
) )
@login_required(login_url="/")
def toggle_game_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="gameboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
if request.headers.get("HX-Request"):
return render(request, "apps/gameboard/_partials/_applets.html", {
"applets": applet_context(request.user, "gameboard"),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
})
return redirect("gameboard")

View File

@@ -54,16 +54,16 @@ class DashboardMaintenanceTest(FunctionalTest):
self.browser.find_element(By.ID, "id_applet_username") self.browser.find_element(By.ID, "id_applet_username")
self.browser.find_element(By.ID, "id_applet_palette") self.browser.find_element(By.ID, "id_applet_palette")
# 3. Click el w. id="id_dash_gear" # 3. Click el w. id="id_dash_gear"
dash_gear = self.browser.find_element(By.ID, "id_dash_gear") dash_gear = self.browser.find_element(By.CSS_SELECTOR, ".gear-btn")
dash_gear.click() dash_gear.click()
# 4. A menu appears; wait_for el w. id="id_applet_menu" # 4. A menu appears; wait_for el w. id="id_dash_applet_menu"
self.wait_for( self.wait_for(
lambda: self.assertTrue( lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
# 5. Find two checkboxes in menu, name="username" & name="palette"; assert both .is_selected() # 5. Find two checkboxes in menu, name="username" & name="palette"; assert both .is_selected()
menu = self.browser.find_element(By.ID, "id_applet_menu") menu = self.browser.find_element(By.ID, "id_dash_applet_menu")
username_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="username"]') username_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="username"]')
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]') palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
self.assertTrue(username_cb.is_selected()) self.assertTrue(username_cb.is_selected())
@@ -76,7 +76,7 @@ class DashboardMaintenanceTest(FunctionalTest):
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click() menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
self.wait_for( self.wait_for(
lambda: self.assertFalse( lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
# 8. wait_for palette applet to be gone # 8. wait_for palette applet to be gone
@@ -93,10 +93,10 @@ class DashboardMaintenanceTest(FunctionalTest):
dash_gear.click() dash_gear.click()
self.wait_for( self.wait_for(
lambda: self.assertTrue( lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
menu = self.browser.find_element(By.ID, "id_applet_menu") menu = self.browser.find_element(By.ID, "id_dash_applet_menu")
palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]') palette_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="palette"]')
self.assertFalse(palette_cb.is_selected()) self.assertFalse(palette_cb.is_selected())
# 11. Click it to re-check box; submit # 11. Click it to re-check box; submit
@@ -106,7 +106,7 @@ class DashboardMaintenanceTest(FunctionalTest):
# 12. wait_for id_applet_palette to reappear # 12. wait_for id_applet_palette to reappear
self.wait_for( self.wait_for(
lambda: self.assertFalse( lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
self.wait_for( self.wait_for(
@@ -125,19 +125,19 @@ class AppletMenuDismissTest(FunctionalTest):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
def _open_menu(self): def _open_menu(self):
self.browser.find_element(By.ID, "id_dash_gear").click() self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for( self.wait_for(
lambda: self.assertTrue( lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
def test_gear_click_toggles_menu_closed(self): def test_gear_click_toggles_menu_closed(self):
self._open_menu() self._open_menu()
self.browser.find_element(By.ID, "id_dash_gear").click() self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for( self.wait_for(
lambda: self.assertFalse( lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
@@ -146,7 +146,7 @@ class AppletMenuDismissTest(FunctionalTest):
self.browser.find_element(By.ID, "id_applet_menu_cancel").click() self.browser.find_element(By.ID, "id_applet_menu_cancel").click()
self.wait_for( self.wait_for(
lambda: self.assertFalse( lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )
@@ -155,6 +155,6 @@ class AppletMenuDismissTest(FunctionalTest):
self.browser.find_element(By.TAG_NAME, "h2").click() self.browser.find_element(By.TAG_NAME, "h2").click()
self.wait_for( self.wait_for(
lambda: self.assertFalse( lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_applet_menu").is_displayed() self.browser.find_element(By.ID, "id_dash_applet_menu").is_displayed()
) )
) )

View File

@@ -1,10 +1,18 @@
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from apps.applets.models import Applet
class GameboardNavigationTest(FunctionalTest): class GameboardNavigationTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
def test_footer_links_to_gameboard(self): def test_footer_links_to_gameboard(self):
# 1. Log in, nav to dashboard # 1. Log in, nav to dashboard
self.create_pre_authenticated_session("capman@test.io") self.create_pre_authenticated_session("capman@test.io")
@@ -41,20 +49,12 @@ class GameboardNavigationTest(FunctionalTest):
# 1. Log in, nav to gameboard # 1. Log in, nav to gameboard
self.create_pre_authenticated_session("capman@test.io") self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/gameboard/") self.browser.get(self.live_server_url + "/gameboard/")
# 2. Assert game kit & gear btns both present (stacked vertically) # 2. Assert game kit applet & gear btn present
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.ID, "id_game_kit_btn") lambda: self.browser.find_element(By.ID, "id_game_kit")
) )
self.browser.find_element(By.ID, "id_game_gear") self.browser.find_element(By.CSS_SELECTOR, ".gear-btn")
# 3. Click game kit btn to open panel # 3. Assert Coin-on-a-String present in kit
self.browser.find_element(By.ID, "id_game_kit_btn").click()
# 4. Wait for game kit panel to become visible
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_kit").is_displayed()
)
)
# 5. Assert Coin-on-a-String present in kit
coin = self.browser.find_element(By.ID, "id_kit_coin_on_a_string") coin = self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
# 6. Hover over it; assert tooltip shows name, entry text & reuse description # 6. Hover over it; assert tooltip shows name, entry text & reuse description
ActionChains(self.browser).move_to_element(coin).perform() ActionChains(self.browser).move_to_element(coin).perform()
@@ -91,3 +91,75 @@ class GameboardNavigationTest(FunctionalTest):
# 9. Assert card deck & dice set placeholder present # 9. Assert card deck & dice set placeholder present
self.browser.find_element(By.ID, "id_kit_card_deck") self.browser.find_element(By.ID, "id_kit_card_deck")
self.browser.find_element(By.ID, "id_kit_dice_set") self.browser.find_element(By.ID, "id_kit_dice_set")
class GameboardAppletMenuTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
self.create_pre_authenticated_session("gamer@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
def test_user_can_toggle_applet_visibility_via_gear_menu(self):
# 1. Assert both applets present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_games")
)
self.browser.find_element(By.ID, "id_applet_new_game")
# 2. Click gear; wait for menu
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
# 3. Find checkboxes; assert both checked
menu = self.browser.find_element(By.ID, "id_game_applet_menu")
my_games_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="my-games"]')
new_game_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="new-game"]')
self.assertTrue(my_games_cb.is_selected())
self.assertTrue(new_game_cb.is_selected())
# 4. Uncheck my-games; plant no-reload marker; submit
my_games_cb.click()
self.assertFalse(my_games_cb.is_selected())
self.browser.execute_script("window.__no_reload_marker = true")
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
# 5. Wait for menu to close; assert my-games gone, new game remains
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertRaises(
NoSuchElementException,
self.browser.find_element,
By.ID, "id_applet_my_games",
)
)
self.browser.find_element(By.ID, "id_applet_new_game")
# 6. Re-check my-games; assert it reappears
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
menu = self.browser.find_element(By.ID, "id_game_applet_menu")
my_games_cb = menu.find_element(By.CSS_SELECTOR, '[name="applets"][value="my-games"]')
self.assertFalse(my_games_cb.is_selected())
my_games_cb.click()
menu.find_element(By.CSS_SELECTOR, '[type="submit"]').click()
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_game_applet_menu").is_displayed()
)
)
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_applet_my_games")
)
)
# 7. Assert no full page reload occurred
self.assertTrue(self.browser.execute_script("return window.__no_reload_marker === true"))

View File

@@ -0,0 +1,115 @@
// ── Gear button ────────────────────────────────────────────
.gear-btn {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
z-index: 1;
font-size: 2rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
}
// ── Applet menu (shared structure) ─────────────────────────
%applet-menu {
position: absolute;
bottom: 3rem;
right: 0.5rem;
z-index: 100;
background-color: rgba(var(--priUser), 0.95);
border: 0.15rem solid rgba(var(--secUser), 0.5);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
;
border-radius: 0.75rem;
padding: 1rem;
.menu-btns {
display: flex;
gap: 0.25rem;
margin-top: 0.75rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 0.9em;
height: 0.9em;
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
position: relative;
flex-shrink: 0;
top: 0.1em;
&:checked::after {
content: '';
position: absolute;
left: 0.2em;
bottom: 0.2em;
width: 0.55em;
height: 1em;
border: 0.12em solid rgba(var(--ninUser), 1);
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}
}
#id_dash_applet_menu { @extend %applet-menu; }
#id_game_applet_menu { @extend %applet-menu; }
// ── Applets grid (shared across all boards) ────────────────
%applets-grid {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 98%,
transparent 100%
);
section {
border: 0.2rem solid rgba(var(--secUser), 0.5);
border-radius: 0.75rem;
padding: 1rem;
overflow: hidden;
min-width: 0;
grid-column: span var(--applet-cols, 12);
grid-row: span var(--applet-rows, 3);
@container (max-width: 550px) {
grid-column: span 12;
}
}
}
#id_applets_container { @extend %applets-grid; }
#id_game_applets_container { @extend %applets-grid; }

View File

@@ -28,110 +28,7 @@ body.page-dashboard {
position: relative; position: relative;
} }
#id_dash_gear {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
z-index: 1;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: rgba(var(--secUser), 1);
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1);
}
#id_applet_menu {
position: absolute;
bottom: 3rem;
right: 0.5rem;
z-index: 100;
background-color: rgba(var(--priUser), 0.95);
border: 0.15rem solid rgba(var(--secUser), 0.5);
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
;
border-radius: 0.75rem;
padding: 1rem;
.menu-btns {
display: flex;
gap: 0.25rem;
margin-top: 0.75rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 0.9em;
height: 0.9em;
border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
position: relative;
flex-shrink: 0;
top: 0.1em;
&:checked::after {
content: '';
position: absolute;
left: 0.2em;
bottom: 0.2em;
width: 0.55em;
height: 1em;
border: 0.12em solid rgba(var(--ninUser), 1);
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}
}
#id_applets_container { #id_applets_container {
container-type: inline-size;
--grid-gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-rows: 3rem;
gap: var(--grid-gap);
padding: 0.75rem;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(
to bottom,
transparent 0%,
black 2%,
black 98%,
transparent 100%
);
section {
border: 0.2rem solid rgba(var(--secUser), 0.5);
border-radius: 0.75rem;
padding: 1rem;
overflow: hidden;
min-width: 0;
grid-column: span var(--applet-cols, 12);
grid-row: span var(--applet-rows, 3);
}
#id_applet_my_lists { #id_applet_my_lists {
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
display: flex; display: flex;
@@ -240,12 +137,6 @@ body.page-dashboard {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
#id_applets_container {
section {
grid-column: span 12;
}
}
} }
@media (min-width: 738px) { @media (min-width: 738px) {

View File

@@ -0,0 +1,73 @@
html:has(body.page-gameboard) {
overflow: hidden;
}
body.page-gameboard {
overflow: hidden;
.container {
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.row {
flex-shrink: 0;
margin-bottom: -1rem;
}
}
.gameboard-page {
flex: 1;
min-width: 425px;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
@media (max-width: 550px) {
.gameboard-page {
min-width: 0;
overflow: hidden;
}
}
@media (min-width: 738px) {
.gameboard-page {
min-width: 666px;
}
}
#id_game_applets_container {
#id_applet_game_kit {
display: flex;
flex-direction: column;
#id_game_kit {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
}
}
@media (max-height: 500px) {
body.page-gameboard {
.container {
.row {
padding: 0.25rem 0;
.col-lg-6 h2 {
margin-bottom: 0.5rem;
}
}
}
}
}

View File

@@ -1,7 +1,9 @@
@import 'rootvars'; @import 'rootvars';
@import 'applets';
@import 'base'; @import 'base';
@import 'button-pad'; @import 'button-pad';
@import 'dashboard'; @import 'dashboard';
@import 'gameboard';
@import 'palette-picker'; @import 'palette-picker';
@import 'wallet-tokens'; @import 'wallet-tokens';

View File

@@ -0,0 +1,7 @@
{% for entry in applets %}
{% if entry.visible %}
{% with "apps/"|add:entry.applet.context|add:"/_partials/_applet-"|add:entry.applet.slug|add:".html" as partial %}
{% include partial %}
{% endwith %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,3 @@
<button class="gear-btn" data-menu-target="{{ menu_id }}">
<i class="fa-solid fa-gear"></i>
</button>

View File

@@ -0,0 +1,17 @@
<section
id="id_applet_my_lists"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<a href="{% url 'my_lists' user.id %}" class="my-lists-main">My lists:</a>
<div class="my-lists-container">
<ul>
{% for list in recent_lists %}
<li>
<a href="{{ list.get_absolute_url }}">{{ list.name }}</a>
</li>
{% empty %}
<li>No lists yet.</li>
{% endfor %}
</ul>
</div>
</section>

View File

@@ -0,0 +1,8 @@
<section
id="id_applet_new_list"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Start a new to-do list</h2>
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
</section>

View File

@@ -0,0 +1,21 @@
<section
id="id_applet_palette"
class="palette"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<div class="palette-scroll">
{% for palette in palettes %}
<div class="palette-item">
<div class="swatch {{ palette.name }}{% if user_palette == palette.name %} active{% endif %}{% if palette.locked %} locked{% endif %}"></div>
{% if not palette.locked %}
<form method="POST" action="{% url "set_palette" %}">
{% csrf_token %}
<button type="submit" name="palette" value="{{ palette.name }}" class="btn btn-confirm">OK</button>
</form>
{% else %}
<span class="btn btn-disabled">&times;</span>
{% endif %}
</div>
{% endfor %}
</div>
</section>

View File

@@ -0,0 +1,23 @@
<section
id="id_applet_username"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<form method="POST" action="{% url "set_profile" %}">
{% csrf_token %}
<div class="username-field">
<span class="username-at">@</span>
<input
id="id_new_username"
name="username"
required
value="{{ user.username|default:'' }}"
autocomplete="off"
placeholder="username"
data-original="{{ user.username|default:'' }}"
oninput="this.closest('form').querySelector('.save-btn').hidden = (this.value === this.dataset.original)"
onblur="this.scrollLeft = 0"
>
</div>
<button type="submit" class="btn btn-confirm save-btn" hidden>OK</button>
</form>
</section>

View File

@@ -0,0 +1,7 @@
<section
id="id_applet_wallet"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<span>Writs: {{ user.wallet.writs }}</span>
<a href="{% url "wallet" %}" class="wallet-manage-link">Manage Wallet</a>
</section>

View File

@@ -1,6 +1,7 @@
{% load lyric_extras %} {% load lyric_extras %}
<div id="id_applets_container"> <div id="id_applets_container">
<div id="id_applet_menu" style="display:none;"> <div id="id_dash_applet_menu" style="display:none;">
<form <form
hx-post="{% url "toggle_applets" %}" hx-post="{% url "toggle_applets" %}"
hx-target="#id_applets_container" hx-target="#id_applets_container"
@@ -20,95 +21,9 @@
{% endfor %} {% endfor %}
<div class="menu-btns"> <div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button> <button type="submit" class="btn btn-confirm">OK</button>
<button type="button" id="id_applet_menu_cancel" class="btn btn-cancel">NVM</button> <button type="button" id="id_applet_menu_cancel" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div> </div>
</form> </form>
</div> </div>
{% include "apps/applets/_partials/_applets.html" %}
{% for entry in applets %}
{% if entry.visible %}
{% if entry.applet.slug == "wallet" %}
<section
id="id_applet_wallet"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<span>Writs: {{ user.wallet.writs }}</span>
<a href="{% url "wallet" %}" class="wallet-manage-link">Manage Wallet</a>
</section>
{% elif entry.applet.slug == "new-list" %}
<section
id="id_applet_new_list"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Start a new to-do list</h2>
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
</section>
{% elif entry.applet.slug == "my-lists" %}
<section
id="id_applet_my_lists"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<a href="{% url 'my_lists' user.id %}" class="my-lists-main">My lists:</a>
<div class="my-lists-container">
<ul>
{% for list in recent_lists %}
<li>
<a href="{{ list.get_absolute_url }}">{{ list.name }}</a>
</li>
{% empty %}
<li>No lists yet.</li>
{% endfor %}
</ul>
</div>
</section>
{% elif entry.applet.slug == "username" %}
<section
id="id_applet_username"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<form method="POST" action="{% url "set_profile" %}">
{% csrf_token %}
<div class="username-field">
<span class="username-at">@</span>
<input
id="id_new_username"
name="username"
required
value="{{ user.username|default:'' }}"
autocomplete="off"
placeholder="username"
data-original="{{ user.username|default:'' }}"
oninput="this.closest('form').querySelector('.save-btn').hidden = (this.value === this.dataset.original)"
onblur="this.scrollLeft = 0"
>
</div>
<button type="submit" class="btn btn-confirm save-btn" hidden>OK</button>
</form>
</section>
{% elif entry.applet.slug == "palette" %}
<section
id="id_applet_palette"
class="palette"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<div class="palette-scroll">
{% for palette in palettes %}
<div class="palette-item">
<div class="swatch {{ palette.name }}{% if user_palette == palette.name %} active{% endif %}{% if palette.locked %} locked{% endif %}"></div>
{% if not palette.locked %}
<form method="POST" action="{% url "set_palette" %}">
{% csrf_token %}
<button type="submit" name="palette" value="{{ palette.name }}" class="btn btn-confirm">OK</button>
</form>
{% else %}
<span class="btn btn-disabled">&times;</span>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
{% endfor %}
</div> </div>

View File

@@ -2,6 +2,5 @@
<script> <script>
window.onload = () => { window.onload = () => {
initialize("#id_text"); initialize("#id_text");
initGearMenu();
}; };
</script> </script>

View File

@@ -11,9 +11,7 @@
{% block content %} {% block content %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div id="id_dash_content"> <div id="id_dash_content">
<button id="id_dash_gear"> {% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %}
<i class="fa-solid fa-gear"></i>
</button>
{% include "apps/dashboard/_partials/_applets.html" %} {% include "apps/dashboard/_partials/_applets.html" %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -0,0 +1,22 @@
<section
id="id_applet_game_kit"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Game Kit</h2>
<div id="id_game_kit">
{% if coin %}
<div id="id_kit_coin_on_a_string" class="token">
<i class="fa-solid fa-clover"></i>
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
<i class="fa-solid fa-coins"></i>
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div>
</section>

View File

@@ -0,0 +1,9 @@
<section
id="id_applet_my_games"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>My Games</h2>
<ul class="game-list">
<small>[feature forthcoming]</small>
</ul>
</section>

View File

@@ -0,0 +1,9 @@
<section
id="id_applet_new_game"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>New Game</h2>
<ul class="game-type">
<small>[feature forthcoming]</small>
</ul>
</section>

View File

@@ -0,0 +1,27 @@
<div id="id_game_applets_container">
<div id="id_game_applet_menu" style="display:none;">
<form
hx-post="{% url "toggle_game_applets" %}"
hx-target="#id_game_applets_container"
hx-swap="outerHTML"
>
{% csrf_token %}
{% for entry in applets %}
<label>
<input
type="checkbox"
name="applets"
value="{{ entry.applet.slug }}"
{% if entry.visible %}checked{% endif %}
>
{{ entry.applet.name }}
</label>
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" id="id_game_applet_menu_cancel" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div>
</form>
</div>
{% include "apps/applets/_partials/_applets.html" %}
</div>

View File

@@ -5,36 +5,7 @@
{% block content %} {% block content %}
<div class="gameboard-page"> <div class="gameboard-page">
<section id="id_applet_my_games"> {% include "apps/applets/_partials/_gear.html" with menu_id="id_game_applet_menu" %}
<h2>My Games</h2> {% include "apps/gameboard/_partials/_applets.html" %}
</section>
<section id="id_applet_new_game">
<h2>New Game</h2>
</section>
<div id="id_game_gear"></div>
<button
id="id_game_kit_btn"
onclick="document.getElementById('id_game_kit').style.display='block'"
>
Game Kit
</button>
<div id="id_game_kit" style="display:none;">
{% if coin %}
<div id="id_kit_coin_on_a_string" class="token">
<i class="fa-solid fa-clover"></i>
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
<i class="fa-solid fa-coins"></i>
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -53,6 +53,7 @@
{% block scripts %} {% block scripts %}
{% endblock scripts %} {% endblock scripts %}
<script src="{% static "vendor/htmx.min.js" %}"></script> <script src="{% static "vendor/htmx.min.js" %}"></script>
<script src="{% static "apps/scripts/applets.js" %}"></script>
<script> <script>
document.body.addEventListener('htmx:configRequest', function(evt) { document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken'); evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');