Add HTML5 setup form actions
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-16 21:26:54 +03:00
parent 2580cf9832
commit e71789f51e
3 changed files with 133 additions and 2 deletions
+53 -1
View File
@@ -164,6 +164,7 @@ def render_html5_project_setup(*, project_id: str, projects: Iterable[object], s
<div class="source-list">{source_cards or '<p class="muted padded">Источники импорта не настроены</p>'}</div> <div class="source-list">{source_cards or '<p class="muted padded">Источники импорта не настроены</p>'}</div>
</aside> </aside>
<section class="panel setup-main"> <section class="panel setup-main">
{render_html5_setup_actions(project_id, setup)}
{render_html5_setup_summary(project_id, setup)} {render_html5_setup_summary(project_id, setup)}
</section> </section>
</section> </section>
@@ -172,6 +173,50 @@ def render_html5_project_setup(*, project_id: str, projects: Iterable[object], s
return _page(f"SFERA HTML5 setup - {project_id}", content) return _page(f"SFERA HTML5 setup - {project_id}", content)
def render_html5_setup_actions(project_id: str, setup: object) -> str:
sources = getattr(setup, "import_sources", []) or []
current_source = _enum_text(getattr(setup, "current_source", None) or "")
source_options = "".join(_source_option(source, current_source) for source in sources)
if not source_options:
source_options = f'<option value="{escape(current_source or "XML_DUMP")}">{escape(current_source or "XML_DUMP")}</option>'
return f"""
<div class="setup-actions-panel" data-html5-setup-actions>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/source"
hx-post="/html5/projects/{quote(project_id)}/setup/source"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<label>Источник</label>
<select name="source">{source_options}</select>
<button type="submit">Сохранить</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/import"
hx-post="/html5/projects/{quote(project_id)}/setup/import"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<button type="submit">Запустить импорт</button>
</form>
<form
class="inline-form"
method="post"
action="/html5/projects/{quote(project_id)}/setup/reindex"
hx-post="/html5/projects/{quote(project_id)}/setup/reindex"
hx-target="[data-html5-setup-summary]"
hx-swap="outerHTML"
>
<button type="submit">Переиндексировать</button>
</form>
</div>
"""
def render_html5_setup_summary(project_id: str, setup: object) -> str: def render_html5_setup_summary(project_id: str, setup: object) -> str:
status = _enum_text(getattr(setup, "status", "unknown")) status = _enum_text(getattr(setup, "status", "unknown"))
message = str(getattr(setup, "message", "")) message = str(getattr(setup, "message", ""))
@@ -360,6 +405,13 @@ def _import_source_card(source: object) -> str:
""" """
def _source_option(source: object, current_source: str) -> str:
kind = _enum_text(getattr(source, "kind", ""))
title = str(getattr(source, "title", kind))
selected = " selected" if kind == current_source else ""
return f'<option value="{escape(kind)}"{selected}>{escape(title)} · {escape(kind)}</option>'
def _tree_item(project_id: str, node: object) -> str: def _tree_item(project_id: str, node: object) -> str:
name = getattr(node, "qualified_name", None) or getattr(node, "name", "") name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
kind = getattr(node, "kind", "") kind = getattr(node, "kind", "")
@@ -411,7 +463,7 @@ def _css() -> str:
.layout{display:grid;grid-template-columns:280px minmax(0,1fr)320px;height:calc(100vh - 88px)}.panel{overflow:auto}.panel-title{padding:12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:900;text-transform:uppercase;color:var(--muted)}.tree-item{display:block;padding:10px 12px;border-bottom:1px solid var(--line);text-decoration:none}.tree-item span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item small{color:var(--muted)} .layout{display:grid;grid-template-columns:280px minmax(0,1fr)320px;height:calc(100vh - 88px)}.panel{overflow:auto}.panel-title{padding:12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:900;text-transform:uppercase;color:var(--muted)}.tree-item{display:block;padding:10px 12px;border-bottom:1px solid var(--line);text-decoration:none}.tree-item span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item small{color:var(--muted)}
.editor{min-width:0;overflow:hidden;border-top:0;border-bottom:0}.editor-head{height:72px;border-bottom:1px solid var(--line);padding:0 14px}.editor-head h1{margin:0;font-size:18px}.search{display:flex;gap:6px}.search input{height:32px;width:260px;border:1px solid var(--line);padding:0 10px}.code{height:calc(100% - 72px);margin:0;overflow:auto;padding:16px;background:#fbfaf7;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap} .editor{min-width:0;overflow:hidden;border-top:0;border-bottom:0}.editor-head{height:72px;border-bottom:1px solid var(--line);padding:0 14px}.editor-head h1{margin:0;font-size:18px}.search{display:flex;gap:6px}.search input{height:32px;width:260px;border:1px solid var(--line);padding:0 10px}.code{height:calc(100% - 72px);margin:0;overflow:auto;padding:16px;background:#fbfaf7;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
.metrics{display:grid;grid-template-columns:1fr 1fr;margin:0}.metrics div{padding:12px;border-bottom:1px solid var(--line)}.metrics dt{color:var(--muted);font-size:12px}.metrics dd{margin:2px 0 0;font-size:22px;font-weight:800}.compact{list-style:none;margin:0;padding:0}.compact li{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--line)}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol span,.symbol small{color:var(--muted)}.status{position:fixed;bottom:0;left:0;right:0;display:flex;gap:18px;overflow:auto;height:34px;align-items:center;border-top:1px solid var(--line);background:#fff;padding:0 12px;color:var(--muted);font-size:12px}.empty-state{max-width:720px;margin:80px auto;background:#fff;border:1px solid var(--line);padding:28px} .metrics{display:grid;grid-template-columns:1fr 1fr;margin:0}.metrics div{padding:12px;border-bottom:1px solid var(--line)}.metrics dt{color:var(--muted);font-size:12px}.metrics dd{margin:2px 0 0;font-size:22px;font-weight:800}.compact{list-style:none;margin:0;padding:0}.compact li{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--line)}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol span,.symbol small{color:var(--muted)}.status{position:fixed;bottom:0;left:0;right:0;display:flex;gap:18px;overflow:auto;height:34px;align-items:center;border-top:1px solid var(--line);background:#fff;padding:0 12px;color:var(--muted);font-size:12px}.empty-state{max-width:720px;margin:80px auto;background:#fff;border:1px solid var(--line);padding:28px}
.setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.setup-metrics div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.setup-metrics div:last-child{border-right:0}.setup-metrics dt{font-size:12px;color:var(--muted)}.setup-metrics dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small{color:var(--muted)}.source-list,.history-list{display:grid} .setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.setup-actions-panel{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.inline-form{display:flex;gap:8px;align-items:end}.inline-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.inline-form select{height:32px;min-width:240px;border:1px solid var(--line);background:#fff;padding:0 8px}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.setup-metrics div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.setup-metrics div:last-child{border-right:0}.setup-metrics dt{font-size:12px;color:var(--muted)}.setup-metrics dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small{color:var(--muted)}.source-list,.history-list{display:grid}
@media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}} @media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}}
@media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}} @media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}}
""" """
+59 -1
View File
@@ -17,7 +17,7 @@ from difflib import SequenceMatcher
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.parse import quote, urljoin, urlsplit, urlunsplit from urllib.parse import parse_qs, quote, urljoin, urlsplit, urlunsplit
from uuid import uuid4 from uuid import uuid4
from collaboration import ( from collaboration import (
@@ -1665,6 +1665,42 @@ async def html5_project_setup_summary(project_id: str) -> Response:
) )
@app.post("/html5/projects/{project_id}/setup/source")
async def html5_project_setup_source(project_id: str, request: Request) -> Response:
form = await _html5_form_data(request)
source = ImportSourceKind(_form_value(form, "source") or ImportSourceKind.XML_DUMP.value)
current = _project_setup_response(project_id)
settings = current.settings.model_copy(update={"structure_source": source})
setup = await save_project_settings(project_id, settings)
return Response(
render_html5_setup_summary(project_id, setup),
media_type="text/html; charset=utf-8",
)
@app.post("/html5/projects/{project_id}/setup/import")
async def html5_project_setup_import(project_id: str, request: Request) -> Response:
form = await _html5_form_data(request)
source = ImportSourceKind(_form_value(form, "source") or _current_import_source(project_id).value)
structure_only = _form_value(form, "structure_only") in {"1", "true", "on", "yes"}
_execute_import_project(project_id, ImportRequest(source=source, structure_only=structure_only))
setup = _project_setup_response(project_id)
return Response(
render_html5_setup_summary(project_id, setup),
media_type="text/html; charset=utf-8",
)
@app.post("/html5/projects/{project_id}/setup/reindex")
async def html5_project_setup_reindex(project_id: str) -> Response:
await reindex_project(project_id)
setup = _project_setup_response(project_id)
return Response(
render_html5_setup_summary(project_id, setup),
media_type="text/html; charset=utf-8",
)
@app.get("/version") @app.get("/version")
async def version() -> dict[str, str]: async def version() -> dict[str, str]:
return {"name": "sfera", "version": "0.1.0"} return {"name": "sfera", "version": "0.1.0"}
@@ -7700,6 +7736,28 @@ def _project_has_stored_snapshot(project_id: str) -> bool:
return _storage.has_snapshot(project_id) return _storage.has_snapshot(project_id)
async def _html5_form_data(request: Request) -> dict[str, list[str]]:
body = (await request.body()).decode("utf-8")
return parse_qs(body, keep_blank_values=True)
def _form_value(form: dict[str, list[str]], key: str) -> str | None:
values = form.get(key)
if not values:
return None
value = values[0].strip()
return value or None
def _current_import_source(project_id: str) -> ImportSourceKind:
setup = _project_setup_response(project_id)
if setup.current_source is not None:
return setup.current_source
if setup.settings.structure_source is not None:
return setup.settings.structure_source
return ImportSourceKind.XML_DUMP
def _project_summaries() -> list[ProjectSummaryResponse]: def _project_summaries() -> list[ProjectSummaryResponse]:
project_ids = set(_project_setup.keys()) project_ids = set(_project_setup.keys())
stored_snapshots = _storage.list_snapshot_refs() stored_snapshots = _storage.list_snapshot_refs()
+21
View File
@@ -172,9 +172,30 @@ def test_html5_project_setup_renders_server_fragments():
assert "HTML5 Setup Demo" in setup.text assert "HTML5 Setup Demo" in setup.text
assert "data-html5-setup-summary" in setup.text assert "data-html5-setup-summary" in setup.text
assert f'hx-get="/html5/projects/{project_id}/setup/summary"' in setup.text assert f'hx-get="/html5/projects/{project_id}/setup/summary"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/source"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/import"' in setup.text
assert f'hx-post="/html5/projects/{project_id}/setup/reindex"' in setup.text
assert "XML_DUMP" in setup.text assert "XML_DUMP" in setup.text
assert "__next" not in setup.text assert "__next" not in setup.text
source = client.post(f"/html5/projects/{project_id}/setup/source", data={"source": "EDT_PROJECT"})
assert source.status_code == 200
assert "data-html5-setup-summary" in source.text
assert "EDT_PROJECT" in source.text
assert "<html" not in source.text
html5_import = client.post(f"/html5/projects/{project_id}/setup/import")
assert html5_import.status_code == 200
assert "data-html5-setup-summary" in html5_import.text
assert "mock_indexed" in html5_import.text
assert "<html" not in html5_import.text
reindex = client.post(f"/html5/projects/{project_id}/setup/reindex")
assert reindex.status_code == 200
assert "data-html5-setup-summary" in reindex.text
assert "reindexed" in reindex.text
assert "<html" not in reindex.text
summary = client.get(f"/html5/projects/{project_id}/setup/summary") summary = client.get(f"/html5/projects/{project_id}/setup/summary")
assert summary.status_code == 200 assert summary.status_code == 200
assert "text/html" in summary.headers["content-type"] assert "text/html" in summary.headers["content-type"]