sky wheel: ubiquitous DEL btn — applet & PICK SKY parity w. Dashsky; PICK SKY clears client-only state (no User-model touch) — TDD
My Sky applet (.../dashboard/_partials/_applet-my-sky.html): adds <button id="id_applet_sky_delete_btn" class="btn btn-danger"> at the wheel center, gated on user.sky_chart_data. Click → window.showGuard("Forget sky?") → on OK, fetch POSTs sky_delete (clears every sky_* field on User), removes the 'sky-form:dashboard:sky' localStorage entry that would otherwise rehydrate the post-reload form via _restoreForm(), then reloads — applet's form-render branch is server-template-gated on chart_data so the page comes back form-only.
PICK SKY in-room overlay (.../gameboard/_partials/_sky_overlay.html): adds <button id="id_sky_delete_btn"> at the wheel center. The wheel here is purely a live preview — sky_save fires only on SAVE SKY click w. action='confirm', so there's no draft Character to delete & we do NOT touch the Character/User model. The DEL handler clears the SVG, resets form fields (including lat/lon/tz/tzHint), nulls _lastChartData, disables the SAVE SKY btn, & purges the LS_KEY entry that would otherwise rehydrate on next overlay open / page refresh. Mirrors the user's spec ("shouldn't be targeting the user model anyway, only the character/seat model" — and there's currently no character/seat draft in the PICK SKY flow).
Both handlers defer the window.showGuard readiness check to click-time rather than gating the listener bind itself: window.showGuard is assigned by a base.html script that lives BELOW the content block, so an `if (window.showGuard)` gate at script-execute time would skip the bind entirely (we hit this writing the applet handler — manifested as portal class never receiving 'active' on click).
SCSS: extends the existing #id_sky_delete_form absolute-center rule onto the two new btn IDs (#id_sky_delete_btn, #id_applet_sky_delete_btn). #id_applet_my_sky picks up position:relative as the absolute anchor for the applet btn.
FTs: MySkyAppletDelTest (applet → DEL → guard → OK → reload, asserts User cleared + LS purged + form re-renders) & PickSkyDelTest (overlay → fill form → wheel paints → DEL → guard → OK, asserts SVG empty + form blank + LS purged). Both red before the wiring, green after; full sky suite (46 tests) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -467,6 +467,69 @@ class MySkyApertureSnapScrollTest(FunctionalTest):
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class MySkyAppletDelTest(FunctionalTest):
|
||||||
|
"""My Sky applet (on /dashboard/) gets a parity DEL btn at the wheel
|
||||||
|
center: clicking opens the global guard portal; OK clears the saved sky
|
||||||
|
on the User model & the relevant localStorage entry, then the applet
|
||||||
|
swaps back to its form rendering on reload."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-sky",
|
||||||
|
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
|
||||||
|
)
|
||||||
|
self.gamer = User.objects.create(email="stargazer@test.io")
|
||||||
|
self.gamer.sky_chart_data = _CHART_FIXTURE
|
||||||
|
self.gamer.sky_birth_place = "Baltimore, MD, US"
|
||||||
|
self.gamer.sky_birth_tz = "America/New_York"
|
||||||
|
self.gamer.sky_birth_lat = 39.2904
|
||||||
|
self.gamer.sky_birth_lon = -76.6122
|
||||||
|
self.gamer.sky_birth_dt = datetime(
|
||||||
|
1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC")
|
||||||
|
)
|
||||||
|
self.gamer.save()
|
||||||
|
self.dash_url = self.live_server_url + "/"
|
||||||
|
|
||||||
|
def test_applet_del_clears_user_sky_and_swaps_to_form(self):
|
||||||
|
self.create_pre_authenticated_session("stargazer@test.io")
|
||||||
|
self.browser.get(self.dash_url)
|
||||||
|
|
||||||
|
# 1. Applet shows wheel + DEL btn
|
||||||
|
del_btn = self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_applet_my_sky .btn-danger"
|
||||||
|
))
|
||||||
|
self.assertTrue(del_btn.is_displayed())
|
||||||
|
|
||||||
|
# Seed localStorage as if user had previously typed in the form. After
|
||||||
|
# DEL, this entry should be cleared so the form doesn't repopulate
|
||||||
|
# from stale state on the post-reload form render.
|
||||||
|
self.browser.execute_script("""
|
||||||
|
localStorage.setItem('sky-form:dashboard:sky',
|
||||||
|
JSON.stringify({date:'1980-01-01', lat:'1', lon:'1', place:'Stale', tz:'UTC'}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
del_btn.click()
|
||||||
|
portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal"))
|
||||||
|
self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class")))
|
||||||
|
portal.find_element(By.CSS_SELECTOR, ".guard-yes").click()
|
||||||
|
|
||||||
|
# 2. Page reloads → applet renders form (no chart_data)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_applet_sky_form_wrap"))
|
||||||
|
|
||||||
|
# 3. localStorage cleared (so form isn't seeded with stale data)
|
||||||
|
ls_value = self.browser.execute_script(
|
||||||
|
"return localStorage.getItem('sky-form:dashboard:sky');"
|
||||||
|
)
|
||||||
|
self.assertIsNone(ls_value)
|
||||||
|
|
||||||
|
# 4. DB-side: every sky field is cleared
|
||||||
|
self.gamer.refresh_from_db()
|
||||||
|
self.assertIsNone(self.gamer.sky_chart_data)
|
||||||
|
self.assertIsNone(self.gamer.sky_birth_dt)
|
||||||
|
self.assertEqual(self.gamer.sky_birth_place, "")
|
||||||
|
|
||||||
|
|
||||||
class MySkyAsyncSaveTest(FunctionalTest):
|
class MySkyAsyncSaveTest(FunctionalTest):
|
||||||
"""A fresh user (no saved sky) clicks SAVE SKY (mocked) — without a page
|
"""A fresh user (no saved sky) clicks SAVE SKY (mocked) — without a page
|
||||||
reload, the page transitions into the saved state: body picks up
|
reload, the page transitions into the saved state: body picks up
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Functional tests for the PICK SKY overlay — natal chart entry."""
|
"""Functional tests for the PICK SKY overlay — natal chart entry."""
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from apps.applets.models import Applet
|
from apps.applets.models import Applet
|
||||||
@@ -9,6 +11,21 @@ from apps.lyric.models import User
|
|||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
_PICK_SKY_CHART_FIXTURE = {
|
||||||
|
"planets": {
|
||||||
|
"Sun": {"sign": "Gemini", "degree": 66.7, "retrograde": False},
|
||||||
|
"Moon": {"sign": "Taurus", "degree": 43.0, "retrograde": False},
|
||||||
|
},
|
||||||
|
"houses": {"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150],
|
||||||
|
"asc": 180.0, "mc": 90.0},
|
||||||
|
"elements": {"Fire": 1, "Water": 0, "Stone": 2, "Air": 4, "Time": 1, "Space": 1},
|
||||||
|
"aspects": [],
|
||||||
|
"distinctions": {str(i): 0 for i in range(1, 13)},
|
||||||
|
"house_system": "O",
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _make_sky_select_room():
|
def _make_sky_select_room():
|
||||||
"""Minimal SKY_SELECT room — just enough for the overlay to render."""
|
"""Minimal SKY_SELECT room — just enough for the overlay to render."""
|
||||||
email = "founder@test.io"
|
email = "founder@test.io"
|
||||||
@@ -125,3 +142,104 @@ class PickSkyLocalStorageTest(FunctionalTest):
|
|||||||
self.assertEqual(values["tz"], "America/New_York")
|
self.assertEqual(values["tz"], "America/New_York")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PickSkyDelTest(FunctionalTest):
|
||||||
|
"""PICK SKY overlay gets a DEL btn at the wheel center: clicking opens the
|
||||||
|
global guard portal; OK clears the wheel SVG, resets the form fields, &
|
||||||
|
purges the localStorage entry that would otherwise rehydrate the form on
|
||||||
|
the next overlay open / page refresh. No server hit (the wheel here is
|
||||||
|
purely a preview — un-saved data lives only in localStorage)."""
|
||||||
|
|
||||||
|
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.room, self.founder, self.founder_email = _make_sky_select_room()
|
||||||
|
self.room_url = self.live_server_url + f"/gameboard/room/{self.room.id}/"
|
||||||
|
|
||||||
|
def _ls_key(self):
|
||||||
|
# Mirrors the JS: 'sky-form:' + SAVE_URL (overlay.dataset.saveUrl is
|
||||||
|
# an absolute URL, so the LS key carries the full http://host:port… too)
|
||||||
|
return self.browser.execute_script(
|
||||||
|
"return 'sky-form:' + document.getElementById('id_sky_overlay').dataset.saveUrl;"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_del_clears_wheel_form_and_localstorage(self):
|
||||||
|
self.create_pre_authenticated_session(self.founder_email)
|
||||||
|
self.browser.get(self.room_url)
|
||||||
|
|
||||||
|
# Open PICK SKY modal
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn"))
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sky_overlay"))
|
||||||
|
|
||||||
|
# Mock /sky/preview so schedulePreview resolves & SkyWheel.draw paints
|
||||||
|
# children into #id_sky_svg without hitting PySwiss.
|
||||||
|
self.browser.execute_script("""
|
||||||
|
const FIXTURE = """ + _json.dumps(_PICK_SKY_CHART_FIXTURE) + """;
|
||||||
|
window._origFetch = window.fetch;
|
||||||
|
window.fetch = function(url, opts) {
|
||||||
|
if (typeof url === 'string' && url.includes('/sky/preview')) {
|
||||||
|
return Promise.resolve({ok:true, json:()=>Promise.resolve(FIXTURE)});
|
||||||
|
}
|
||||||
|
return window._origFetch(url, opts);
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Fill form → triggers schedulePreview → wheel renders
|
||||||
|
self.browser.execute_script("""
|
||||||
|
document.getElementById('id_nf_date').value = '2008-05-27';
|
||||||
|
document.getElementById('id_nf_lat').value = '38.3754';
|
||||||
|
document.getElementById('id_nf_lon').value = '-76.6955';
|
||||||
|
document.getElementById('id_nf_place').value = 'Morganza, MD';
|
||||||
|
document.getElementById('id_nf_tz').value = 'America/New_York';
|
||||||
|
document.getElementById('id_nf_date').dispatchEvent(
|
||||||
|
new Event('input', {bubbles:true})
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Wait for the wheel to render (svg has children)
|
||||||
|
self.wait_for(lambda: self.assertTrue(
|
||||||
|
self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *")
|
||||||
|
))
|
||||||
|
|
||||||
|
# localStorage was populated by _saveForm during typing
|
||||||
|
ls_key = self._ls_key()
|
||||||
|
self.assertIsNotNone(self.browser.execute_script(
|
||||||
|
f"return localStorage.getItem({_json.dumps(ls_key)});"
|
||||||
|
))
|
||||||
|
|
||||||
|
# DEL btn → guard portal → OK
|
||||||
|
del_btn = self.browser.find_element(By.ID, "id_sky_delete_btn")
|
||||||
|
del_btn.click()
|
||||||
|
portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal"))
|
||||||
|
self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class")))
|
||||||
|
portal.find_element(By.CSS_SELECTOR, ".guard-yes").click()
|
||||||
|
|
||||||
|
# After OK: SVG empty, form fields blank, localStorage entry purged
|
||||||
|
self.wait_for(lambda: self.assertFalse(
|
||||||
|
self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *"),
|
||||||
|
"Wheel SVG should be cleared after DEL",
|
||||||
|
))
|
||||||
|
values = self.browser.execute_script("""
|
||||||
|
return {
|
||||||
|
date: document.getElementById('id_nf_date').value,
|
||||||
|
lat: document.getElementById('id_nf_lat').value,
|
||||||
|
lon: document.getElementById('id_nf_lon').value,
|
||||||
|
place: document.getElementById('id_nf_place').value,
|
||||||
|
tz: document.getElementById('id_nf_tz').value,
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
self.assertEqual(values["date"], "")
|
||||||
|
self.assertEqual(values["lat"], "")
|
||||||
|
self.assertEqual(values["lon"], "")
|
||||||
|
self.assertEqual(values["place"], "")
|
||||||
|
self.assertEqual(values["tz"], "")
|
||||||
|
self.assertIsNone(self.browser.execute_script(
|
||||||
|
f"return localStorage.getItem({_json.dumps(ls_key)});"
|
||||||
|
))
|
||||||
|
|||||||
@@ -888,6 +888,8 @@ body[class*="-light"] #id_sky_tooltip_2 {
|
|||||||
#id_applet_my_sky {
|
#id_applet_my_sky {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
// Anchor for #id_applet_sky_delete_btn's absolute centering.
|
||||||
|
position: relative;
|
||||||
|
|
||||||
h2 { flex-shrink: 0; }
|
h2 { flex-shrink: 0; }
|
||||||
|
|
||||||
@@ -954,10 +956,14 @@ body.page-sky {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEL btn pinned at the wheel center — only rendered when chart_data exists.
|
// DEL btn pinned at the wheel center — appears wherever a wheel is shown
|
||||||
// Anchored to .sky-wheel-col (which has position:relative) so the btn sits
|
// (Dashsky form#id_sky_delete_form, PICK SKY overlay #id_sky_delete_btn,
|
||||||
// over the SVG's geometric center regardless of the snap layout's outer sizing.
|
// My Sky applet #id_applet_sky_delete_btn). Anchored to .sky-wheel-col /
|
||||||
#id_sky_delete_form {
|
// the applet section (both position:relative) so the btn sits over the SVG's
|
||||||
|
// geometric center regardless of the surrounding layout.
|
||||||
|
#id_sky_delete_form,
|
||||||
|
#id_sky_delete_btn,
|
||||||
|
#id_applet_sky_delete_btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
@@ -71,6 +71,9 @@
|
|||||||
<svg id="id_my_sky_svg" class="sky-svg"
|
<svg id="id_my_sky_svg" class="sky-svg"
|
||||||
{% if not request.user.sky_chart_data %}style="display:none;"{% endif %}></svg>
|
{% if not request.user.sky_chart_data %}style="display:none;"{% endif %}></svg>
|
||||||
{% if request.user.sky_chart_data %}
|
{% if request.user.sky_chart_data %}
|
||||||
|
<button type="button" id="id_applet_sky_delete_btn"
|
||||||
|
class="btn btn-danger"
|
||||||
|
data-delete-url="{% url 'sky_delete' %}">DEL</button>
|
||||||
{{ request.user.sky_chart_data|json_script:"id_my_sky_data" }}
|
{{ request.user.sky_chart_data|json_script:"id_my_sky_data" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
@@ -98,6 +101,38 @@
|
|||||||
SkyWheel.preload().then(function () { SkyWheel.draw(svgEl, stale); });
|
SkyWheel.preload().then(function () { SkyWheel.draw(svgEl, stale); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DEL btn — guard portal → POST sky_delete + clear LS + reload. Reload is
|
||||||
|
// the simplest swap back to the form rendering (the form-rendering branch
|
||||||
|
// is server-template-gated on user.sky_chart_data). window.showGuard is
|
||||||
|
// assigned by the script in base.html that lives BELOW the content block,
|
||||||
|
// so the readiness check is deferred to click-time rather than gating the
|
||||||
|
// listener bind itself.
|
||||||
|
var delBtn = document.getElementById('id_applet_sky_delete_btn');
|
||||||
|
if (delBtn) {
|
||||||
|
var DELETE_URL = delBtn.dataset.deleteUrl;
|
||||||
|
function _csrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
delBtn.addEventListener('click', function () {
|
||||||
|
if (!window.showGuard) return;
|
||||||
|
window.showGuard(delBtn, 'Forget sky?', function () {
|
||||||
|
fetch(DELETE_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': _csrf() },
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) return;
|
||||||
|
// The form-render branch's _restoreForm() will re-seed
|
||||||
|
// fields from this LS entry on the next render — clear it
|
||||||
|
// so the post-reload form lands genuinely empty.
|
||||||
|
try { localStorage.removeItem('sky-form:dashboard:sky'); } catch (_) {}
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
// No sky saved yet — wire up the entry form.
|
// No sky saved yet — wire up the entry form.
|
||||||
|
|||||||
@@ -89,6 +89,8 @@
|
|||||||
{# ── Wheel column ─────────────────────────────────────── #}
|
{# ── Wheel column ─────────────────────────────────────── #}
|
||||||
<div class="sky-wheel-col">
|
<div class="sky-wheel-col">
|
||||||
<svg id="id_sky_svg" class="sky-svg"></svg>
|
<svg id="id_sky_svg" class="sky-svg"></svg>
|
||||||
|
<button type="button" id="id_sky_delete_btn"
|
||||||
|
class="btn btn-danger">DEL</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>{# /.sky-modal-body #}
|
</div>{# /.sky-modal-body #}
|
||||||
@@ -399,6 +401,34 @@
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DEL btn — clears wheel + form + localStorage (no server hit) ────────
|
||||||
|
// PICK SKY's wheel is a live preview; un-saved data lives only in LS_KEY.
|
||||||
|
// Match the My Sky applet / Dashsky pattern but skip the server delete —
|
||||||
|
// there's no Character draft created during preview (sky_save fires only
|
||||||
|
// on SAVE SKY click w. action='confirm').
|
||||||
|
|
||||||
|
// window.showGuard is assigned in a base.html script that loads BELOW the
|
||||||
|
// content block — defer the readiness check to click-time so the listener
|
||||||
|
// bind happens regardless of inline-script execution order.
|
||||||
|
const delBtn = document.getElementById('id_sky_delete_btn');
|
||||||
|
if (delBtn) {
|
||||||
|
delBtn.addEventListener('click', () => {
|
||||||
|
if (!window.showGuard) return;
|
||||||
|
window.showGuard(delBtn, 'Forget sky?', () => {
|
||||||
|
while (svgEl.firstChild) svgEl.removeChild(svgEl.firstChild);
|
||||||
|
form.reset();
|
||||||
|
latInput.value = '';
|
||||||
|
lonInput.value = '';
|
||||||
|
tzInput.value = '';
|
||||||
|
tzHint.textContent = '';
|
||||||
|
_lastChartData = null;
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
setStatus('');
|
||||||
|
try { localStorage.removeItem(LS_KEY); } catch (_) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── CSRF ──────────────────────────────────────────────────────────────────
|
// ── CSRF ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _getCsrf() {
|
function _getCsrf() {
|
||||||
|
|||||||
Reference in New Issue
Block a user