Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
A multi-game Purchase is now treated as an *unsplittable* bundle (one price, whole-purchase refund). Independently-refundable multi-item orders (e.g. a Steam cart) are instead recorded as N separate single-game purchases, so per-game pricing and per-game refunds work with the existing single-purchase machinery — no through-model needed. Add-purchase form (single form, single endpoint): - 1 game: unchanged. - 2+ games: a "Separate price per game" toggle appears (default off = one bundle price). On, the bundle Price hides and one price input per game appears; the view creates one single-game Purchase each from price_for_game_<id>. `price` is now optional so combined mode still validates. Split action: - A Split button on multi-game purchase rows opens a confirmation modal that replaces the bundle with one single-game purchase per game (price split evenly, needs_price_update set), then HX-Redirects to the list. New general-purpose `selection-fields` custom element renders one synced form field per selected item of a source SearchSelect (consuming the existing search-select:change contract); it knows nothing about prices, so it is reusable. Behavior in ts/elements/selection-fields.ts. Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split icon, and unit + Playwright e2e coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,7 +44,7 @@ docs/ — Additional documentation
|
|||||||
|
|
||||||
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
||||||
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
||||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`)
|
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own Purchase.
|
||||||
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
||||||
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ from common.components.core import (
|
|||||||
randomid,
|
randomid,
|
||||||
render,
|
render,
|
||||||
)
|
)
|
||||||
from common.components.custom_elements import SessionTimestampButtons, register_element
|
from common.components.custom_elements import (
|
||||||
|
SelectionFields,
|
||||||
|
SessionTimestampButtons,
|
||||||
|
register_element,
|
||||||
|
)
|
||||||
from common.components.date_range_picker import (
|
from common.components.date_range_picker import (
|
||||||
DateRangeCalendar,
|
DateRangeCalendar,
|
||||||
DateRangeField,
|
DateRangeField,
|
||||||
@@ -94,6 +98,7 @@ __all__ = [
|
|||||||
"truncate",
|
"truncate",
|
||||||
"BaseComponent",
|
"BaseComponent",
|
||||||
"register_element",
|
"register_element",
|
||||||
|
"SelectionFields",
|
||||||
"SessionTimestampButtons",
|
"SessionTimestampButtons",
|
||||||
"custom_element_builder",
|
"custom_element_builder",
|
||||||
"Element",
|
"Element",
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ reader so drift fails ``tsc``.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TypedDict, get_type_hints
|
from typing import TypedDict, get_type_hints
|
||||||
|
|
||||||
from common.components.core import Media
|
from common.components.core import Node
|
||||||
from common.components.primitives import custom_element_builder
|
from common.components.primitives import (
|
||||||
|
Div,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Template,
|
||||||
|
custom_element_builder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -125,3 +131,51 @@ _GameStatusSelector = custom_element_builder("game-status-selector")
|
|||||||
_SessionDeviceSelector = custom_element_builder("session-device-selector")
|
_SessionDeviceSelector = custom_element_builder("session-device-selector")
|
||||||
_PlayEventRow = custom_element_builder("play-event-row")
|
_PlayEventRow = custom_element_builder("play-event-row")
|
||||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionFieldsProps(TypedDict):
|
||||||
|
source: str # data-name of the source SearchSelect to mirror
|
||||||
|
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
|
||||||
|
field_type: str # input type, e.g. "number"
|
||||||
|
min_items: int # render nothing until at least this many items are selected
|
||||||
|
active: bool # when false, render nothing (but preserve typed values)
|
||||||
|
|
||||||
|
|
||||||
|
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
|
||||||
|
|
||||||
|
_SelectionFields = custom_element_builder("selection-fields")
|
||||||
|
|
||||||
|
|
||||||
|
def SelectionFields(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
name_prefix: str,
|
||||||
|
field_type: str = "text",
|
||||||
|
min_items: int = 1,
|
||||||
|
active: bool = False,
|
||||||
|
input_attributes: list[tuple[str, str]] | None = None,
|
||||||
|
) -> Node:
|
||||||
|
"""Render one synced form field per selected item of a source SearchSelect.
|
||||||
|
|
||||||
|
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
|
||||||
|
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
|
||||||
|
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
|
||||||
|
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
|
||||||
|
the global ``#add-form`` styling, so the markup stays minimal.
|
||||||
|
"""
|
||||||
|
row_template = Template(attributes=[("data-selection-fields-row", "")])[
|
||||||
|
Div(attributes=[("data-selection-fields-row-item", "")])[
|
||||||
|
Label(attributes=[("data-selection-fields-label", "")]),
|
||||||
|
Input(type=field_type, attributes=list(input_attributes or [])),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
return _SelectionFields(
|
||||||
|
source=source,
|
||||||
|
name_prefix=name_prefix,
|
||||||
|
field_type=field_type,
|
||||||
|
min_items=min_items,
|
||||||
|
active="true" if active else "false",
|
||||||
|
)[
|
||||||
|
Div(attributes=[("data-selection-fields-rows", "")]),
|
||||||
|
row_template,
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Browser tests for the purchase pricing UX and the split action.
|
||||||
|
|
||||||
|
- A synthetic page isolates the general ``selection-fields`` element (no API,
|
||||||
|
deterministic option values), mirroring ``test_search_select_e2e.py``.
|
||||||
|
- The real-app tests drive the actual add-purchase form and the split modal
|
||||||
|
against pytest-django's ``live_server``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import path, reverse
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from common.components import SearchSelect, SelectionFields
|
||||||
|
from games.models import Game, Platform, Purchase
|
||||||
|
|
||||||
|
|
||||||
|
def selection_fields_view(request):
|
||||||
|
html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
|
<script type="module" src="/static/js/search_select.js"></script>
|
||||||
|
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="padding: 50px;">
|
||||||
|
{
|
||||||
|
SearchSelect(
|
||||||
|
name="games",
|
||||||
|
selected=[],
|
||||||
|
options=[
|
||||||
|
{"value": "7", "label": "Game A", "data": {}},
|
||||||
|
{"value": "8", "label": "Game B", "data": {}},
|
||||||
|
],
|
||||||
|
multi_select=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
SelectionFields(
|
||||||
|
source="games",
|
||||||
|
name_prefix="price_for_game_",
|
||||||
|
field_type="number",
|
||||||
|
min_items=2,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("sf-test/", selection_fields_view),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ROOT_URLCONF="e2e.test_purchase_e2e")
|
||||||
|
def test_selection_fields_syncs_with_source(live_server, page: Page):
|
||||||
|
page.goto(live_server.url + "/sf-test/")
|
||||||
|
|
||||||
|
games = page.locator('[data-search-select][data-name="games"]')
|
||||||
|
rows = page.locator("selection-fields [data-selection-fields-rows] input")
|
||||||
|
|
||||||
|
# Below min_items (2): nothing rendered.
|
||||||
|
expect(rows).to_have_count(0)
|
||||||
|
|
||||||
|
games.locator("[data-search-select-search]").click()
|
||||||
|
games.locator('[data-search-select-option][data-value="7"]').click()
|
||||||
|
expect(rows).to_have_count(0) # only one selected, still below min_items
|
||||||
|
|
||||||
|
games.locator("[data-search-select-search]").click()
|
||||||
|
games.locator('[data-search-select-option][data-value="8"]').click()
|
||||||
|
expect(rows).to_have_count(2)
|
||||||
|
|
||||||
|
# One input per item, named by the prefix + item id.
|
||||||
|
expect(
|
||||||
|
page.locator('selection-fields input[name="price_for_game_7"]')
|
||||||
|
).to_have_count(1)
|
||||||
|
expect(
|
||||||
|
page.locator('selection-fields input[name="price_for_game_8"]')
|
||||||
|
).to_have_count(1)
|
||||||
|
|
||||||
|
# Typed values survive removing and re-adding another item.
|
||||||
|
page.locator('selection-fields input[name="price_for_game_7"]').fill("12")
|
||||||
|
games.locator('[data-pill][data-value="8"] [data-pill-remove]').click()
|
||||||
|
expect(rows).to_have_count(0)
|
||||||
|
games.locator("[data-search-select-search]").click()
|
||||||
|
games.locator('[data-search-select-option][data-value="8"]').click()
|
||||||
|
expect(rows).to_have_count(2)
|
||||||
|
expect(
|
||||||
|
page.locator('selection-fields input[name="price_for_game_7"]')
|
||||||
|
).to_have_value("12")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||||
|
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||||
|
page.goto(f"{live_server.url}{reverse('login')}")
|
||||||
|
page.fill('input[name="username"]', "tester")
|
||||||
|
page.fill('input[name="password"]', "secret123")
|
||||||
|
page.click('input[type="submit"]')
|
||||||
|
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _select_two_games(page: Page) -> None:
|
||||||
|
games = page.locator('[data-search-select][data-name="games"]')
|
||||||
|
games.locator("[data-search-select-search]").click()
|
||||||
|
options = games.locator("[data-search-select-option]")
|
||||||
|
expect(options).to_have_count(2) # prefetched on focus
|
||||||
|
options.nth(0).click()
|
||||||
|
options.nth(1).click()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_purchase_per_game_toggle_reveals_inputs(
|
||||||
|
authenticated_page: Page, live_server
|
||||||
|
):
|
||||||
|
"""The combined/per-game toggle appears only at 2+ games; turning it on
|
||||||
|
hides the bundle Price and shows one price input per selected game.
|
||||||
|
(Server-side creation of N purchases is covered by the unit tests.)"""
|
||||||
|
page = authenticated_page
|
||||||
|
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||||
|
Game.objects.create(name="Alpha Game", platform=platform)
|
||||||
|
Game.objects.create(name="Beta Game", platform=platform)
|
||||||
|
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||||
|
|
||||||
|
checkbox_row = page.locator("#separate-prices-row")
|
||||||
|
expect(checkbox_row).to_be_hidden()
|
||||||
|
|
||||||
|
_select_two_games(page)
|
||||||
|
expect(checkbox_row).to_be_visible()
|
||||||
|
|
||||||
|
page.locator("#id_separate_prices").check()
|
||||||
|
expect(page.locator("#id_price")).to_be_hidden()
|
||||||
|
per_game_inputs = page.locator(
|
||||||
|
"selection-fields [data-selection-fields-rows] input"
|
||||||
|
)
|
||||||
|
expect(per_game_inputs).to_have_count(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_purchase_action(authenticated_page: Page, live_server):
|
||||||
|
page = authenticated_page
|
||||||
|
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||||
|
game_a = Game.objects.create(name="Alpha Game", platform=platform)
|
||||||
|
game_b = Game.objects.create(name="Beta Game", platform=platform)
|
||||||
|
bundle = Purchase.objects.create(
|
||||||
|
price=30.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
platform=platform,
|
||||||
|
ownership_type=Purchase.DIGITAL,
|
||||||
|
type=Purchase.GAME,
|
||||||
|
)
|
||||||
|
bundle.games.set([game_a, game_b])
|
||||||
|
|
||||||
|
page.goto(f"{live_server.url}{reverse('games:list_purchases')}")
|
||||||
|
# Before: one bundle row.
|
||||||
|
expect(page.locator('[id^="purchase-row-"]')).to_have_count(1)
|
||||||
|
|
||||||
|
page.locator('[title="Split into per-game purchases"]').click()
|
||||||
|
modal = page.locator("#split-confirmation-modal")
|
||||||
|
expect(modal).to_be_visible()
|
||||||
|
modal.locator('button[type="submit"]', has_text="Split").click()
|
||||||
|
|
||||||
|
page.wait_for_url(f"{live_server.url}{reverse('games:list_purchases')}**")
|
||||||
|
# After: the bundle row is gone, replaced by two per-game rows. Asserted via
|
||||||
|
# the UI (not the ORM) to avoid live_server/SQLite write-read contention.
|
||||||
|
expect(page.locator(f"#purchase-row-{bundle.id}")).to_have_count(0)
|
||||||
|
expect(page.locator('[id^="purchase-row-"]')).to_have_count(2)
|
||||||
@@ -231,6 +231,9 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||||
|
# The bundle Price is optional: in price-per-game mode it is hidden and
|
||||||
|
# the per-game inputs carry the prices instead. Empty falls back to 0.
|
||||||
|
self.fields["price"].required = False
|
||||||
|
|
||||||
games = MultipleGameChoiceField(
|
games = MultipleGameChoiceField(
|
||||||
queryset=Game.objects.order_by("sort_name"),
|
queryset=Game.objects.order_by("sort_name"),
|
||||||
@@ -305,6 +308,11 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
)
|
)
|
||||||
if not name:
|
if not name:
|
||||||
self.add_error("name", f"{type_display} must have a name.")
|
self.add_error("name", f"{type_display} must have a name.")
|
||||||
|
|
||||||
|
# An empty bundle Price (price-per-game mode) saves as 0, not NULL.
|
||||||
|
if cleaned_data.get("price") is None:
|
||||||
|
cleaned_data["price"] = 0
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||||
|
|
||||||
|
// Switch between a single bundle price and one price per game. The per-game
|
||||||
|
// inputs are the selection-fields element; this only sets the policy: the
|
||||||
|
// hidden pricing_mode the view reads, the element's "active" flag, and whether
|
||||||
|
// the bundle Price field is shown.
|
||||||
|
function applyPricingMode(separate) {
|
||||||
|
const pricingMode = getEl("#id_pricing_mode");
|
||||||
|
if (pricingMode) pricingMode.value = separate ? "per_game" : "combined";
|
||||||
|
|
||||||
|
const selectionFields = document.querySelector("selection-fields");
|
||||||
|
if (selectionFields)
|
||||||
|
selectionFields.setAttribute("active", separate ? "true" : "false");
|
||||||
|
|
||||||
|
const priceInput = getEl("#id_price");
|
||||||
|
if (priceInput) {
|
||||||
|
const wrapper = priceInput.closest("div");
|
||||||
|
if (wrapper) wrapper.classList.toggle("hidden", separate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||||
// react to its custom "search-select:change" event instead of syncing a select.
|
// react to its custom "search-select:change" event instead of syncing a select.
|
||||||
document.addEventListener("search-select:change", (event) => {
|
document.addEventListener("search-select:change", (event) => {
|
||||||
@@ -12,6 +31,21 @@ document.addEventListener("search-select:change", (event) => {
|
|||||||
const platformEl = getEl("#id_platform");
|
const platformEl = getEl("#id_platform");
|
||||||
if (platformEl) platformEl.value = platformId;
|
if (platformEl) platformEl.value = platformId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The combined/per-game choice is only meaningful with 2+ games. Reveal the
|
||||||
|
// checkbox there; below the threshold, fall back to a single bundle price.
|
||||||
|
const separateRow = getEl("#separate-prices-row");
|
||||||
|
const multipleGames = event.detail.values.length >= 2;
|
||||||
|
if (separateRow) separateRow.classList.toggle("hidden", !multipleGames);
|
||||||
|
if (!multipleGames) {
|
||||||
|
const checkbox = getEl("#id_separate_prices");
|
||||||
|
if (checkbox) checkbox.checked = false;
|
||||||
|
applyPricingMode(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onSwap("#id_separate_prices", (checkbox) => {
|
||||||
|
checkbox.addEventListener("change", () => applyPricingMode(checkbox.checked));
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupElementHandlers() {
|
function setupElementHandlers() {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="text-black dark:text-white w-4 h-4">
|
||||||
|
<path fill="currentColor" d="M14 4l2.29 2.29-2.88 2.88 1.42 1.42 2.88-2.88L20 12V4z M10 4H4v8l2.29-2.29 4.71 4.71V20h2v-8.41l-5.29-5.3z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 270 B |
@@ -105,6 +105,16 @@ urlpatterns = [
|
|||||||
purchase.refund_purchase,
|
purchase.refund_purchase,
|
||||||
name="refund_purchase",
|
name="refund_purchase",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"purchase/<int:purchase_id>/split/confirm",
|
||||||
|
purchase.split_purchase_confirmation,
|
||||||
|
name="split_purchase_confirmation",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"purchase/<int:purchase_id>/split",
|
||||||
|
purchase.split_purchase,
|
||||||
|
name="split_purchase",
|
||||||
|
),
|
||||||
path("session/add", session.add_session, name="add_session"),
|
path("session/add", session.add_session, name="add_session"),
|
||||||
path(
|
path(
|
||||||
"session/add/for-game/<int:game_id>",
|
"session/add/for-game/<int:game_id>",
|
||||||
|
|||||||
+205
-3
@@ -5,6 +5,7 @@ from django.http import (
|
|||||||
HttpResponse,
|
HttpResponse,
|
||||||
HttpResponseRedirect,
|
HttpResponseRedirect,
|
||||||
)
|
)
|
||||||
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
@@ -17,18 +18,22 @@ from common.components import (
|
|||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Checkbox,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
Element,
|
||||||
Fragment,
|
Fragment,
|
||||||
GameLink,
|
GameLink,
|
||||||
Icon,
|
Icon,
|
||||||
|
Input,
|
||||||
LinkedPurchase,
|
LinkedPurchase,
|
||||||
Modal,
|
Modal,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
Node,
|
Node,
|
||||||
PriceConverted,
|
PriceConverted,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
|
Safe,
|
||||||
|
SelectionFields,
|
||||||
StyledButton,
|
StyledButton,
|
||||||
TableRow,
|
TableRow,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
@@ -42,7 +47,7 @@ from games.models import Game, Purchase
|
|||||||
from games.views.general import use_custom_redirect
|
from games.views.general import use_custom_redirect
|
||||||
|
|
||||||
|
|
||||||
def _render_purchase_buttons(purchase_id, is_refunded):
|
def _render_purchase_buttons(purchase_id, is_refunded, can_split=False):
|
||||||
"""Return button group HTML for a purchase row."""
|
"""Return button group HTML for a purchase row."""
|
||||||
return ButtonGroup(
|
return ButtonGroup(
|
||||||
[
|
[
|
||||||
@@ -58,6 +63,19 @@ def _render_purchase_buttons(purchase_id, is_refunded):
|
|||||||
}
|
}
|
||||||
if not is_refunded
|
if not is_refunded
|
||||||
else {},
|
else {},
|
||||||
|
{
|
||||||
|
"href": "#",
|
||||||
|
"hx_get": reverse(
|
||||||
|
"games:split_purchase_confirmation",
|
||||||
|
args=[purchase_id],
|
||||||
|
),
|
||||||
|
"hx_target": "#global-modal-container",
|
||||||
|
"slot": Icon("split"),
|
||||||
|
"title": "Split into per-game purchases",
|
||||||
|
"color": "gray",
|
||||||
|
}
|
||||||
|
if can_split
|
||||||
|
else {},
|
||||||
{
|
{
|
||||||
"href": reverse("games:edit_purchase", args=[purchase_id]),
|
"href": reverse("games:edit_purchase", args=[purchase_id]),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
@@ -90,7 +108,11 @@ def _render_purchase_row(purchase):
|
|||||||
else "-"
|
else "-"
|
||||||
),
|
),
|
||||||
purchase.created_at.strftime(dateformat),
|
purchase.created_at.strftime(dateformat),
|
||||||
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)),
|
_render_purchase_buttons(
|
||||||
|
purchase.id,
|
||||||
|
bool(purchase.date_refunded),
|
||||||
|
can_split=purchase.num_purchases > 1,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +188,76 @@ def _purchase_additional_row() -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pricing_controls() -> Node:
|
||||||
|
"""Pricing UI for the add-purchase form.
|
||||||
|
|
||||||
|
By default the form's own single Price field is the bundle price. When 2+
|
||||||
|
games are selected and "Separate price per game" is checked, the per-game
|
||||||
|
inputs (the general ``selection-fields`` element) take over and the bundle
|
||||||
|
Price is hidden. Toggle/visibility wiring lives in add_purchase.js; the
|
||||||
|
hidden ``pricing_mode`` tells the view which path to take.
|
||||||
|
"""
|
||||||
|
return Div(attributes=[("id", "pricing-controls")])[
|
||||||
|
Div(attributes=[("id", "separate-prices-row"), ("class", "hidden")])[
|
||||||
|
Checkbox(
|
||||||
|
name="separate_prices",
|
||||||
|
label="Separate price per game",
|
||||||
|
attributes=[("id", "id_separate_prices")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Input(
|
||||||
|
type="hidden",
|
||||||
|
attributes=[
|
||||||
|
("name", "pricing_mode"),
|
||||||
|
("id", "id_pricing_mode"),
|
||||||
|
("value", "combined"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SelectionFields(
|
||||||
|
source="games",
|
||||||
|
name_prefix="price_for_game_",
|
||||||
|
field_type="number",
|
||||||
|
min_items=2,
|
||||||
|
active=False,
|
||||||
|
input_attributes=[
|
||||||
|
("step", "0.01"),
|
||||||
|
("min", "0"),
|
||||||
|
("inputmode", "decimal"),
|
||||||
|
("placeholder", "Price"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_separate_purchases(form: PurchaseForm, post) -> None:
|
||||||
|
"""Create one single-game Purchase per selected game from the shared form
|
||||||
|
fields, each priced from its own ``price_for_game_<id>`` input. The
|
||||||
|
``m2m_changed`` signal sets ``num_purchases``/``price_per_game`` once each
|
||||||
|
game is attached."""
|
||||||
|
data = form.cleaned_data
|
||||||
|
shared = {
|
||||||
|
"platform": data.get("platform"),
|
||||||
|
"date_purchased": data["date_purchased"],
|
||||||
|
"date_refunded": data.get("date_refunded"),
|
||||||
|
"infinite": data.get("infinite", False),
|
||||||
|
"price_currency": data["price_currency"],
|
||||||
|
"ownership_type": data["ownership_type"],
|
||||||
|
"type": data["type"],
|
||||||
|
"related_game": data.get("related_game"),
|
||||||
|
"name": data.get("name") or "",
|
||||||
|
}
|
||||||
|
for game in data["games"]:
|
||||||
|
raw_price = post.get(f"price_for_game_{game.id}", "")
|
||||||
|
try:
|
||||||
|
price = float(raw_price) if raw_price not in (None, "") else 0.0
|
||||||
|
except ValueError:
|
||||||
|
price = 0.0
|
||||||
|
purchase = Purchase(price=price, **shared)
|
||||||
|
purchase.save()
|
||||||
|
purchase.games.set([game])
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||||
initial = {"date_purchased": timezone.now()}
|
initial = {"date_purchased": timezone.now()}
|
||||||
@@ -173,6 +265,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = PurchaseForm(request.POST or None, initial=initial)
|
form = PurchaseForm(request.POST or None, initial=initial)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
if request.POST.get("pricing_mode") == "per_game":
|
||||||
|
_create_separate_purchases(form, request.POST)
|
||||||
|
return redirect("games:list_purchases")
|
||||||
purchase = form.save()
|
purchase = form.save()
|
||||||
if "submit_and_redirect" in request.POST:
|
if "submit_and_redirect" in request.POST:
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
@@ -198,7 +293,12 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
|
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
AddForm(
|
||||||
|
form,
|
||||||
|
request=request,
|
||||||
|
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
|
||||||
|
additional_row=_purchase_additional_row(),
|
||||||
|
),
|
||||||
title="Add New Purchase",
|
title="Add New Purchase",
|
||||||
scripts=mark_safe(
|
scripts=mark_safe(
|
||||||
ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
|
ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
|
||||||
@@ -386,6 +486,108 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
return HttpResponse(row_html + modal_close, status=200)
|
return HttpResponse(row_html + modal_close, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_confirmation_modal(purchase: Purchase, request: HttpRequest) -> Node:
|
||||||
|
count = purchase.num_purchases
|
||||||
|
form = Element(
|
||||||
|
"form",
|
||||||
|
attributes=[("hx-post", reverse("games:split_purchase", args=[purchase.id]))],
|
||||||
|
children=[
|
||||||
|
CsrfInput(request),
|
||||||
|
P(
|
||||||
|
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
||||||
|
children=[
|
||||||
|
f"Creates {count} separate purchases, one per game, with the "
|
||||||
|
"price split evenly. Each can then be priced and refunded "
|
||||||
|
"independently."
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
[("class", "items-center mt-5")],
|
||||||
|
[
|
||||||
|
StyledButton(
|
||||||
|
[("class", "w-full")],
|
||||||
|
"Split",
|
||||||
|
color="blue",
|
||||||
|
size="lg",
|
||||||
|
type="submit",
|
||||||
|
),
|
||||||
|
StyledButton(
|
||||||
|
[("class", "mt-0 w-full")],
|
||||||
|
"Cancel",
|
||||||
|
color="gray",
|
||||||
|
size="base",
|
||||||
|
onclick="this.closest('#split-confirmation-modal').remove()",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return Modal(
|
||||||
|
"split-confirmation-modal",
|
||||||
|
children=[
|
||||||
|
Element(
|
||||||
|
"h1",
|
||||||
|
attributes=[
|
||||||
|
(
|
||||||
|
"class",
|
||||||
|
"text-2xl leading-6 font-medium dark:text-white text-center",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
children=["Split purchase"],
|
||||||
|
),
|
||||||
|
P(
|
||||||
|
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||||
|
children=[
|
||||||
|
f"Split “{purchase.standardized_name}” into per-game purchases?"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
form,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def split_purchase_confirmation(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
|
return HttpResponse(_split_confirmation_modal(purchase, request))
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
@transaction.atomic
|
||||||
|
def split_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
|
"""Replace one multi-game (unsplittable-style) purchase with one single-game
|
||||||
|
purchase per game, splitting the price evenly as a starting point. Each new
|
||||||
|
purchase is then independently priceable and refundable."""
|
||||||
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
|
games = list(purchase.games.all())
|
||||||
|
count = len(games)
|
||||||
|
if count > 1:
|
||||||
|
share = purchase.price / count
|
||||||
|
for game in games:
|
||||||
|
new_purchase = Purchase(
|
||||||
|
price=share,
|
||||||
|
price_currency=purchase.price_currency,
|
||||||
|
date_purchased=purchase.date_purchased,
|
||||||
|
date_refunded=purchase.date_refunded,
|
||||||
|
infinite=purchase.infinite,
|
||||||
|
ownership_type=purchase.ownership_type,
|
||||||
|
type=purchase.type,
|
||||||
|
related_game=purchase.related_game,
|
||||||
|
name=purchase.name,
|
||||||
|
platform=purchase.platform,
|
||||||
|
needs_price_update=True,
|
||||||
|
)
|
||||||
|
new_purchase.save()
|
||||||
|
new_purchase.games.set([game])
|
||||||
|
purchase.delete()
|
||||||
|
messages.success(request, f"Split into {count} purchases")
|
||||||
|
|
||||||
|
response = HttpResponse(status=204)
|
||||||
|
response["HX-Redirect"] = reverse("games:list_purchases")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from games.models import Game, Platform, Purchase
|
||||||
|
|
||||||
|
|
||||||
|
class AddPurchasePricingTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||||
|
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
|
||||||
|
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
|
||||||
|
|
||||||
|
def _base_data(self, **overrides):
|
||||||
|
data = {
|
||||||
|
"games": [self.game_a.id, self.game_b.id],
|
||||||
|
"platform": self.platform.id,
|
||||||
|
"date_purchased": "2025-01-01",
|
||||||
|
"price_currency": "USD",
|
||||||
|
"ownership_type": Purchase.DIGITAL,
|
||||||
|
"type": Purchase.GAME,
|
||||||
|
"name": "",
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def test_combined_creates_single_bundle(self):
|
||||||
|
data = self._base_data(pricing_mode="combined", price="30")
|
||||||
|
response = self.client.post(reverse("games:add_purchase"), data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(Purchase.objects.count(), 1)
|
||||||
|
bundle = Purchase.objects.get()
|
||||||
|
self.assertEqual(bundle.num_purchases, 2)
|
||||||
|
self.assertEqual(bundle.price, 30)
|
||||||
|
|
||||||
|
def test_per_game_creates_separate_single_game_purchases(self):
|
||||||
|
data = self._base_data(
|
||||||
|
pricing_mode="per_game",
|
||||||
|
**{
|
||||||
|
f"price_for_game_{self.game_a.id}": "10",
|
||||||
|
f"price_for_game_{self.game_b.id}": "20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.post(reverse("games:add_purchase"), data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(Purchase.objects.count(), 2)
|
||||||
|
|
||||||
|
for purchase in Purchase.objects.all():
|
||||||
|
self.assertEqual(purchase.num_purchases, 1)
|
||||||
|
self.assertEqual(sorted(p.price for p in Purchase.objects.all()), [10.0, 20.0])
|
||||||
|
linked_games = [
|
||||||
|
list(p.games.values_list("id", flat=True)) for p in Purchase.objects.all()
|
||||||
|
]
|
||||||
|
self.assertTrue(all(len(games) == 1 for games in linked_games))
|
||||||
|
self.assertEqual(
|
||||||
|
{games[0] for games in linked_games},
|
||||||
|
{self.game_a.id, self.game_b.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SplitPurchaseTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||||
|
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
|
||||||
|
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
|
||||||
|
|
||||||
|
def _bundle(self, games, price=30.0):
|
||||||
|
bundle = Purchase.objects.create(
|
||||||
|
price=price,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
platform=self.platform,
|
||||||
|
ownership_type=Purchase.DIGITAL,
|
||||||
|
type=Purchase.GAME,
|
||||||
|
)
|
||||||
|
bundle.games.set(games)
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
def test_split_creates_per_game_purchases_and_deletes_original(self):
|
||||||
|
bundle = self._bundle([self.game_a, self.game_b], price=30.0)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("games:split_purchase", args=[bundle.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertEqual(response["HX-Redirect"], reverse("games:list_purchases"))
|
||||||
|
self.assertFalse(Purchase.objects.filter(id=bundle.id).exists())
|
||||||
|
self.assertEqual(Purchase.objects.count(), 2)
|
||||||
|
for purchase in Purchase.objects.all():
|
||||||
|
self.assertEqual(purchase.num_purchases, 1)
|
||||||
|
self.assertEqual(purchase.price, 15.0) # 30 / 2, split evenly
|
||||||
|
self.assertTrue(purchase.needs_price_update)
|
||||||
|
|
||||||
|
def test_split_is_noop_for_single_game_purchase(self):
|
||||||
|
single = self._bundle([self.game_a], price=10.0)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("games:split_purchase", args=[single.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertTrue(Purchase.objects.filter(id=single.id).exists())
|
||||||
|
self.assertEqual(Purchase.objects.count(), 1)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { readSelectionFieldsProps, SelectionFieldsProps } from "../generated/props.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one form field per selected item of a source SearchSelect (matched by
|
||||||
|
* its data-name). Reacts to the SearchSelect's "search-select:change" event and
|
||||||
|
* to its own "active" attribute. Typed values are preserved (keyed by item id)
|
||||||
|
* across selection changes and active toggling.
|
||||||
|
*/
|
||||||
|
class SelectionFieldsElement extends HTMLElement {
|
||||||
|
static get observedAttributes(): string[] {
|
||||||
|
return ["active"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private props!: SelectionFieldsProps;
|
||||||
|
private source: HTMLElement | null = null;
|
||||||
|
private typedValues = new Map<string, string>();
|
||||||
|
|
||||||
|
private readonly onSourceChange = (event: Event): void => {
|
||||||
|
const detail = (event as CustomEvent).detail;
|
||||||
|
if (!detail || detail.name !== this.props.source) return;
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.props = readSelectionFieldsProps(this);
|
||||||
|
this.source = document.querySelector<HTMLElement>(
|
||||||
|
`[data-search-select][data-name="${this.props.source}"]`,
|
||||||
|
);
|
||||||
|
document.addEventListener("search-select:change", this.onSourceChange);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
document.removeEventListener("search-select:change", this.onSourceChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(): void {
|
||||||
|
// connectedCallback assigns props; ignore the initial pre-connect call.
|
||||||
|
if (this.props) this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectedItems(): { value: string; label: string }[] {
|
||||||
|
if (!this.source) return [];
|
||||||
|
const pills = this.source.querySelectorAll(
|
||||||
|
"[data-search-select-pills] [data-pill]",
|
||||||
|
);
|
||||||
|
const items: { value: string; label: string }[] = [];
|
||||||
|
pills.forEach((pill) => {
|
||||||
|
const value = pill.getAttribute("data-value");
|
||||||
|
if (!value) return;
|
||||||
|
const labelElement = pill.querySelector("[data-search-select-label]");
|
||||||
|
const label = labelElement?.textContent?.trim() || value;
|
||||||
|
items.push({ value, label });
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureTypedValues(): void {
|
||||||
|
this.querySelectorAll<HTMLInputElement>(
|
||||||
|
"[data-selection-fields-rows] input",
|
||||||
|
).forEach((input) => {
|
||||||
|
const itemId = input.getAttribute("data-item-id");
|
||||||
|
if (itemId) this.typedValues.set(itemId, input.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const rows = this.querySelector<HTMLElement>("[data-selection-fields-rows]");
|
||||||
|
const template = this.querySelector<HTMLTemplateElement>(
|
||||||
|
"template[data-selection-fields-row]",
|
||||||
|
);
|
||||||
|
if (!rows || !template) return;
|
||||||
|
|
||||||
|
this.captureTypedValues();
|
||||||
|
rows.replaceChildren();
|
||||||
|
|
||||||
|
const active = this.getAttribute("active") === "true";
|
||||||
|
const items = this.selectedItems();
|
||||||
|
if (!active || items.length < this.props.minItems) return;
|
||||||
|
|
||||||
|
const prototype = template.content.firstElementChild;
|
||||||
|
if (!prototype) return;
|
||||||
|
|
||||||
|
items.forEach(({ value, label }) => {
|
||||||
|
const row = prototype.cloneNode(true) as HTMLElement;
|
||||||
|
const labelElement = row.querySelector("[data-selection-fields-label]");
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input");
|
||||||
|
if (labelElement) labelElement.textContent = label;
|
||||||
|
if (input) {
|
||||||
|
input.name = `${this.props.namePrefix}${value}`;
|
||||||
|
input.setAttribute("data-item-id", value);
|
||||||
|
const preserved = this.typedValues.get(value);
|
||||||
|
if (preserved !== undefined) input.value = preserved;
|
||||||
|
}
|
||||||
|
rows.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("selection-fields", SelectionFieldsElement);
|
||||||
Reference in New Issue
Block a user