Build editable HTML5 form designer
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-21 04:51:13 +03:00
parent 1b2721e2b7
commit e546985843
7 changed files with 409 additions and 36 deletions
@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class FormEditorCommand:
lineage_id: str
name: str
caption: str
handler_name: str
@dataclass(slots=True)
class FormEditorElement:
lineage_id: str
name: str
qualified_name: str
caption: str
control_kind: str
binding: str
width: str = "stretch"
row: int = 0
@dataclass(slots=True)
class FormEditorDraft:
form_lineage_id: str
form_name: str
form_title: str
owner_name: str
layout_kind: str = "auto"
commands: list[FormEditorCommand] = field(default_factory=list)
elements: list[FormEditorElement] = field(default_factory=list)
@@ -0,0 +1,150 @@
from __future__ import annotations
from api_server.form_editor_models import FormEditorCommand, FormEditorDraft, FormEditorElement
def build_form_editor_draft(
form_semantics: object,
form_module: object | None,
form_data: dict[str, list[str]] | None = None,
) -> FormEditorDraft:
form = getattr(form_semantics, "form", None)
form_name = str(getattr(form, "qualified_name", None) or getattr(form, "name", "Форма"))
module_attrs = getattr(form_module, "attributes", {}) or {}
owner_name = str(module_attrs.get("owner_qualified_name") or _owner_name_from_form(form_name))
draft = FormEditorDraft(
form_lineage_id=str(getattr(form, "lineage_id", "") or ""),
form_name=form_name,
form_title=str(_first_value(form_data, "form_title") or getattr(form, "name", None) or "Форма"),
owner_name=owner_name,
layout_kind=str(_first_value(form_data, "layout_kind") or "auto"),
commands=_commands_from_semantics(form_semantics),
elements=_elements_from_semantics(form_semantics),
)
if not draft.elements:
draft.elements = _default_elements_for_form(draft)
if form_data:
_apply_form_data(draft, form_data)
return draft
def _commands_from_semantics(form_semantics: object) -> list[FormEditorCommand]:
handlers = getattr(form_semantics, "command_handlers", {}) or {}
commands: list[FormEditorCommand] = []
for command in getattr(form_semantics, "commands", []) or []:
lineage_id = str(getattr(command, "lineage_id", "") or "")
handler = handlers.get(lineage_id)
handler_name = str(getattr(handler, "name", None) or getattr(handler, "qualified_name", None) or "")
name = str(getattr(command, "name", "Команда"))
commands.append(FormEditorCommand(lineage_id=lineage_id, name=name, caption=name, handler_name=handler_name))
return commands
def _elements_from_semantics(form_semantics: object) -> list[FormEditorElement]:
elements: list[FormEditorElement] = []
for index, element in enumerate(getattr(form_semantics, "elements", []) or []):
attributes = getattr(element, "attributes", {}) or {}
name = str(getattr(element, "name", None) or getattr(element, "qualified_name", "Элемент"))
elements.append(
FormEditorElement(
lineage_id=str(getattr(element, "lineage_id", "") or f"element.{index}"),
name=name,
qualified_name=str(getattr(element, "qualified_name", name)),
caption=str(attributes.get("caption") or attributes.get("synonym") or name),
control_kind=_control_kind_for(name, attributes),
binding=str(attributes.get("path") or attributes.get("dataPath") or attributes.get("binding") or name),
width=str(attributes.get("width") or "stretch"),
row=index,
)
)
return elements
def _apply_form_data(draft: FormEditorDraft, form_data: dict[str, list[str]]) -> None:
captions = _values(form_data, "element_caption")
kinds = _values(form_data, "element_kind")
bindings = _values(form_data, "element_binding")
widths = _values(form_data, "element_width")
for index, element in enumerate(draft.elements):
element.caption = _at(captions, index, element.caption)
element.control_kind = _safe_control_kind(_at(kinds, index, element.control_kind))
element.binding = _at(bindings, index, element.binding)
element.width = _safe_width(_at(widths, index, element.width))
command_captions = _values(form_data, "command_caption")
for index, command in enumerate(draft.commands):
command.caption = _at(command_captions, index, command.caption)
new_name = str(_first_value(form_data, "new_element_name") or "").strip()
if new_name:
draft.elements.append(
FormEditorElement(
lineage_id=f"draft.{len(draft.elements) + 1}",
name=new_name,
qualified_name=f"{draft.form_name}.{new_name}",
caption=new_name,
control_kind=_safe_control_kind(_first_value(form_data, "new_element_kind") or "input"),
binding=new_name,
row=len(draft.elements),
)
)
def _default_elements_for_form(draft: FormEditorDraft) -> list[FormEditorElement]:
if "ФормаСписка" in draft.form_name:
return [
FormEditorElement("default.list", "Список", f"{draft.form_name}.Список", "Список", "table", "Список", row=0),
]
if "ФормаДокумента" in draft.form_name:
return [
FormEditorElement("default.number", "Номер", f"{draft.form_name}.Номер", "Номер", "input", "Объект.Номер", "half", 0),
FormEditorElement("default.date", "Дата", f"{draft.form_name}.Дата", "Дата", "date", "Объект.Дата", "half", 1),
FormEditorElement("default.table", "Товары", f"{draft.form_name}.Товары", "Товары", "table", "Объект.Товары", row=2),
]
return [
FormEditorElement("default.name", "Наименование", f"{draft.form_name}.Наименование", "Наименование", "input", "Объект.Наименование", row=0),
]
def _control_kind_for(name: str, attributes: dict) -> str:
raw = str(attributes.get("control") or attributes.get("control_kind") or attributes.get("type") or "").casefold()
if "table" in raw or "табли" in raw:
return "table"
if "checkbox" in raw or "boolean" in raw or "булево" in raw or name.casefold().startswith(("это", "пометка")):
return "checkbox"
if "date" in raw or "дата" in raw or "Дата" in name:
return "date"
if "group" in raw or "группа" in raw:
return "group"
return "input"
def _safe_control_kind(value: str) -> str:
return value if value in {"input", "date", "checkbox", "table", "group", "text"} else "input"
def _safe_width(value: str) -> str:
return value if value in {"stretch", "half", "third"} else "stretch"
def _owner_name_from_form(form_name: str) -> str:
parts = form_name.split(".")
if len(parts) >= 2:
return ".".join(parts[:2])
return form_name
def _values(form_data: dict[str, list[str]], key: str) -> list[str]:
return [str(value) for value in form_data.get(key, [])]
def _first_value(form_data: dict[str, list[str]] | None, key: str) -> str | None:
if not form_data:
return None
values = form_data.get(key)
return str(values[0]) if values else None
def _at(values: list[str], index: int, default: str) -> str:
value = values[index].strip() if index < len(values) else ""
return value or default
@@ -6,8 +6,9 @@ from typing import Any
from fastapi import HTTPException
from api_server.form_editor_service import form_module_for_form, select_form_semantics
from api_server.form_editor_view_model import build_form_editor_draft
from api_server.html5_editor import render_html5_editor, render_html5_source, render_html5_symbol_detail
from api_server.html5_form_editor import render_html5_form_editor
from api_server.html5_form_editor import render_html5_form_designer, render_html5_form_editor
from api_server.html5_inspector import (
render_html5_flowchart,
render_html5_object_context,
@@ -74,6 +75,28 @@ def html5_form_editor_page(
)
def html5_form_editor_preview(
*,
project_id: str,
form: dict[str, list[str]],
project_snapshot: Callable[[str], SirSnapshot],
form_semantics_items: Callable[[SirSnapshot], Iterable[object]],
form_semantics_response: Callable[[object], Any],
) -> str:
snapshot = project_snapshot(project_id)
form_id = _form_value(form, "form")
forms = [form_semantics_response(item) for item in form_semantics_items(snapshot)]
selected = select_form_semantics(forms, form_id)
form_module = form_module_for_form(snapshot, selected.form if selected is not None else None)
draft = build_form_editor_draft(selected, form_module, form) if selected is not None else None
return render_html5_form_designer(project_id, selected, form_module, draft=draft)
def _form_value(form: dict[str, list[str]], key: str) -> str | None:
values = form.get(key)
return str(values[0]) if values else None
async def html5_object_context_fragment(
*,
project_id: str,
@@ -4,6 +4,8 @@ from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.form_editor_models import FormEditorCommand, FormEditorDraft, FormEditorElement
from api_server.form_editor_view_model import build_form_editor_draft
from api_server.html5 import _metric, _page, _project_link, _topbar
from api_server.html5_editor import render_html5_source
from sir import SirSnapshot
@@ -42,6 +44,7 @@ def render_html5_form_editor(
handlers = getattr(selected, "command_handlers", {}) if selected is not None else {}
module_lineage = str(getattr(form_module, "lineage_id", "") or "")
form_name = getattr(selected_form, "qualified_name", None) or getattr(selected_form, "name", "Форма")
draft = build_form_editor_draft(selected, form_module) if selected is not None else None
content = f"""
<main
class="workspace"
@@ -66,7 +69,7 @@ def render_html5_form_editor(
{_module_button(project_id, module_lineage)}
</nav>
</div>
{_form_designer_surface(project_id, selected, form_module)}
{render_html5_form_designer(project_id, selected, form_module, draft=draft)}
</section>
<aside class="panel inspector" data-html5-form-inspector>
<div class="panel-title">Состав формы</div>
@@ -90,6 +93,19 @@ def render_html5_form_editor(
return _page(f"SFERA Forms - {project_id}", content)
def render_html5_form_designer(
project_id: str,
form_semantics: object | None,
form_module: object | None,
*,
draft: FormEditorDraft | None = None,
) -> str:
if form_semantics is None:
return '<section class="form-designer" data-html5-form-designer><p class="muted padded">Выберите форму объекта.</p></section>'
draft = draft or build_form_editor_draft(form_semantics, form_module)
return _form_designer_surface(project_id, form_semantics, form_module, draft)
def _selected_form(forms: list[object], selected_form_id: str | None) -> object | None:
if selected_form_id:
selected = next(
@@ -147,65 +163,175 @@ def _module_button(project_id: str, module_lineage: str) -> str:
"""
def _form_designer_surface(project_id: str, form_semantics: object | None, form_module: object | None) -> str:
if form_semantics is None:
return '<section class="form-designer" data-html5-form-designer><p class="muted padded">Выберите форму объекта.</p></section>'
def _form_designer_surface(project_id: str, form_semantics: object, form_module: object | None, draft: FormEditorDraft) -> str:
form = getattr(form_semantics, "form", None)
commands = getattr(form_semantics, "commands", []) or []
elements = getattr(form_semantics, "elements", []) or []
handlers = getattr(form_semantics, "command_handlers", {}) or {}
form_name = getattr(form, "qualified_name", None) or getattr(form, "name", "Форма")
module_attrs = getattr(form_module, "attributes", {}) or {}
owner = module_attrs.get("owner_qualified_name") or _owner_name_from_form(str(form_name))
quoted_project = quote(project_id)
return f"""
<section class="form-designer" data-html5-form-designer data-html5-form="{escape(str(form_name))}">
<header class="form-designer-head">
<div>
<strong>{escape(str(form_name))}</strong>
<small>{escape(str(owner))} · объектная форма 1С</small>
<strong>{escape(draft.form_title)}</strong>
<small>{escape(draft.owner_name)} · форма как часть объекта 1С · visual layout</small>
</div>
<span data-html5-object-cache="warm">cache-warm</span>
</header>
<div class="form-canvas" data-html5-form-canvas>
<div class="form-window">
<div class="form-window-title">{escape(str(getattr(form, "name", "Форма")))}</div>
<div class="form-command-bar">
{''.join(_canvas_command(command, handlers) for command in commands) or '<span class="muted">Команды не описаны</span>'}
</div>
<div class="form-fields">
{''.join(_canvas_element(element) for element in elements) or '<p class="muted">Элементы формы не описаны в выгрузке.</p>'}
</div>
<form
class="form-designer-body"
hx-post="/html5/projects/{quoted_project}/forms/editor/preview"
hx-target="[data-html5-form-designer]"
hx-swap="outerHTML"
data-html5-form-edit-form
>
<input type="hidden" name="form" value="{escape(draft.form_lineage_id)}" />
<div class="form-canvas" data-html5-form-canvas>
{_canvas_window(draft)}
</div>
</div>
<aside class="form-property-panel" data-html5-form-properties>
<div class="property-row">
<label>Заголовок формы<input name="form_title" value="{escape(draft.form_title)}" /></label>
<label>Размещение
<select name="layout_kind">
{_option("auto", "Авто 1С", draft.layout_kind)}
{_option("columns", "Колонки", draft.layout_kind)}
{_option("compact", "Компактно", draft.layout_kind)}
</select>
</label>
</div>
<div class="panel-title">Командная панель</div>
{_command_editor(draft.commands)}
<div class="panel-title">Элементы формы</div>
{_element_editor(draft.elements)}
<div class="form-add-row">
<input name="new_element_name" placeholder="Новый реквизит формы" />
<select name="new_element_kind">
<option value="input">Поле ввода</option>
<option value="date">Дата</option>
<option value="checkbox">Флажок</option>
<option value="table">Таблица</option>
<option value="group">Группа</option>
</select>
</div>
<button type="submit" class="button primary" data-html5-form-preview-action>Применить в макет</button>
</aside>
</form>
<footer class="form-designer-foot">
<a class="button" href="/html5/projects/{quote(project_id)}/objects/context/{quote(str(owner), safe='')}">Контекст объекта</a>
<a class="button" href="/html5/projects/{quoted_project}/objects/context/{quote(draft.owner_name, safe='')}">Контекст объекта</a>
<small>Черновое редактирование: структура и свойства пересобираются сервером, модуль формы остается частью объекта.</small>
</footer>
</section>
"""
def _canvas_command(command: object, handlers: dict[str, object]) -> str:
lineage = str(getattr(command, "lineage_id", "") or "")
handler = handlers.get(lineage)
handler_name = getattr(handler, "name", None) or getattr(handler, "qualified_name", None) or "handler?"
def _canvas_window(draft: FormEditorDraft) -> str:
layout_attr = escape(draft.layout_kind)
return f"""
<button type="button" data-html5-form-command="{escape(lineage)}" title="{escape(str(handler_name))}">
{escape(str(getattr(command, "name", "Команда")))}
<div class="form-window" data-html5-form-window data-html5-form-layout="{layout_attr}">
<div class="form-window-title">
<span>{escape(draft.form_title)}</span>
<small>1C:Enterprise 8.5-style managed form</small>
</div>
<div class="form-command-bar">
{''.join(_canvas_command(command) for command in draft.commands) or '<span class="muted">Команды не описаны</span>'}
</div>
<div class="form-fields">
{''.join(_canvas_element(element) for element in draft.elements)}
</div>
</div>
"""
def _canvas_command(command: FormEditorCommand) -> str:
handler_name = command.handler_name or "handler?"
return f"""
<button type="button" data-html5-form-command="{escape(command.lineage_id)}" title="{escape(handler_name)}">
{escape(command.caption)}
</button>
"""
def _canvas_element(element: object) -> str:
name = str(getattr(element, "name", None) or getattr(element, "qualified_name", "Элемент"))
kind = str(getattr(element, "kind", "FORM_ELEMENT"))
def _canvas_element(element: FormEditorElement) -> str:
control = _control_markup(element)
return f"""
<label class="form-field" data-html5-form-element="{escape(name)}">
<span>{escape(name)}</span>
<input value="{escape(kind)}" readonly />
</label>
<div
class="form-field"
data-html5-form-element="{escape(element.lineage_id)}"
data-html5-form-control="{escape(element.control_kind)}"
data-html5-form-width="{escape(element.width)}"
>
<label>{escape(element.caption)}</label>
{control}
</div>
"""
def _control_markup(element: FormEditorElement) -> str:
if element.control_kind == "table":
return f"""
<div class="form-table-control">
<div><span>{escape(element.binding)}</span><span>Количество</span><span>Сумма</span></div>
<div><span></span><span></span><span></span></div>
<div><span></span><span></span><span></span></div>
</div>
"""
if element.control_kind == "checkbox":
return f'<label class="form-check-control"><input type="checkbox" checked /> <span>{escape(element.binding)}</span></label>'
if element.control_kind == "date":
return '<input class="form-input-control" value="21.05.2026 0:00:00" readonly />'
if element.control_kind == "group":
return f'<div class="form-group-control">{escape(element.binding)}</div>'
if element.control_kind == "text":
return '<textarea class="form-text-control" readonly></textarea>'
return f'<input class="form-input-control" value="{escape(element.binding)}" readonly />'
def _command_editor(commands: list[FormEditorCommand]) -> str:
if not commands:
return '<p class="muted padded">Команды можно добавить после импорта структуры формы.</p>'
return "".join(
f"""
<label class="form-editor-row" data-html5-form-command-editor>
<span>{escape(command.name)}</span>
<input type="hidden" name="command_lineage" value="{escape(command.lineage_id)}" />
<input name="command_caption" value="{escape(command.caption)}" />
<small>{escape(command.handler_name or "handler?")}</small>
</label>
"""
for command in commands
)
def _element_editor(elements: list[FormEditorElement]) -> str:
return "".join(
f"""
<article class="form-editor-row" data-html5-form-element-editor>
<input type="hidden" name="element_lineage" value="{escape(element.lineage_id)}" />
<label><span>Заголовок</span><input name="element_caption" value="{escape(element.caption)}" /></label>
<label><span>Вид</span><select name="element_kind">
{_option("input", "Поле ввода", element.control_kind)}
{_option("date", "Дата", element.control_kind)}
{_option("checkbox", "Флажок", element.control_kind)}
{_option("table", "Таблица", element.control_kind)}
{_option("group", "Группа", element.control_kind)}
{_option("text", "Текст", element.control_kind)}
</select></label>
<label><span>Данные</span><input name="element_binding" value="{escape(element.binding)}" /></label>
<label><span>Ширина</span><select name="element_width">
{_option("stretch", "Вся строка", element.width)}
{_option("half", "1/2", element.width)}
{_option("third", "1/3", element.width)}
</select></label>
</article>
"""
for element in elements
)
def _option(value: str, label: str, selected: str) -> str:
selected_attr = ' selected' if value == selected else ""
return f'<option value="{escape(value)}"{selected_attr}>{escape(label)}</option>'
def _form_object_summary(form_semantics: object | None, form_module: object | None) -> str:
if form_semantics is None:
return '<p class="object-summary">Форма не выбрана</p>'
@@ -58,6 +58,7 @@ from api_server.html5_forms import (
from api_server.html5_editor_controller import (
html5_editor_page as _html5_editor_page,
html5_form_editor_page as _html5_form_editor_page,
html5_form_editor_preview as _html5_form_editor_preview,
html5_object_context_fragment as _html5_object_context_fragment,
)
from api_server.html5_project_controller import (
@@ -1441,6 +1442,20 @@ async def html5_project_form_editor(project_id: str, form: str | None = None) ->
)
@app.post("/html5/projects/{project_id}/forms/editor/preview")
async def html5_project_form_editor_preview(project_id: str, request: Request) -> Response:
form = await _html5_form_data(request)
return _html5_response(
_html5_form_editor_preview(
project_id=project_id,
form=form,
project_snapshot=_project_snapshot_or_404,
form_semantics_items=form_semantics,
form_semantics_response=_form_semantics_response,
)
)
@app.get("/html5/projects/{project_id}/events")
async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse:
return _html5_sse_response(
@@ -8,5 +8,7 @@
.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)}.object-actions{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.object-actions .button{height:28px;padding:0 9px;font-size:12px}.object-actions .button[data-html5-object-action-active="true"],.object-actions .button[aria-current="page"]{background:var(--brand);border-color:var(--brand);color:#fff}.object-breadcrumb{display:flex;gap:6px;flex-wrap:wrap;padding:9px 12px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;font-weight:800;color:var(--muted)}.object-breadcrumb span:not(:last-child)::after{content:"/";margin-left:6px;color:#98a2b3}.object-breadcrumb span:last-child{color:var(--ink)}.object-summary,.symbol-summary,.review-summary,.project-summary,.object-report-summary,.authoring-summary{margin:0;padding:10px 12px;border-bottom:1px solid var(--line);background:#f8fbff;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.symbol:hover{background:#f8fbff}.symbol span,.symbol small{color:var(--muted)}.symbol-focus,.symbol-reference,.object-focus,.object-context-item{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol-focus small,.symbol-reference span,.symbol-reference small,.object-focus span,.object-context-item small{color:var(--muted)}.inline-actions{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;font-weight:800}.inline-actions a{color:var(--brand);text-decoration:none}.report-grid{display:grid;grid-template-columns:1fr 1fr;margin:0}.report-grid div{padding:10px 12px;border-bottom:1px solid var(--line)}.report-grid dt{color:var(--muted);font-size:12px}.report-grid dd{margin:2px 0 0;font-size:20px;font-weight:900}.review-item,.authoring-change{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.authoring-change[hx-get]{cursor:pointer}.authoring-change[hx-get]:hover{background:#f8fbff}.review-item span,.review-item small,.authoring-change span,.authoring-change small{color:var(--muted)}.diff-item{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;padding:8px 12px;border-bottom:1px solid var(--line)}.diff-item span{color:var(--muted);font-weight:800}.diff-item code{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.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}.settings-form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.settings-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.settings-form input{height:32px;border:1px solid var(--line);padding:0 8px}.saved{float:right;color:var(--ok);font-weight:900}.setup-actions-panel,.ops-filter{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ops-filter input{height:32px;min-width:180px;border:1px solid var(--line);padding:0 8px}.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}.rollback-form,.authoring-preview-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.rollback-form input,.authoring-preview-form input,.authoring-preview-form textarea{min-width:0;border:1px solid var(--line);padding:0 8px}.rollback-form input,.authoring-preview-form input{height:32px}.authoring-preview-form textarea{grid-column:1/-1;min-height:88px;padding:8px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.rollback-form button,.authoring-preview-form button{grid-column:1/-1}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics,.ops-summary{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.ops-summary{grid-template-columns:repeat(5,1fr);margin:12px 0}.setup-metrics div,.ops-summary div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff}.setup-metrics div:last-child,.ops-summary div:last-child{border-right:0}.setup-metrics dt,.ops-summary dt{font-size:12px;color:var(--muted)}.setup-metrics dd,.ops-summary 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,.check-item{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,.check-item span,.check-item small,.check-head span,.check-head small{color:var(--muted)}.source-list,.history-list,.check-list{display:grid}.check-head{display:flex;gap:10px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.job-log{margin:0;padding:0;list-style:none}.job-log li{padding:8px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
.form-editor-layout{grid-template-columns:280px minmax(0,1.2fr) minmax(320px,.8fr)}.form-editor-actions{display:flex;gap:8px;flex-wrap:wrap}.tree-item[data-html5-form-selected="true"]{background:#f8fbff;border-left-color:var(--brand)}.form-designer{height:calc(100% - 72px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden;background:#f7f9fc}.form-designer-head,.form-designer-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line);background:#fff}.form-designer-head strong,.form-designer-head small{display:block}.form-designer-head small{color:var(--muted)}.form-designer-head span{border:1px solid var(--line);background:#eef6f1;color:var(--ok);padding:5px 8px;font-size:12px;font-weight:900}.form-designer-foot{border-top:1px solid var(--line);border-bottom:0}.form-canvas{min-height:0;overflow:auto;padding:18px}.form-window{max-width:720px;min-height:420px;margin:0 auto;border:1px solid var(--line);background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.08)}.form-window-title{height:38px;display:flex;align-items:center;border-bottom:1px solid var(--line);padding:0 12px;font-weight:900;background:#fbfcfe}.form-command-bar{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line)}.form-command-bar button{height:28px;font-size:12px}.form-fields{display:grid;gap:10px;padding:14px}.form-field{display:grid;grid-template-columns:160px minmax(0,1fr);gap:8px;align-items:center}.form-field span{font-size:12px;font-weight:800;color:var(--muted)}.form-field input{height:30px;border:1px solid var(--line);padding:0 8px;background:#fbfaf7}
.form-designer-body{min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 340px;overflow:hidden}.form-property-panel{min-height:0;overflow:auto;border-left:1px solid var(--line);background:#fff}.property-row{display:grid;grid-template-columns:1fr 132px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}.property-row label,.form-editor-row label{display:grid;gap:4px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.property-row input,.property-row select,.form-editor-row input,.form-editor-row select,.form-add-row input,.form-add-row select{height:30px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.2 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.form-window{border-color:#b9c1cd;background:#fdfdfd;box-shadow:0 12px 28px rgba(33,43,54,.12)}.form-window-title{justify-content:space-between;height:34px;background:linear-gradient(#f8f9fb,#e9edf3);border-bottom-color:#cbd3df;color:#1f2937}.form-window-title small{font-size:11px;color:#687385;font-weight:800}.form-command-bar{min-height:42px;background:#f4f6f9;border-bottom-color:#ccd4df}.form-command-bar button{height:27px;border-color:#aeb8c6;background:linear-gradient(#fff,#edf1f6);box-shadow:inset 0 1px 0 #fff;color:#1f2937}.form-command-bar button:hover{background:#fff}.form-fields{grid-template-columns:repeat(12,minmax(0,1fr));align-content:start;gap:8px 12px;padding:14px 16px 22px;background:#fbfbfc}.form-field{grid-column:1/-1;grid-template-columns:150px minmax(0,1fr);min-height:32px;padding:4px 6px;border:1px solid transparent}.form-field:hover{border-color:#b9c1cd;background:#fff}.form-field[data-html5-form-width="half"]{grid-column:span 6}.form-field[data-html5-form-width="third"]{grid-column:span 4}.form-field>label{font-size:12px;font-weight:800;color:#4b5563;align-self:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.form-input-control,.form-text-control{width:100%;border:1px solid #aeb8c6;background:#fff;padding:0 7px;color:#17202a;box-shadow:inset 0 1px 2px rgba(15,23,42,.06)}.form-input-control{height:28px}.form-text-control{min-height:64px;resize:none}.form-check-control{display:flex;align-items:center;gap:8px;color:#374151}.form-check-control input{width:16px;height:16px}.form-table-control{border:1px solid #aeb8c6;background:#fff;min-height:108px;overflow:hidden}.form-table-control div{display:grid;grid-template-columns:2fr 1fr 1fr}.form-table-control span{min-height:27px;border-right:1px solid #d7dde6;border-bottom:1px solid #d7dde6;padding:5px 7px;color:#374151;font-size:12px}.form-table-control div:first-child span{background:#eef2f7;font-weight:900}.form-group-control{min-height:44px;border:1px dashed #aeb8c6;background:#f6f8fb;padding:10px;color:#687385;font-weight:800}.form-editor-row{display:grid;gap:7px;padding:10px 12px;border-bottom:1px solid var(--line)}label.form-editor-row{grid-template-columns:88px minmax(0,1fr);align-items:center;text-transform:none}.form-editor-row small{color:var(--muted);font-size:11px}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr 118px 1fr 104px}.form-add-row{display:grid;grid-template-columns:minmax(0,1fr) 118px;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.button.primary{background:var(--brand);border-color:var(--brand);color:#fff;margin:12px}.form-designer-foot small{color:var(--muted);font-weight:800}.form-window[data-html5-form-layout="compact"] .form-fields{gap:4px 8px}.form-window[data-html5-form-layout="compact"] .form-field{min-height:28px}.form-window[data-html5-form-layout="columns"] .form-field{grid-column:span 6}.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="table"],.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="group"]{grid-column:1/-1}
@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){.form-designer-body{grid-template-columns:1fr}.form-property-panel{border-left:0;border-top:1px solid var(--line)}.form-field[data-html5-form-width="half"],.form-field[data-html5-form-width="third"]{grid-column:1/-1}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr}.property-row{grid-template-columns:1fr}}
@media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}}
+23
View File
@@ -692,7 +692,30 @@ def test_html5_object_context_fragment(tmp_path: Path):
assert "ПровестиКоманда" in form_editor.text
assert "Модуль формы" in form_editor.text
assert 'data-html5-object-cache="warm"' in form_editor.text
assert "data-html5-form-edit-form" in form_editor.text
assert "data-html5-form-window" in form_editor.text
assert "data-html5-form-properties" in form_editor.text
assert "Применить в макет" in form_editor.text
assert "ПриОткрытии" in form_editor.text
form_preview = client.post(
f"/html5/projects/{project_id}/forms/editor/preview",
data={
"form": form_node["lineage_id"],
"form_title": "Заказ покупателя 8.5",
"layout_kind": "columns",
"command_caption": "Провести заказ",
"new_element_name": "Комментарий",
"new_element_kind": "text",
},
)
assert form_preview.status_code == 200
assert "data-html5-form-designer" in form_preview.text
assert "Заказ покупателя 8.5" in form_preview.text
assert "Провести заказ" in form_preview.text
assert "Комментарий" in form_preview.text
assert 'data-html5-form-control="text"' in form_preview.text
assert 'data-html5-form-layout="columns"' in form_preview.text
assert "data-html5-object-report-summary" in context.text
assert "data links" in context.text
assert "data-html5-review" in context.text