feat(l4d2-web): blueprint overlay picker — drag-list + add-dropdown

Replace the per-row checkbox + numeric Order table on the blueprint
detail page with a drag-to-reorder list of selected overlays plus a
native <select> for adding more. Removing uses an × button per row;
the option sorted-inserts back into the dropdown alphabetically.

Native HTML5 drag-and-drop, no library, no JS-disabled fallback.
Server contract is unchanged: each list row owns one hidden
<input name="overlay_ids">, DOM order = submission order, and the
existing fallback_position branch in ordered_overlay_ids_from_form
absorbs the now-omitted overlay_position_<id> fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-08 21:37:11 +02:00
parent dec4fed809
commit a4e9f6cd26
No known key found for this signature in database
6 changed files with 312 additions and 15 deletions

View file

@ -297,12 +297,15 @@ def blueprint_page(blueprint_id: int):
).all() ).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows} overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
selected_ids = {overlay.id for overlay in selected_overlays}
available_overlays = [overlay for overlay in all_overlays if overlay.id not in selected_ids]
return render_template( return render_template(
"blueprint_detail.html", "blueprint_detail.html",
blueprint=blueprint, blueprint=blueprint,
selected_overlays=selected_overlays, selected_overlays=selected_overlays,
available_overlays=available_overlays,
all_overlays=all_overlays, all_overlays=all_overlays,
selected_overlay_ids={overlay.id for overlay in selected_overlays}, selected_overlay_ids=selected_ids,
overlay_positions=overlay_positions, overlay_positions=overlay_positions,
arguments=json.loads(blueprint.arguments), arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config), config_lines=json.loads(blueprint.config),

View file

@ -274,3 +274,82 @@ dialog.modal::backdrop {
.file-tree-row-truncated { .file-tree-row-truncated {
font-style: italic; font-style: italic;
} }
.overlay-picker {
display: grid;
gap: var(--space-m);
}
.overlay-picker-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-height: 3rem;
}
.overlay-picker-row {
display: flex;
align-items: center;
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
background: var(--color-surface-muted);
border: var(--line);
border-radius: var(--radius-s);
cursor: grab;
user-select: none;
}
.overlay-picker-row:active {
cursor: grabbing;
}
.overlay-picker-row.is-dragging {
opacity: 0.4;
}
.overlay-picker-row.drop-before {
box-shadow: 0 2px 0 0 var(--color-focus) inset;
}
.overlay-picker-row.drop-after {
box-shadow: 0 -2px 0 0 var(--color-focus) inset;
}
.overlay-picker-handle {
color: var(--color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: -0.1em;
}
.overlay-picker-name {
flex: 1;
}
.overlay-picker-remove {
background: none;
color: var(--color-muted);
padding: 0 var(--space-s);
font-size: 1.1rem;
line-height: 1;
}
.overlay-picker-remove:hover {
color: var(--color-danger);
}
.overlay-picker-empty {
margin: 0;
}
.overlay-picker-add {
display: flex;
align-items: center;
gap: var(--space-s);
}
.overlay-picker-add select {
flex: 1;
}

View file

@ -0,0 +1,159 @@
(() => {
const root = document.querySelector("[data-overlay-list]");
if (!root) return;
const select = document.querySelector("[data-overlay-add]");
const empty = document.querySelector("[data-overlay-empty]");
let dragRow = null;
const clearDropMarkers = () => {
root
.querySelectorAll(".drop-before, .drop-after")
.forEach((el) => el.classList.remove("drop-before", "drop-after"));
};
const refreshEmpty = () => {
if (!empty) return;
empty.hidden = root.children.length > 0;
};
const buildHiddenInput = (overlayId) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = "overlay_ids";
input.value = overlayId;
return input;
};
const buildRow = (overlayId, overlayName) => {
const li = document.createElement("li");
li.className = "overlay-picker-row";
li.draggable = true;
li.dataset.overlayId = overlayId;
li.dataset.overlayName = overlayName;
const handle = document.createElement("span");
handle.className = "overlay-picker-handle";
handle.setAttribute("aria-hidden", "true");
handle.textContent = "⋮⋮";
const name = document.createElement("span");
name.className = "overlay-picker-name";
name.textContent = overlayName;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "overlay-picker-remove";
remove.dataset.action = "remove";
remove.setAttribute("aria-label", `Remove ${overlayName}`);
remove.textContent = "×";
li.append(handle, name, remove, buildHiddenInput(overlayId));
return li;
};
const insertOptionSorted = (overlayId, overlayName) => {
if (!select) return;
const option = document.createElement("option");
option.value = overlayId;
option.dataset.overlayName = overlayName;
option.textContent = overlayName;
const lower = overlayName.toLowerCase();
const existing = Array.from(select.querySelectorAll("option[value]:not([value=''])"));
const next = existing.find(
(opt) => (opt.dataset.overlayName || opt.textContent).toLowerCase() > lower,
);
if (next) {
select.insertBefore(option, next);
} else {
select.appendChild(option);
}
};
root.addEventListener("dragstart", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (!row) return;
dragRow = row;
row.classList.add("is-dragging");
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", row.dataset.overlayId || "");
}
});
root.addEventListener("dragend", () => {
if (dragRow) {
dragRow.classList.remove("is-dragging");
}
clearDropMarkers();
dragRow = null;
});
root.addEventListener("dragover", (event) => {
if (!dragRow) return;
const row = event.target.closest(".overlay-picker-row");
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
clearDropMarkers();
if (row && row !== dragRow) {
const rect = row.getBoundingClientRect();
const before = event.clientY < rect.top + rect.height / 2;
row.classList.add(before ? "drop-before" : "drop-after");
}
});
root.addEventListener("dragleave", (event) => {
const row = event.target.closest(".overlay-picker-row");
if (row) {
row.classList.remove("drop-before", "drop-after");
}
});
root.addEventListener("drop", (event) => {
if (!dragRow) return;
event.preventDefault();
const row = event.target.closest(".overlay-picker-row");
if (row && row !== dragRow) {
const before = row.classList.contains("drop-before");
row.classList.remove("drop-before", "drop-after");
if (before) {
root.insertBefore(dragRow, row);
} else {
root.insertBefore(dragRow, row.nextSibling);
}
} else if (!row) {
root.appendChild(dragRow);
}
clearDropMarkers();
});
root.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-action='remove']");
if (!trigger) return;
const row = trigger.closest(".overlay-picker-row");
if (!row) return;
const overlayId = row.dataset.overlayId;
const overlayName = row.dataset.overlayName || "";
row.remove();
if (overlayId) insertOptionSorted(overlayId, overlayName);
refreshEmpty();
});
if (select) {
select.addEventListener("change", () => {
const value = select.value;
if (!value) return;
const option = select.querySelector(`option[value="${CSS.escape(value)}"]`);
const overlayName = option ? option.dataset.overlayName || option.textContent : "";
root.appendChild(buildRow(value, overlayName));
if (option) option.remove();
select.value = "";
refreshEmpty();
});
}
refreshEmpty();
})();

View file

@ -12,20 +12,28 @@
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}"> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label> <label>Name <input name="name" value="{{ blueprint.name }}" required></label>
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p> <p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
<table class="table"> <div class="overlay-picker">
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead> <ol class="overlay-picker-list" data-overlay-list>
<tbody> {% for overlay in selected_overlays %}
{% for overlay in all_overlays %} <li class="overlay-picker-row" draggable="true" data-overlay-id="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">
<tr> <span class="overlay-picker-handle" aria-hidden="true">⋮⋮</span>
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td> <span class="overlay-picker-name">{{ overlay.name }}</span>
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td> <button type="button" class="overlay-picker-remove" data-action="remove" aria-label="Remove {{ overlay.name }}">×</button>
<td>{{ overlay.name }}</td> <input type="hidden" name="overlay_ids" value="{{ overlay.id }}">
</tr> </li>
{% else %}
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
{% endfor %} {% endfor %}
</tbody> </ol>
</table> <p class="overlay-picker-empty muted" data-overlay-empty {% if selected_overlays %}hidden{% endif %}>No overlays selected. Pick one below to add.</p>
<label class="overlay-picker-add">
<span>Add overlay</span>
<select data-overlay-add>
<option value="">Pick a name…</option>
{% for overlay in available_overlays %}
<option value="{{ overlay.id }}" data-overlay-name="{{ overlay.name }}">{{ overlay.name }}</option>
{% endfor %}
</select>
</label>
</div>
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label> <label>Arguments <textarea name="arguments" rows="8" spellcheck="false">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label> <label>Config <textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea></label>
<button type="submit">Save blueprint</button> <button type="submit">Save blueprint</button>
@ -48,4 +56,5 @@
</form> </form>
</div> </div>
</dialog> </dialog>
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
{% endblock %} {% endblock %}

View file

@ -1,6 +1,7 @@
import json import json
import pytest import pytest
from sqlalchemy import select
from l4d2web.app import create_app from l4d2web.app import create_app
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
@ -247,3 +248,48 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client
assert update.status_code == 302 assert update.status_code == 302
assert update.headers["Location"] == "/blueprints/1" assert update.headers["Location"] == "/blueprints/1"
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
with session_scope() as session:
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=0))
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert "data-overlay-list" in body
assert "data-overlay-add" in body
assert body.count('data-overlay-id="1"') == 1
assert 'data-overlay-id="2"' not in body
assert '<option value="2"' in body
assert '<option value="3"' in body
assert '<option value="1"' not in body
def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client) -> None:
with session_scope() as session:
user = session.scalar(select(User).where(User.username == "alice"))
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add_all(
[
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=2, position=0),
BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=1, position=1),
]
)
blueprint_id = blueprint.id
response = user_client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
first = body.find('name="overlay_ids" value="2"')
second = body.find('name="overlay_ids" value="1"')
assert first != -1 and second != -1
assert first < second

View file

@ -459,7 +459,8 @@ def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> N
assert 'name="arguments"' in text assert 'name="arguments"' in text
assert 'name="config"' in text assert 'name="config"' in text
assert 'name="overlay_ids"' in text assert 'name="overlay_ids"' in text
assert 'name="overlay_position_1"' in text assert "data-overlay-list" in text
assert "data-overlay-add" in text
def test_admin_jobs_page_renders_system_job(tmp_path, monkeypatch) -> None: def test_admin_jobs_page_renders_system_job(tmp_path, monkeypatch) -> None: