From 771a3b0cee2cac6072a054041c931ef345b6ce53 Mon Sep 17 00:00:00 2001 From: Taylor Kaiman Date: Sun, 17 Aug 2025 12:03:34 -0600 Subject: [PATCH 1/5] Character UI: beta detail view, partials, and styles - Add character_detail_beta.html with updated layout - Extract _character_summary.html and _feature_form.html partials - Wire up routes and views for beta detail - Improve markdown rendering in game templates - Update base styles and layout scaffolding - Add templates/snippets/messages.html and TODO.md --- TODO.md | 59 +++ .../character/_character_summary.html | 270 ++++++++++++ .../templates/character/_feature_form.html | 416 ++++++++++++++++++ .../templates/character/character_base.html | 11 +- .../character/character_detail_beta.html | 216 +++++++++ .../templates/character/feature_form.html | 2 +- camp/character/urls.py | 6 + camp/character/views.py | 163 ++++++- camp/game/templatetags/markdown.py | 22 + static/css/base.css | 5 + templates/base.html | 26 +- templates/snippets/messages.html | 10 + 12 files changed, 1190 insertions(+), 16 deletions(-) create mode 100644 TODO.md create mode 100644 camp/character/templates/character/_character_summary.html create mode 100644 camp/character/templates/character/_feature_form.html create mode 100644 camp/character/templates/character/character_detail_beta.html create mode 100644 templates/snippets/messages.html diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..060e4b9c --- /dev/null +++ b/TODO.md @@ -0,0 +1,59 @@ +# Character screen HTMX responsiveness + +Status of incremental HTMX work to make add/remove actions responsive on the character screen. + +## Done +- Added partial templates for HTMX rendering: + - `camp/character/templates/character/_character_summary.html` (issues + feature groups; links open feature modal via HTMX) + - `camp/character/templates/character/_feature_form.html` (feature form/choices; posts via HTMX, re-renders the modal content) +- Updated character detail page to use HTMX and a modal target: + - `camp/character/templates/character/character_detail.html` + - Wraps summary in a `#character-summary` container that loads via `hx-get` from `character-summary` and refreshes on `refresh-character` events (listens from body) + - Adds a Bootstrap modal container `#featureModal` with `#featureModalContent` as HTMX target +- Backend endpoints updated/added: + - `camp/character/views.py` + - `feature_view`: HTMX-aware GET/POST. Returns `_feature_form.html` and sets `HX-Trigger: refresh-character` (targeted at body) after successful mutations; also injects a hidden `hx-get` to force-refresh `#character-summary` upon success + - `character_summary_view`: returns `_character_summary.html` with issues + feature groups + - Manage actions now HTMX-aware with correct responses: + - `set_attr`, `set_name`, `apply_view`, `undo_view` return 204 + `HX-Trigger: refresh-character` (target body) + - `delete_character`, `copy_view` set `HX-Redirect` to navigate appropriately + - `camp/character/urls.py`: added `path("/summary/", views.character_summary_view, name="character-summary")` +- Client helpers: + - Base template (`templates/base.html`) already sets global `hx-headers` with CSRF and re-initializes tooltips on HTMX loads + - `_feature_form.html` includes small script to enable/disable freeform option field based on "Other" radio selection + - Added out-of-band messages partial `templates/snippets/messages.html` and include it in HTMX partials so alerts update without full reload + - Added script to close any open Bootstrap modal when `refresh-character` fires +- Test suite passes (`poetry run pytest`): 39 passed + +## TODO (to reach feature completeness) +- Manage actions via HTMX + - [x] Set Attributes (Level/CP): convert modal form to `hx-post` and have server respond with `HX-Trigger: refresh-character` + - [x] Change Name: convert to `hx-post` + trigger + - [x] Delete/Discard: convert to `hx-post` + redirect fallback; trigger a summary refresh or navigate away when appropriate + - [x] Undo: convert to `hx-post`; on success, trigger summary refresh (and possibly close modal) + - [x] Copy Character: convert to `hx-post`; on success, navigate to the new character (HTMX: `HX-Redirect`) + - [x] Full Character Respend: update `apply_view` HTMX branch to send `HX-Trigger: refresh-character` (avoid full page ClientRefresh) + - [ ] QA: verify summary refresh across all feature types (classes, breeds, subfeatures, choices) and remove hidden refresher if redundant + +- Modal UX + - [ ] Optionally load large available lists lazily (category sections `hx-get` on expand) to reduce initial DOM size + +- Messages and feedback + - [x] Add a messages fragment target on the page and, for HTMX responses, also update it so feedback is visible without a full page load + +- Template consistency / cleanup + - [ ] DRY up `character/feature_form.html` by including `_feature_form.html` for full-page fallback instead of duplicating markup + - [ ] Ensure all feature links in summary and nested lists include HTMX attributes (sanity pass) + +- Testing + - [ ] Add view tests for `character_summary_view` + - [ ] Add HTMX behavior tests for `feature_view` POST: returns partial, sends `HX-Trigger`, retains fallback behavior on non-HTMX requests + +- Optional enhancements + - [ ] Replace "Add New " in-summary Bootstrap modals with HTMX-driven on-demand loading into the feature modal to simplify DOM + - [ ] Persist/restore accordion collapse state across HTMX swaps if desirable + +## Notes +- CSRF is handled via global `hx-headers` in `templates/base.html` +- Tooltips are re-initialized on every HTMX load via `htmx.onLoad` in `templates/base.html` +- Progressive enhancement preserved: `href` and standard form posts remain for non-JS users diff --git a/camp/character/templates/character/_character_summary.html b/camp/character/templates/character/_character_summary.html new file mode 100644 index 00000000..8cc9351d --- /dev/null +++ b/camp/character/templates/character/_character_summary.html @@ -0,0 +1,270 @@ +{% load character_sheet %} +{% load markdown %} + +
+ +{% comment %} OOB messages so alerts update during HTMX swaps {% endcomment %} +{% if request.htmx %}{% include "snippets/messages.html" %}{% endif %} + +{% if issues|length == 1 %} + + +{% elif issues %} + +
+
+

+ +

+
+
+
    + {% for issue in issues %} + {% if issue.feature_id %} +
  • + + {{issue.reason | markdown}} + +
  • + {% else %} +
  • {{issue.reason | markdown}}
  • + {% endif %} + {% endfor %} +
+
+
+
+
+ + +{% endif %} + + +
+{% for group in feature_groups %} +
+
+

+ {{ group.name }} + {% if group.has_available %} + + {% endif %} +

+ {% if group.explain_list %} +
    + {% for text in group.explain_list %} +
  • {{ text }}
  • + {% endfor %} +
+ {% endif %} +
+ {% for f in group.taken %} + + {% endfor %} +
+
+
+{% endfor %} +
+ + + +{% for group in feature_groups %} +{% if group.has_available %} + +{% endif %} +{% endfor %} + +
diff --git a/camp/character/templates/character/_feature_form.html b/camp/character/templates/character/_feature_form.html new file mode 100644 index 00000000..63994bc8 --- /dev/null +++ b/camp/character/templates/character/_feature_form.html @@ -0,0 +1,416 @@ +{% load crispy_forms_tags %} +{% load character_sheet %} +{% load markdown %} + + + + + diff --git a/camp/character/templates/character/character_base.html b/camp/character/templates/character/character_base.html index 86e44d71..908b943c 100644 --- a/camp/character/templates/character/character_base.html +++ b/camp/character/templates/character/character_base.html @@ -7,7 +7,7 @@ {% endblock %} {% block body_title %} -
+
+

{% if character.campaign %} Campaign: {{character.campaign}} {% else %} @@ -82,6 +82,13 @@

Player: {{character.owner.username}}
  • LP: {% get 'lp' %}
  • Spikes: {% get 'spikes' %}
  • {% block extra_header_bar %}{% endblock %} +
  • + {% if request.resolver_match.url_name == 'character-detail-beta' %} + Switch to classic + {% else %} + Try responsive editor (beta) + {% endif %} +
  • diff --git a/camp/character/templates/character/character_detail_beta.html b/camp/character/templates/character/character_detail_beta.html new file mode 100644 index 00000000..a26cd516 --- /dev/null +++ b/camp/character/templates/character/character_detail_beta.html @@ -0,0 +1,216 @@ +{% extends "character/character_base.html" %} +{% load rules %} +{% load character_sheet %} +{% load markdown %} + +{% block extra_header_bar %} +
  • + +
  • + {% endblock %} + +{% block content %} + +
    + {% include "character/_character_summary.html" %} +
    + + +{% if not character.campaign %} + + +{% endif %} + + + + + + + +{% if undo %} + + + +{% endif %} + + + + + + + + +{% endblock %} diff --git a/camp/character/templates/character/feature_form.html b/camp/character/templates/character/feature_form.html index 4886956b..1aeff8c2 100644 --- a/camp/character/templates/character/feature_form.html +++ b/camp/character/templates/character/feature_form.html @@ -75,7 +75,7 @@

    Details

    {% for explanation in explain_ranks %} -

    {{ explanation | markdown }}

    +

    {{ explanation|feature_markdown:feature_base }}

    {% endfor %}
    {% endif %} diff --git a/camp/character/urls.py b/camp/character/urls.py index 0fea6007..f36661db 100644 --- a/camp/character/urls.py +++ b/camp/character/urls.py @@ -22,6 +22,12 @@ def to_url(self, value): path("", views.CharacterListView.as_view(), name="character-list"), path("new/", views.CreateCharacterView.as_view(), name="character-add"), path("/", views.CharacterView.as_view(), name="character-detail"), + path( + "/beta/", + views.CharacterViewBeta.as_view(), + name="character-detail-beta", + ), + path("/summary/", views.character_summary_view, name="character-summary"), path("/delete/", views.delete_character, name="character-delete"), path("/set/", views.set_attr, name="character-set-attr"), path("/name/", views.set_name, name="character-set-name"), diff --git a/camp/character/views.py b/camp/character/views.py index acd666f5..4ecbe8e0 100644 --- a/camp/character/views.py +++ b/camp/character/views.py @@ -15,6 +15,7 @@ from django.db import IntegrityError from django.db import transaction from django.http import Http404 +from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render @@ -78,6 +79,10 @@ def get_context_data(self, **kwargs) -> dict: return context +class CharacterViewBeta(CharacterView): + template_name = "character/character_detail_beta.html" + + class CreateCharacterView(AutoPermissionRequiredMixin, CreateView): model = Character form_class = forms.NewCharacterForm @@ -127,6 +132,10 @@ def delete_character(request, pk): messages.warning(request, "Character discarded.") else: messages.warning(request, "Character was already discarded.") + if getattr(request, "htmx", False): + response = redirect("home") + response["HX-Redirect"] = response.url + return response return redirect("home") @@ -172,6 +181,11 @@ def set_attr(request, pk): sheet.save() else: messages.error(request, "Error validating character: %s" % d.reason) + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-detail", pk=pk) @@ -194,6 +208,11 @@ def apply_view(request, pk): messages.success(request, result.reason or "Change applied.") else: messages.error(request, result.reason or "Could not apply change.") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return HttpResponseClientRefresh() @@ -212,6 +231,11 @@ def set_name(request, pk): messages.success(request, f"Name changed to {new_name}") else: messages.error(request, "Character name can't be empty.") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect(character) @@ -248,13 +272,62 @@ def feature_view(request, pk, feature_id): pf = None if can_inc or can_dec: - if request.POST and "purchase" in request.POST or "remove" in request.POST: + if request.POST and ("purchase" in request.POST or "remove" in request.POST): success, pf = _try_apply_purchase( sheet, feature_id, feature_controller, controller, request ) - if success: - # If we purchased a feature and it has a choice that can be made, - # stay on the feature page. Otherwise, return to the character page. + if success and getattr(request, "htmx", False): + # Rebuild controller state and return partial with trigger + try: + feature_controller = controller.feature_controller(feature_id) + except Exception: + # If the feature no longer exists (e.g., removed), try parent; otherwise fall back to character page via empty context + if feature_controller.internal and feature_controller.parent: + feature_controller = feature_controller.parent + issues = feature_controller.issues() or [] + if ( + feature_controller.value > 0 + and feature_controller.supports_child_purchases + ): + subfeatures = feature_controller.subfeatures + subfeatures_available = _features( + controller, + feature_controller.subfeatures_available, + hide_internal=False, + ) + else: + subfeatures = [] + subfeatures_available = [] + for sf in subfeatures: + if sf_issues := sf.issues(): + issues.extend(sf_issues) + choices = feature_controller.choices + context = { + "character": character, + "controller": controller, + "feature": feature_controller, + "subfeatures": subfeatures, + "subfeatures_available": subfeatures_available, + "explain_ranks": feature_controller.explain, + "feature_base": reverse("character-detail", args=[character.id]) + + "f/", + "choices": ( + {k: forms.ChoiceForm(c) for (k, c) in choices.items()} + if choices + else {} + ), + "purchase_form": forms.FeatureForm(feature_controller), + "issues": issues, + } + if not can_inc: + context["no_purchase_reason"] = can_inc.reason + context["trigger_refresh"] = True + response = render(request, "character/_feature_form.html", context) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response + elif success: + # Existing non-HTMX behavior stay = False if feature_controller.has_available_choices: messages.info(request, "Choices are available for this feature.") @@ -302,6 +375,50 @@ def feature_view(request, pk, feature_id): messages.success(request, result.reason) else: messages.error(request, result.reason or "Could not apply choice.") + if getattr(request, "htmx", False): + # Re-render partial with updated state and trigger a summary refresh + feature_controller = controller.feature_controller(feature_id) + issues = feature_controller.issues() or [] + if ( + feature_controller.value > 0 + and feature_controller.supports_child_purchases + ): + subfeatures = feature_controller.subfeatures + subfeatures_available = _features( + controller, + feature_controller.subfeatures_available, + hide_internal=False, + ) + else: + subfeatures = [] + subfeatures_available = [] + for sf in subfeatures: + if sf_issues := sf.issues(): + issues.extend(sf_issues) + choices = feature_controller.choices + context = { + "character": character, + "controller": controller, + "feature": feature_controller, + "subfeatures": subfeatures, + "subfeatures_available": subfeatures_available, + "explain_ranks": feature_controller.explain, + "feature_base": reverse("character-detail", args=[character.id]) + "f/", + "choices": ( + {k: forms.ChoiceForm(c) for (k, c) in choices.items()} + if choices + else {} + ), + "purchase_form": pf, + "issues": issues, + } + if not can_inc: + context["no_purchase_reason"] = can_inc.reason + context["trigger_refresh"] = True + response = render(request, "character/_feature_form.html", context) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-feature-view", pk=pk, feature_id=feature_id) if feature_controller.value > 0 and feature_controller.supports_child_purchases: @@ -326,6 +443,7 @@ def feature_view(request, pk, feature_id): "subfeatures": subfeatures, "subfeatures_available": subfeatures_available, "explain_ranks": feature_controller.explain, + "feature_base": reverse("character-detail", args=[character.id]) + "f/", "choices": ( {k: forms.ChoiceForm(c) for (k, c) in choices.items()} if choices else {} ), @@ -334,9 +452,32 @@ def feature_view(request, pk, feature_id): } if not can_inc: context["no_purchase_reason"] = can_inc.reason + # Render full page or partial depending on HTMX/param + if getattr(request, "htmx", False) or request.GET.get("partial"): + return render(request, "character/_feature_form.html", context) return render(request, "character/feature_form.html", context) +@permission_required( + "character.view_character", fn=objectgetter(Character), raise_exception=True +) +def character_summary_view(request, pk): + character = get_object_or_404(Character, id=pk) + sheet: Sheet = cast(Sheet, character.primary_sheet) + controller: CharacterController = sheet.controller + taken_features = controller.list_features(taken=True, available=False) + available_features = controller.list_features(available=True, taken=False) + context = { + "character": character, + "controller": controller, + "feature_groups": _features( + controller, chain(taken_features, available_features) + ), + "issues": controller.issues(), + } + return render(request, "character/_character_summary.html", context) + + def _try_apply_purchase( sheet: Sheet, feature_id: str, @@ -412,8 +553,18 @@ def undo_view(request, pk): else: description = "the last action" messages.success(request, f"Undid {description}") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-detail", pk=pk) messages.error(request, "Invalid undo request.") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-detail", pk=pk) @@ -443,6 +594,10 @@ def copy_view(request, pk): controller.model = new_model new_sheet.controller = controller new_sheet.save() + if getattr(request, "htmx", False): + response = redirect(new_character) + response["HX-Redirect"] = response.url + return response return redirect(new_character) diff --git a/camp/game/templatetags/markdown.py b/camp/game/templatetags/markdown.py index 9f405d2e..ae1aff1e 100644 --- a/camp/game/templatetags/markdown.py +++ b/camp/game/templatetags/markdown.py @@ -32,3 +32,25 @@ def markdown(value, arg=None): return nh3.clean( _MD.convert(value), set_tag_attribute_values=attr_values, attributes=_ATTRIBUTES ) + + +@register.filter(name="feature_markdown") +@mark_safe +@stringfilter +def feature_markdown(value: str, base: str | None = None): + """Render markdown intended for feature explanations. + + If a base is provided, rewrite any hrefs that start with "../" to be + rooted at the provided base. This makes links generated by the rules + engine work in both classic and reactive views. + """ + # Render and sanitize like the standard markdown filter + html = nh3.clean(_MD.convert(value), attributes=_ATTRIBUTES) + if not base: + return html + # Ensure base ends with a slash + if not base.endswith("/"): + base = base + "/" + # Rewrite relative links pointing to parent path + # This is a simple and safe textual replacement post-sanitization + return html.replace('href="../', f'href="{base}') diff --git a/static/css/base.css b/static/css/base.css index c43779d7..2e11dc73 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -21,3 +21,8 @@ .skill-tooltip .tooltip-content strong { color: #3498db; } + +/* Feature modal content: ensure long content scrolls instead of cutting off */ +.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} diff --git a/templates/base.html b/templates/base.html index 2ed1ffb2..bd88b138 100644 --- a/templates/base.html +++ b/templates/base.html @@ -112,17 +112,16 @@ - {% if messages %}
    - {% for message in messages %} - - {% endfor %} - + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %}
    - {% endif %} @@ -158,6 +157,15 @@

    {% block body_title %}{% endblock %}

    const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); }); + // Close any open Bootstrap modal when the server triggers a refresh-character event + document.body.addEventListener('refresh-character', function() { + const openModalEl = document.querySelector('.modal.show'); + if (openModalEl) { + const modalInstance = bootstrap.Modal.getInstance(openModalEl) || new bootstrap.Modal(openModalEl); + modalInstance.hide(); + } + }); + document.body.addEventListener('htmx:afterRequest', function (evt) { // Adapted from https://xvello.net/blog/htmx-error-handling/ const errorTarget = document.getElementById("htmx-alert") diff --git a/templates/snippets/messages.html b/templates/snippets/messages.html new file mode 100644 index 00000000..565f2e2a --- /dev/null +++ b/templates/snippets/messages.html @@ -0,0 +1,10 @@ +
    + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} +
    From 069a9505771e945a61fba7130d931dfb963dde14 Mon Sep 17 00:00:00 2001 From: Taylor Kaiman Date: Sun, 17 Aug 2025 12:23:40 -0600 Subject: [PATCH 2/5] UI beta: fix sticky tooltips and initialize via [data-bs-title]; server-driven summary refresh; use feature_markdown for Details; remove duplicate data-bs-toggle on links; recompute can_increase after HTMX actions; fix closing tag in character_base; tidy HTMX forms --- TODO.md | 59 ------------------- .../character/_character_summary.html | 4 -- .../templates/character/_feature_form.html | 34 ++++++++--- .../templates/character/character_base.html | 2 +- camp/character/views.py | 4 ++ templates/base.html | 26 +++++++- 6 files changed, 56 insertions(+), 73 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 060e4b9c..00000000 --- a/TODO.md +++ /dev/null @@ -1,59 +0,0 @@ -# Character screen HTMX responsiveness - -Status of incremental HTMX work to make add/remove actions responsive on the character screen. - -## Done -- Added partial templates for HTMX rendering: - - `camp/character/templates/character/_character_summary.html` (issues + feature groups; links open feature modal via HTMX) - - `camp/character/templates/character/_feature_form.html` (feature form/choices; posts via HTMX, re-renders the modal content) -- Updated character detail page to use HTMX and a modal target: - - `camp/character/templates/character/character_detail.html` - - Wraps summary in a `#character-summary` container that loads via `hx-get` from `character-summary` and refreshes on `refresh-character` events (listens from body) - - Adds a Bootstrap modal container `#featureModal` with `#featureModalContent` as HTMX target -- Backend endpoints updated/added: - - `camp/character/views.py` - - `feature_view`: HTMX-aware GET/POST. Returns `_feature_form.html` and sets `HX-Trigger: refresh-character` (targeted at body) after successful mutations; also injects a hidden `hx-get` to force-refresh `#character-summary` upon success - - `character_summary_view`: returns `_character_summary.html` with issues + feature groups - - Manage actions now HTMX-aware with correct responses: - - `set_attr`, `set_name`, `apply_view`, `undo_view` return 204 + `HX-Trigger: refresh-character` (target body) - - `delete_character`, `copy_view` set `HX-Redirect` to navigate appropriately - - `camp/character/urls.py`: added `path("/summary/", views.character_summary_view, name="character-summary")` -- Client helpers: - - Base template (`templates/base.html`) already sets global `hx-headers` with CSRF and re-initializes tooltips on HTMX loads - - `_feature_form.html` includes small script to enable/disable freeform option field based on "Other" radio selection - - Added out-of-band messages partial `templates/snippets/messages.html` and include it in HTMX partials so alerts update without full reload - - Added script to close any open Bootstrap modal when `refresh-character` fires -- Test suite passes (`poetry run pytest`): 39 passed - -## TODO (to reach feature completeness) -- Manage actions via HTMX - - [x] Set Attributes (Level/CP): convert modal form to `hx-post` and have server respond with `HX-Trigger: refresh-character` - - [x] Change Name: convert to `hx-post` + trigger - - [x] Delete/Discard: convert to `hx-post` + redirect fallback; trigger a summary refresh or navigate away when appropriate - - [x] Undo: convert to `hx-post`; on success, trigger summary refresh (and possibly close modal) - - [x] Copy Character: convert to `hx-post`; on success, navigate to the new character (HTMX: `HX-Redirect`) - - [x] Full Character Respend: update `apply_view` HTMX branch to send `HX-Trigger: refresh-character` (avoid full page ClientRefresh) - - [ ] QA: verify summary refresh across all feature types (classes, breeds, subfeatures, choices) and remove hidden refresher if redundant - -- Modal UX - - [ ] Optionally load large available lists lazily (category sections `hx-get` on expand) to reduce initial DOM size - -- Messages and feedback - - [x] Add a messages fragment target on the page and, for HTMX responses, also update it so feedback is visible without a full page load - -- Template consistency / cleanup - - [ ] DRY up `character/feature_form.html` by including `_feature_form.html` for full-page fallback instead of duplicating markup - - [ ] Ensure all feature links in summary and nested lists include HTMX attributes (sanity pass) - -- Testing - - [ ] Add view tests for `character_summary_view` - - [ ] Add HTMX behavior tests for `feature_view` POST: returns partial, sends `HX-Trigger`, retains fallback behavior on non-HTMX requests - -- Optional enhancements - - [ ] Replace "Add New " in-summary Bootstrap modals with HTMX-driven on-demand loading into the feature modal to simplify DOM - - [ ] Persist/restore accordion collapse state across HTMX swaps if desirable - -## Notes -- CSRF is handled via global `hx-headers` in `templates/base.html` -- Tooltips are re-initialized on every HTMX load via `htmx.onLoad` in `templates/base.html` -- Progressive enhancement preserved: `href` and standard form posts remain for non-JS users diff --git a/camp/character/templates/character/_character_summary.html b/camp/character/templates/character/_character_summary.html index 8cc9351d..64e57aaf 100644 --- a/camp/character/templates/character/_character_summary.html +++ b/camp/character/templates/character/_character_summary.html @@ -87,7 +87,6 @@

    hx-get="{% url 'character-feature-view' character.id f.full_id %}?partial=1" hx-target="#featureModalContent" hx-swap="innerHTML" data-bs-toggle="modal" data-bs-target="#featureModal" - data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="top" data-bs-custom-class="skill-tooltip" @@ -117,7 +116,6 @@

    hx-get="{% url 'character-feature-view' character.id sf.full_id%}?partial=1" hx-target="#featureModalContent" hx-swap="innerHTML" data-bs-toggle="modal" data-bs-target="#featureModal" - data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="top" data-bs-custom-class="skill-tooltip" @@ -174,7 +172,6 @@

    Ad hx-get="{% url 'character-feature-view' character.id f.full_id%}?partial=1" hx-target="#featureModalContent" hx-swap="innerHTML" data-bs-toggle="modal" data-bs-target="#featureModal" - data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="top" data-bs-custom-class="skill-tooltip" @@ -225,7 +222,6 @@

    hx-get="{% url 'character-feature-view' character.id f.full_id%}?partial=1" hx-target="#featureModalContent" hx-swap="innerHTML" data-bs-toggle="modal" data-bs-target="#featureModal" - data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="top" data-bs-custom-class="skill-tooltip" diff --git a/camp/character/templates/character/_feature_form.html b/camp/character/templates/character/_feature_form.html index 63994bc8..6ffb6a56 100644 --- a/camp/character/templates/character/_feature_form.html +++ b/camp/character/templates/character/_feature_form.html @@ -68,8 +68,7 @@

    {{ feature.display_name }}

    + hx-target="#featureModalContent" hx-swap="innerHTML"> {% csrf_token %} {{ purchase_form | crispy }} {% if purchase_form.show_remove_button %} @@ -97,7 +96,7 @@

    {{ feature.display_name }}

    Details

    {% for explanation in explain_ranks %} -

    {{ explanation|feature_markdown:feature_base }}

    +

    {{ explanation | feature_markdown:feature_base }}

    {% endfor %}
    {% endif %} @@ -178,8 +177,7 @@

    {{ choice.controller.name }}

    Selected Choices

    + hx-target="#featureModalContent" hx-swap="innerHTML"> {% csrf_token %} @@ -222,8 +220,7 @@

    Selected Choices

    {% if choice.available %} + hx-target="#featureModalContent" hx-swap="innerHTML"> {% csrf_token %} {{ choice | crispy }} @@ -413,4 +410,27 @@

    document.querySelectorAll('input[type="radio"][name="option"]:not([value="__other__"])') .forEach(function(r) { r.addEventListener('change', update); }); }); + +// Rewrite relative links in Details markdown to feature links and enable HTMX modal navigation +htmx.onLoad(function() { + const container = document.querySelector('#featureModalContent') || document; + const anchors = container.querySelectorAll('a[href]'); + const characterId = '{{ character.id }}'; + anchors.forEach(function(a) { + const href = a.getAttribute('href'); + if (!href) { return; } + // Skip absolute, protocol, and hash links + if (href.startsWith('/') || href.startsWith('http') || href.startsWith('#')) { return; } + // Convert relative feature id links (e.g., "rogue" or "class.rogue") into absolute feature URLs + const absolute = `/character/${characterId}/f/${href}`; + a.setAttribute('href', absolute); + }); + // Enhance all internal feature links to load inside the modal via HTMX + container.querySelectorAll('a[href^="/character/"]').forEach(function(a) { + const url = a.getAttribute('href'); + a.setAttribute('hx-get', url + '?partial=1'); + a.setAttribute('hx-target', '#featureModalContent'); + a.setAttribute('hx-swap', 'innerHTML'); + }); +}); diff --git a/camp/character/templates/character/character_base.html b/camp/character/templates/character/character_base.html index 908b943c..1260931c 100644 --- a/camp/character/templates/character/character_base.html +++ b/camp/character/templates/character/character_base.html @@ -14,7 +14,7 @@ href="{% url 'character-detail' character.id %}"> {{ character }} -
    +
    {% endblock %} {% block precontent %} diff --git a/camp/character/views.py b/camp/character/views.py index 4ecbe8e0..50118c67 100644 --- a/camp/character/views.py +++ b/camp/character/views.py @@ -319,6 +319,8 @@ def feature_view(request, pk, feature_id): "purchase_form": forms.FeatureForm(feature_controller), "issues": issues, } + # Recheck increment permission on fresh controller + can_inc = feature_controller.can_increase() if not can_inc: context["no_purchase_reason"] = can_inc.reason context["trigger_refresh"] = True @@ -412,6 +414,8 @@ def feature_view(request, pk, feature_id): "purchase_form": pf, "issues": issues, } + # Recheck increment permission on fresh controller + can_inc = feature_controller.can_increase() if not can_inc: context["no_purchase_reason"] = can_inc.reason context["trigger_refresh"] = True diff --git a/templates/base.html b/templates/base.html index bd88b138..000301c9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -152,9 +152,18 @@

    {% block body_title %}{% endblock %}

    diff --git a/camp/character/templates/character/character_detail_beta.html b/camp/character/templates/character/character_detail_beta.html index a26cd516..367eeb6c 100644 --- a/camp/character/templates/character/character_detail_beta.html +++ b/camp/character/templates/character/character_detail_beta.html @@ -41,9 +41,11 @@ {% block content %} +
    +
    {% include "character/_character_summary.html" %}
    @@ -60,7 +62,7 @@

    Set Attributes