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:
parent
dec4fed809
commit
a4e9f6cd26
6 changed files with 312 additions and 15 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
159
l4d2web/static/js/blueprint-overlay-picker.js
Normal file
159
l4d2web/static/js/blueprint-overlay-picker.js
Normal 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();
|
||||||
|
})();
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue