fix(docs-demo): sync admin settings sections with manifest
The demo Settings screen was fed by a frozen snapshot baked into the externally generated demoDataset.js, so it drifted from the real manifest: the Remnawave Panel (plus System and Migrations) sections were missing and trial/checkout/common still showed as top-level sections instead of subsections. Generate frontend/src/lib/webapp/settingsManifest.generated.json from manifest_payload() (the same source the live /admin/settings endpoint uses) via scripts/export_settings_manifest.py, and build the demo section structure from it, overlaying realistic demo values per field key. A pytest drift guard fails if the Python manifest changes without regenerating the snapshot, so the demo stays in sync going forward.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { DEV_MOCK } from "./previewMock.js";
|
import { DEV_MOCK } from "./previewMock.js";
|
||||||
import { DEMO_DATASET } from "./demoDataset.js";
|
import { DEMO_DATASET } from "./demoDataset.js";
|
||||||
|
import SETTINGS_MANIFEST_SECTIONS from "./settingsManifest.generated.json";
|
||||||
import { withDemoAvatar, withDemoAvatarDetail, withDemoAvatarTicket } from "./demoAvatars.js";
|
import { withDemoAvatar, withDemoAvatarDetail, withDemoAvatarTicket } from "./demoAvatars.js";
|
||||||
|
|
||||||
const DEMO_LANGUAGE_STORAGE_KEY = "rw_minishop_demo_language";
|
const DEMO_LANGUAGE_STORAGE_KEY = "rw_minishop_demo_language";
|
||||||
@@ -605,18 +606,43 @@ function filterDemoSupportTickets(items, params) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function demoSettingsValuesByKey() {
|
||||||
|
const map = new Map();
|
||||||
|
for (const section of DEMO_DATASET.settingsSections || []) {
|
||||||
|
for (const field of section.fields || []) {
|
||||||
|
map.set(field.key, field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function demoSettingsSections(clone) {
|
function demoSettingsSections(clone) {
|
||||||
const sections = clone(DEMO_DATASET.settingsSections || []);
|
// Section/field structure comes from the manifest snapshot generated off the
|
||||||
|
// Python source of truth (scripts/export_settings_manifest.py), so the demo
|
||||||
|
// stays in sync with the real admin. Realistic values are overlaid per field
|
||||||
|
// key from the dump-based dataset; fields absent there (e.g. a freshly added
|
||||||
|
// section) simply show their placeholders.
|
||||||
|
const demoValues = demoSettingsValuesByKey();
|
||||||
|
const sections = clone(SETTINGS_MANIFEST_SECTIONS);
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
for (const field of section.fields || []) {
|
for (const field of section.fields || []) {
|
||||||
if (!demoSettingsChanges.has(field.key)) continue;
|
const demoField = demoValues.get(field.key);
|
||||||
const change = demoSettingsChanges.get(field.key);
|
if (demoField) {
|
||||||
if (change.deleted) {
|
if ("value" in demoField) field.value = demoField.value;
|
||||||
field.value = field.default ?? "";
|
if ("overridden" in demoField) field.overridden = demoField.overridden;
|
||||||
field.overridden = false;
|
if ("updated_at" in demoField) field.updated_at = demoField.updated_at;
|
||||||
} else {
|
if ("source" in demoField) field.source = demoField.source;
|
||||||
field.value = change.value;
|
if (field.secret && "has_value" in demoField) field.has_value = demoField.has_value;
|
||||||
field.overridden = true;
|
}
|
||||||
|
if (demoSettingsChanges.has(field.key)) {
|
||||||
|
const change = demoSettingsChanges.get(field.key);
|
||||||
|
if (change.deleted) {
|
||||||
|
field.value = field.default ?? "";
|
||||||
|
field.overridden = false;
|
||||||
|
} else {
|
||||||
|
field.value = change.value;
|
||||||
|
field.overridden = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
|||||||
|
"""Export the admin settings manifest as a demo-safe JSON snapshot.
|
||||||
|
|
||||||
|
The Mini App demo (docs site) has no backend, so its admin Settings screen is
|
||||||
|
fed by a mock. Previously the section/field structure was frozen inside the
|
||||||
|
externally-generated ``demoDataset.js`` snapshot, which drifted from the real
|
||||||
|
manifest (e.g. the Remnawave Panel section was missing).
|
||||||
|
|
||||||
|
This script regenerates ``frontend/src/lib/webapp/settingsManifest.generated.json``
|
||||||
|
straight from :func:`manifest_payload` — the same source of truth the live
|
||||||
|
``/admin/settings`` endpoint uses — grouped into sections exactly like
|
||||||
|
``admin_settings_get_route``. The demo overlays its realistic values on top of
|
||||||
|
this structure, so adding a field in Python is enough to keep the demo in sync.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python scripts/export_settings_manifest.py
|
||||||
|
|
||||||
|
A pytest drift guard (``tests/test_settings_manifest_demo_sync.py``) compares the
|
||||||
|
committed JSON against a fresh build, so the snapshot cannot silently drift.
|
||||||
|
After regenerating, run Prettier so the file matches the frontend code style::
|
||||||
|
|
||||||
|
npx --prefix frontend prettier --write src/lib/webapp/settingsManifest.generated.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BACKEND = ROOT / "backend"
|
||||||
|
for _path in (str(BACKEND), str(ROOT)):
|
||||||
|
if _path not in sys.path:
|
||||||
|
sys.path.insert(0, _path)
|
||||||
|
|
||||||
|
from bot.app.web.admin_settings_manifest import manifest_payload # noqa: E402
|
||||||
|
|
||||||
|
OUTPUT_PATH = ROOT / "frontend" / "src" / "lib" / "webapp" / "settingsManifest.generated.json"
|
||||||
|
|
||||||
|
|
||||||
|
def build_demo_settings_sections() -> List[Dict[str, Any]]:
|
||||||
|
"""Group the manifest into ordered sections with demo-safe field values.
|
||||||
|
|
||||||
|
Mirrors :func:`bot.app.web.admin_api_impl.settings.admin_settings_get_route`
|
||||||
|
but without any database- or settings-derived values: every field starts
|
||||||
|
empty/unoverridden and secrets expose no value. The demo fills in realistic
|
||||||
|
values per field key at runtime.
|
||||||
|
"""
|
||||||
|
fields = manifest_payload()
|
||||||
|
sections: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for field in fields:
|
||||||
|
section_id = field["section"]
|
||||||
|
if section_id not in sections:
|
||||||
|
sections[section_id] = {
|
||||||
|
"id": section_id,
|
||||||
|
"order": field["section_order"],
|
||||||
|
"fields": [],
|
||||||
|
}
|
||||||
|
is_secret = bool(field.get("secret"))
|
||||||
|
response_field: Dict[str, Any] = {
|
||||||
|
**field,
|
||||||
|
"value": "",
|
||||||
|
"overridden": False,
|
||||||
|
"updated_at": None,
|
||||||
|
}
|
||||||
|
if is_secret:
|
||||||
|
response_field["has_value"] = False
|
||||||
|
webhook_path = str(response_field.get("webhook_path") or "").strip()
|
||||||
|
if webhook_path:
|
||||||
|
if not webhook_path.startswith("/"):
|
||||||
|
webhook_path = f"/{webhook_path}"
|
||||||
|
response_field["webhook_path"] = webhook_path
|
||||||
|
response_field["webhook_base_url_configured"] = False
|
||||||
|
sections[section_id]["fields"].append(response_field)
|
||||||
|
|
||||||
|
return sorted(sections.values(), key=lambda section: section["order"])
|
||||||
|
|
||||||
|
|
||||||
|
def render_json(sections: List[Dict[str, Any]]) -> str:
|
||||||
|
return json.dumps(sections, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
sections = build_demo_settings_sections()
|
||||||
|
OUTPUT_PATH.write_text(render_json(sections), encoding="utf-8")
|
||||||
|
field_count = sum(len(section["fields"]) for section in sections)
|
||||||
|
print(
|
||||||
|
f"Wrote {len(sections)} sections / {field_count} fields to {OUTPUT_PATH.relative_to(ROOT)}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Guard that the demo settings manifest snapshot stays in sync with Python.
|
||||||
|
|
||||||
|
The Mini App demo feeds its admin Settings screen from
|
||||||
|
``frontend/src/lib/webapp/settingsManifest.generated.json``, generated off the
|
||||||
|
real :func:`manifest_payload`. If a developer adds or changes a settings field
|
||||||
|
in Python without regenerating that snapshot, this test fails and tells them how
|
||||||
|
to refresh it — keeping the demo automatically in step with reality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_GENERATOR_PATH = _REPO_ROOT / "scripts" / "export_settings_manifest.py"
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location("export_settings_manifest", _GENERATOR_PATH)
|
||||||
|
export_settings_manifest = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(export_settings_manifest)
|
||||||
|
|
||||||
|
OUTPUT_PATH = export_settings_manifest.OUTPUT_PATH
|
||||||
|
build_demo_settings_sections = export_settings_manifest.build_demo_settings_sections
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsManifestDemoSyncTests(unittest.TestCase):
|
||||||
|
def test_committed_snapshot_matches_manifest(self):
|
||||||
|
self.assertTrue(
|
||||||
|
OUTPUT_PATH.exists(),
|
||||||
|
"Generated settings manifest snapshot is missing; run "
|
||||||
|
"`python scripts/export_settings_manifest.py`.",
|
||||||
|
)
|
||||||
|
committed = json.loads(OUTPUT_PATH.read_text(encoding="utf-8"))
|
||||||
|
expected = build_demo_settings_sections()
|
||||||
|
self.assertEqual(
|
||||||
|
committed,
|
||||||
|
expected,
|
||||||
|
"Demo settings manifest is stale. Regenerate it with "
|
||||||
|
"`python scripts/export_settings_manifest.py` and re-run Prettier.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_snapshot_includes_remnawave_section(self):
|
||||||
|
committed = json.loads(OUTPUT_PATH.read_text(encoding="utf-8"))
|
||||||
|
section_ids = {section["id"] for section in committed}
|
||||||
|
self.assertIn("remnawave", section_ids)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user