feat(web): blueprint-prefilled create-server flow + empty-state CTA
- Per-row "Create server" link on /blueprints navigates to /servers?blueprint_id=<id>; that page validates the param against the user's owned blueprints, pre-selects the option, and auto-opens the create modal. - /servers empty-blueprint state now shows an actionable "Create a blueprint first ->" link (styled like the primary button) pointing at /blueprints, replacing the silent disabled "+ Create" button + muted hint. - Drop the "Reassign blueprint" form on the server detail page along with the unused POST /servers/<id> form route. The JSON PATCH /servers/<id> endpoint is retained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
923a1840f4
commit
d14ed9c117
7 changed files with 118 additions and 118 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
|
||||
from flask import Blueprint, Response, redirect, render_template
|
||||
from flask import Blueprint, Response, redirect, render_template, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.auth import current_user, require_admin, require_login
|
||||
|
|
@ -70,7 +70,23 @@ def servers_page() -> str:
|
|||
blueprints = db.scalars(
|
||||
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
|
||||
).all()
|
||||
return render_template("servers.html", rows=rows, blueprints=blueprints)
|
||||
|
||||
prefill_blueprint_id: int | None = None
|
||||
raw_prefill = request.args.get("blueprint_id")
|
||||
if raw_prefill:
|
||||
try:
|
||||
candidate = int(raw_prefill)
|
||||
except ValueError:
|
||||
candidate = None
|
||||
if candidate is not None and any(b.id == candidate for b in blueprints):
|
||||
prefill_blueprint_id = candidate
|
||||
|
||||
return render_template(
|
||||
"servers.html",
|
||||
rows=rows,
|
||||
blueprints=blueprints,
|
||||
prefill_blueprint_id=prefill_blueprint_id,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/servers/<int:server_id>")
|
||||
|
|
@ -84,11 +100,6 @@ def server_detail(server_id: int):
|
|||
if server is None:
|
||||
return Response(status=404)
|
||||
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
|
||||
blueprints = db.scalars(
|
||||
select(BlueprintModel)
|
||||
.where(BlueprintModel.user_id == user.id)
|
||||
.order_by(BlueprintModel.name)
|
||||
).all()
|
||||
recent_job_rows = db.execute(
|
||||
select(Job, User, Server)
|
||||
.join(User, User.id == Job.user_id)
|
||||
|
|
@ -102,7 +113,6 @@ def server_detail(server_id: int):
|
|||
"server_detail.html",
|
||||
server=server,
|
||||
blueprint=blueprint,
|
||||
blueprints=blueprints,
|
||||
recent_job_rows=recent_job_rows,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -119,36 +119,6 @@ def update_server(server_id: int) -> Response:
|
|||
return jsonify({"id": server_id}), 200
|
||||
|
||||
|
||||
@bp.post("/servers/<int:server_id>")
|
||||
@require_login
|
||||
def update_server_form(server_id: int) -> Response:
|
||||
user = current_user()
|
||||
assert user is not None
|
||||
|
||||
try:
|
||||
blueprint_id = int(request.form["blueprint_id"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return Response("blueprint_id is required", status=400)
|
||||
|
||||
with session_scope() as db:
|
||||
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
|
||||
if server is None:
|
||||
return Response(status=404)
|
||||
|
||||
blueprint = db.scalar(
|
||||
select(BlueprintModel).where(
|
||||
BlueprintModel.id == blueprint_id,
|
||||
BlueprintModel.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if blueprint is None:
|
||||
return Response("blueprint not found", status=404)
|
||||
|
||||
server.blueprint_id = blueprint.id
|
||||
|
||||
return redirect(f"/servers/{server_id}")
|
||||
|
||||
|
||||
LIFECYCLE_OPERATIONS = {"initialize", "start", "stop", "delete"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,16 +59,20 @@ a:focus-visible {
|
|||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
button,
|
||||
a.button {
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
color: #fff;
|
||||
padding: var(--space-s) var(--space-l);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
button.danger,
|
||||
a.button.danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@
|
|||
<button type="button" data-modal-open="create-blueprint-modal">+ Create</button>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Updated</th></tr></thead>
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for blueprint in blueprints %}
|
||||
<tr>
|
||||
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
|
||||
<td>{{ blueprint.created_at }}</td>
|
||||
<td>{{ blueprint.updated_at }}</td>
|
||||
<td><a href="/servers?blueprint_id={{ blueprint.id }}">Create server</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted">No blueprints configured.</td></tr>
|
||||
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -29,24 +29,6 @@
|
|||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Reassign blueprint</h2>
|
||||
<form method="post" action="/servers/{{ server.id }}" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Blueprint
|
||||
<select name="blueprint_id" required>
|
||||
{% for option in blueprints %}
|
||||
<option value="{{ option.id }}"{% if option.id == server.blueprint_id %} selected{% endif %}>{{ option.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="field-hint">Changes apply on the next server action.</p>
|
||||
<div>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h2>Recent Jobs</h2>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@
|
|||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Servers</h1>
|
||||
<button type="button" data-modal-open="create-server-modal"{% if not blueprints %} disabled{% endif %}>+ Create</button>
|
||||
{% if blueprints %}
|
||||
<button type="button" data-modal-open="create-server-modal">+ Create</button>
|
||||
{% else %}
|
||||
<a class="button" href="/blueprints">Create a blueprint first →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not blueprints %}
|
||||
<p class="muted">Create a blueprint before adding servers.</p>
|
||||
{% endif %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Blueprint</th><th>Desired</th><th>Actual</th></tr></thead>
|
||||
<tbody>
|
||||
|
|
@ -46,7 +47,7 @@
|
|||
<label>Blueprint
|
||||
<select name="blueprint_id" required>
|
||||
{% for blueprint in blueprints %}
|
||||
<option value="{{ blueprint.id }}">{{ blueprint.name }}</option>
|
||||
<option value="{{ blueprint.id }}"{% if blueprint.id == prefill_blueprint_id %} selected{% endif %}>{{ blueprint.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -58,4 +59,15 @@
|
|||
</form>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
{% if prefill_blueprint_id %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dialog = document.getElementById("create-server-modal");
|
||||
if (dialog && typeof dialog.showModal === "function") {
|
||||
dialog.showModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,32 @@ def user_client_with_blueprints(tmp_path, monkeypatch):
|
|||
return client, payload
|
||||
|
||||
|
||||
def test_servers_page_without_blueprints_shows_create_blueprint_cta(tmp_path, monkeypatch) -> None:
|
||||
db_url = f"sqlite:///{tmp_path/'no_blueprints.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
|
||||
init_db()
|
||||
|
||||
with session_scope() as session:
|
||||
user = User(username="solo", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
user_id = user.id
|
||||
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["user_id"] = user_id
|
||||
sess["csrf_token"] = "test-token"
|
||||
|
||||
response = client.get("/servers")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/blueprints"' in text
|
||||
assert "Create a blueprint first" in text
|
||||
assert "disabled" not in text
|
||||
|
||||
|
||||
def test_create_server_from_blueprint(user_client_with_blueprints) -> None:
|
||||
client, data = user_client_with_blueprints
|
||||
payload = {"name": "alpha", "port": 27015, "blueprint_id": data["blueprint_id"]}
|
||||
|
|
@ -213,59 +239,6 @@ def test_create_server_returns_409_when_port_range_exhausted(tmp_path, monkeypat
|
|||
assert second.status_code == 409
|
||||
|
||||
|
||||
def test_update_server_form_reassigns_blueprint(user_client_with_blueprints) -> None:
|
||||
client, data = user_client_with_blueprints
|
||||
create = client.post(
|
||||
"/servers",
|
||||
data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert create.status_code == 302
|
||||
server_id = int(create.headers["Location"].rsplit("/", 1)[-1])
|
||||
|
||||
response = client.post(
|
||||
f"/servers/{server_id}",
|
||||
data={"blueprint_id": str(data["other_blueprint_id"])},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"] == f"/servers/{server_id}"
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from l4d2web.models import Server
|
||||
|
||||
with session_scope() as session:
|
||||
server = session.scalars(select(Server)).one()
|
||||
assert server.blueprint_id == data["other_blueprint_id"]
|
||||
|
||||
|
||||
def test_update_server_form_rejects_foreign_blueprint(user_client_with_blueprints, tmp_path) -> None:
|
||||
client, data = user_client_with_blueprints
|
||||
create = client.post(
|
||||
"/servers",
|
||||
data={"name": "alpha", "port": "27015", "blueprint_id": str(data["blueprint_id"])},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
server_id = int(create.headers["Location"].rsplit("/", 1)[-1])
|
||||
|
||||
with session_scope() as session:
|
||||
other = User(username="bob", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(other)
|
||||
session.flush()
|
||||
foreign = Blueprint(user_id=other.id, name="foreign", arguments="[]", config="[]")
|
||||
session.add(foreign)
|
||||
session.flush()
|
||||
foreign_id = foreign.id
|
||||
|
||||
response = client.post(
|
||||
f"/servers/{server_id}",
|
||||
data={"blueprint_id": str(foreign_id)},
|
||||
headers={"X-CSRF-Token": "test-token"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_operation_redirects_to_index(user_client_with_blueprints) -> None:
|
||||
client, data = user_client_with_blueprints
|
||||
create = client.post(
|
||||
|
|
@ -285,6 +258,54 @@ def test_delete_operation_redirects_to_index(user_client_with_blueprints) -> Non
|
|||
assert response.headers["Location"] == "/servers"
|
||||
|
||||
|
||||
def test_servers_page_prefills_blueprint_when_owned(user_client_with_blueprints) -> None:
|
||||
client, data = user_client_with_blueprints
|
||||
response = client.get(f"/servers?blueprint_id={data['other_blueprint_id']}")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'<option value="{data["other_blueprint_id"]}" selected>' in text
|
||||
assert "showModal" in text
|
||||
|
||||
|
||||
def test_servers_page_ignores_foreign_blueprint_id(user_client_with_blueprints) -> None:
|
||||
client, _ = user_client_with_blueprints
|
||||
|
||||
with session_scope() as session:
|
||||
other = User(username="bob", password_digest=hash_password("secret"), admin=False)
|
||||
session.add(other)
|
||||
session.flush()
|
||||
foreign = Blueprint(user_id=other.id, name="foreign", arguments="[]", config="[]")
|
||||
session.add(foreign)
|
||||
session.flush()
|
||||
foreign_id = foreign.id
|
||||
|
||||
response = client.get(f"/servers?blueprint_id={foreign_id}")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "selected" not in text
|
||||
assert "showModal" not in text
|
||||
|
||||
|
||||
def test_servers_page_ignores_non_integer_blueprint_id(user_client_with_blueprints) -> None:
|
||||
client, _ = user_client_with_blueprints
|
||||
response = client.get("/servers?blueprint_id=abc")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "showModal" not in text
|
||||
|
||||
|
||||
def test_servers_page_without_param_does_not_auto_open(user_client_with_blueprints) -> None:
|
||||
client, _ = user_client_with_blueprints
|
||||
response = client.get("/servers")
|
||||
text = response.get_data(as_text=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "showModal" not in text
|
||||
|
||||
|
||||
def test_lifecycle_form_creates_queued_job(user_client_with_blueprints) -> None:
|
||||
client, data = user_client_with_blueprints
|
||||
create_response = client.post(
|
||||
|
|
|
|||
Loading…
Reference in a new issue