style(overlays): redesign workshop items section
Drops the input-mode radio; the single textarea now accepts any mix of
items and collection URLs (backend autodetect landed in 5c56f18).
Refresh button moves below the items table into a .table-actions row
that also shows an item count + total size summary. Adds .workshop-input
mono font rule and a _humanize_bytes helper alongside the overlay_detail
view.
Plan deviation: PageAssertions has no not_to_contain_text method, so
the e2e test scopes those checks to a body locator instead. Caught in
review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34b65fcbbe
commit
fa394c1f7a
5 changed files with 154 additions and 11 deletions
|
|
@ -480,6 +480,17 @@ def _build_overlay_build_status_context(db, overlay) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _humanize_bytes(n: int) -> str:
|
||||
if n < 1024:
|
||||
return f"{n} B"
|
||||
size = float(n)
|
||||
for unit in ("KB", "MB", "GB"):
|
||||
size /= 1024
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{size:.1f} {unit}"
|
||||
return f"{size:.1f} GB"
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>")
|
||||
@require_login
|
||||
def overlay_detail(overlay_id: int):
|
||||
|
|
@ -515,11 +526,16 @@ def overlay_detail(overlay_id: int):
|
|||
|
||||
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
|
||||
|
||||
total_bytes = sum((wi.file_size or 0) for wi in workshop_items)
|
||||
workshop_items_total_size = _humanize_bytes(total_bytes) if total_bytes else ""
|
||||
|
||||
return render_template(
|
||||
"overlay_detail.html",
|
||||
overlay=overlay,
|
||||
using_blueprints=using_blueprints,
|
||||
workshop_items=workshop_items,
|
||||
workshop_items_count=len(workshop_items),
|
||||
workshop_items_total_size=workshop_items_total_size,
|
||||
file_tree_root_entries=file_tree_root_entries,
|
||||
file_tree_truncated=file_tree_truncated_count > 0
|
||||
if file_tree_root_entries is not None
|
||||
|
|
|
|||
|
|
@ -1333,3 +1333,8 @@ div.modal.modal-wide {
|
|||
align-items: center;
|
||||
margin-top: var(--space-m);
|
||||
}
|
||||
|
||||
.workshop-input {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,17 +45,15 @@
|
|||
{% if can_edit and not latest_build_is_running %}
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<fieldset class="workshop-input-mode">
|
||||
<legend>Input mode</legend>
|
||||
<label><input type="radio" name="input_mode" value="items" checked> Items (paste IDs or URLs; one or many)</label>
|
||||
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
|
||||
</fieldset>
|
||||
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789 https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="stack workshop-refresh-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit">Refresh from Steam</button>
|
||||
<div class="field">
|
||||
<label class="field-label" for="workshop-input">Add items</label>
|
||||
<p class="field-hint">Paste Steam Workshop IDs, item URLs, or collection URLs — one per line. Collections expand automatically.</p>
|
||||
<textarea id="workshop-input" name="input" rows="3" class="workshop-input"
|
||||
placeholder="3726529483 https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665"></textarea>
|
||||
</div>
|
||||
<div class="form-actions-inline" style="justify-content: flex-end">
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -63,6 +61,16 @@
|
|||
{% include "_overlay_item_table.html" with context %}
|
||||
</div>
|
||||
|
||||
{% if can_edit and not latest_build_is_running %}
|
||||
<div class="table-actions">
|
||||
<span class="field-hint">{% if workshop_items_count %}{{ workshop_items_count }} item{{ "s" if workshop_items_count != 1 else "" }}{% if workshop_items_total_size %} · {{ workshop_items_total_size }} total{% endif %}{% else %}0 items{% endif %}</span>
|
||||
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<button type="submit" {% if not workshop_items_count %}disabled{% endif %}>↻ Refresh from Steam</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "_overlay_build_status.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,45 @@ def files_overlay_server(tmp_path, monkeypatch):
|
|||
shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def workshop_overlay_server(tmp_path, monkeypatch):
|
||||
"""live_server + a workshop-type Overlay owned by alice. The overlay
|
||||
starts with zero items — tests that need items should seed them via
|
||||
direct DB writes or via UI actions inside the test."""
|
||||
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
|
||||
app = _boot_app(tmp_path, monkeypatch)
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(
|
||||
username="alice",
|
||||
password_digest=hash_password("secret"),
|
||||
admin=False,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
overlay = Overlay(
|
||||
name="my-maps",
|
||||
path="_pending",
|
||||
type="workshop",
|
||||
user_id=user.id,
|
||||
)
|
||||
session.add(overlay)
|
||||
session.flush()
|
||||
overlay.path = str(overlay.id)
|
||||
user_id = user.id
|
||||
overlay_id = overlay.id
|
||||
|
||||
base_url, shutdown = _serve(app)
|
||||
try:
|
||||
yield {
|
||||
"base_url": base_url,
|
||||
"user_id": user_id,
|
||||
"overlay_id": overlay_id,
|
||||
}
|
||||
finally:
|
||||
shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def server_with_files(tmp_path, monkeypatch):
|
||||
"""live_server + a Server owned by alice with a populated runtime
|
||||
|
|
|
|||
75
l4d2web/tests/e2e/test_workshop_section.py
Normal file
75
l4d2web/tests/e2e/test_workshop_section.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""E2E test for the redesigned workshop items section."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from l4d2web.services import steam_workshop
|
||||
|
||||
from .conftest import login
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def _meta(steam_id: str) -> steam_workshop.WorkshopMetadata:
|
||||
return steam_workshop.WorkshopMetadata(
|
||||
steam_id=steam_id,
|
||||
title=f"Item {steam_id}",
|
||||
filename=f"{steam_id}.vpk",
|
||||
file_url=f"https://example.com/{steam_id}.vpk",
|
||||
file_size=1024,
|
||||
time_updated=1700000000,
|
||||
preview_url="",
|
||||
consumer_app_id=550,
|
||||
result=1,
|
||||
)
|
||||
|
||||
|
||||
def test_workshop_section_renders_without_input_mode_radio(page: Page, workshop_overlay_server) -> None:
|
||||
base_url = workshop_overlay_server["base_url"]
|
||||
overlay_id = workshop_overlay_server["overlay_id"]
|
||||
login(page, base_url)
|
||||
page.goto(f"{base_url}/overlays/{overlay_id}")
|
||||
|
||||
# Legacy input-mode fieldset is gone.
|
||||
expect(page.locator("fieldset.workshop-input-mode")).to_have_count(0)
|
||||
body = page.locator("body")
|
||||
expect(body).not_to_contain_text("Input mode")
|
||||
expect(body).not_to_contain_text("Items (paste IDs or URLs; one or many)")
|
||||
expect(body).not_to_contain_text("Collection (one ID or URL)")
|
||||
|
||||
# Single textarea + Add button.
|
||||
expect(page.locator('textarea[name="input"]')).to_be_visible()
|
||||
expect(page.locator('button:has-text("Add")')).to_be_visible()
|
||||
|
||||
# Refresh button is below the items table.
|
||||
table = page.locator("#overlay-item-table")
|
||||
refresh = page.locator('button:has-text("Refresh from Steam")')
|
||||
expect(table).to_be_visible()
|
||||
expect(refresh).to_be_visible()
|
||||
table_y = table.bounding_box()["y"]
|
||||
refresh_y = refresh.bounding_box()["y"]
|
||||
assert table_y < refresh_y, "Refresh button should sit below the items table"
|
||||
|
||||
|
||||
def test_workshop_autodetect_expands_collection_url(page: Page, workshop_overlay_server) -> None:
|
||||
"""Pasting a collection URL into the textarea (no mode toggle) expands to
|
||||
children server-side. Verified by patching the Steam helpers."""
|
||||
base_url = workshop_overlay_server["base_url"]
|
||||
overlay_id = workshop_overlay_server["overlay_id"]
|
||||
login(page, base_url)
|
||||
|
||||
with patch.object(
|
||||
steam_workshop, "expand_collections", return_value=["1001", "1002"]
|
||||
), patch.object(
|
||||
steam_workshop, "fetch_metadata_batch", return_value=[_meta("1001"), _meta("1002")]
|
||||
):
|
||||
page.goto(f"{base_url}/overlays/{overlay_id}")
|
||||
page.locator('textarea[name="input"]').fill(
|
||||
"https://steamcommunity.com/sharedfiles/filedetails/?id=555"
|
||||
)
|
||||
page.locator('button:has-text("Add")').click()
|
||||
# Handler redirects to /jobs/<n>.
|
||||
page.wait_for_url("**/jobs/*", timeout=5000)
|
||||
Loading…
Reference in a new issue