Skip to content

Commit e65e166

Browse files
committed
feat: add minemap layout.
1 parent 167856a commit e65e166

18 files changed

Lines changed: 961 additions & 125 deletions

CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,12 @@ Docker Compose: backend (uvicorn, port 8000) + frontend (nginx, port 3000). Shar
125125

126126
## Development Workflow
127127

128-
After completing any development task, always run the following before reporting the task as done:
128+
Standard loop for every task:
129129

130-
1. **Tests** — run `make test` (or the relevant subset: `make test-backend` / `make test-frontend`) and make sure everything passes.
131-
2. **Lint** — run `make lint` for frontend changes.
132-
3. **Code review** — perform a self-review of the diff for non-trivial changes.
130+
1. **Develop** — implement the change.
131+
2. **Test**`make test` (or relevant subset: `make test-backend` / `make test-frontend`). Must pass.
132+
3. **Code review** — self-review the diff. If issues found, return to step 1.
133+
134+
Repeat until the review surfaces no further issues. Then report done.
135+
136+
Frontend changes also require `make lint` before review.

backend/app/database.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
version INTEGER NOT NULL DEFAULT 1,
8080
is_public INTEGER NOT NULL DEFAULT 0,
8181
page_type TEXT NOT NULL DEFAULT 'document',
82+
mindmap_layout TEXT,
8283
deleted_at TIMESTAMP,
8384
created_by INTEGER REFERENCES users(id),
8485
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

backend/app/migrations.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,17 @@ async def _m009_page_type(db: aiosqlite.Connection) -> None:
146146
)
147147

148148

149+
async def _m011_mindmap_layout(db: aiosqlite.Connection) -> None:
150+
"""Add `mindmap_layout` to pages so authors can pick LR / RL / Radial.
151+
152+
Nullable TEXT — NULL means "use the frontend default" (`'lr'`), so legacy
153+
rows render unchanged. Validation is enforced by Pydantic Literal in
154+
schemas.MindmapLayout, not at the DB layer (matches `page_type`).
155+
"""
156+
if not await _column_exists(db, "pages", "mindmap_layout"):
157+
await db.execute("ALTER TABLE pages ADD COLUMN mindmap_layout TEXT")
158+
159+
149160
async def _m010_site_settings(db: aiosqlite.Connection) -> None:
150161
"""Create site_settings for branding overrides and the home-page slug.
151162
@@ -175,6 +186,7 @@ async def _m010_site_settings(db: aiosqlite.Connection) -> None:
175186
(8, "api_tokens_extend", _m008_api_tokens_extend),
176187
(9, "page_type", _m009_page_type),
177188
(10, "site_settings", _m010_site_settings),
189+
(11, "mindmap_layout", _m011_mindmap_layout),
178190
]
179191

180192

@@ -271,6 +283,8 @@ async def _detect_preexisting(db: aiosqlite.Connection) -> set[int]:
271283
)
272284
if rows:
273285
applied.add(10)
286+
if await _column_exists(db, "pages", "mindmap_layout"):
287+
applied.add(11)
274288
return applied
275289

276290

backend/app/routers/pages.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,9 @@ async def create_page(body: PageCreate, user=Depends(get_current_user)):
286286
candidate = await unique_slug(db, slugify(body.title, body.slug))
287287
try:
288288
cursor = await db.execute(
289-
"""INSERT INTO pages (slug, title, content_md, parent_id, sort_order, version, page_type, created_by)
290-
VALUES (?, ?, ?, ?, ?, 1, ?, ?)""",
291-
(candidate, body.title, content, body.parent_id, body.sort_order, body.page_type, user["id"]),
289+
"""INSERT INTO pages (slug, title, content_md, parent_id, sort_order, version, page_type, mindmap_layout, created_by)
290+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)""",
291+
(candidate, body.title, content, body.parent_id, body.sort_order, body.page_type, body.mindmap_layout, user["id"]),
292292
)
293293
slug = candidate
294294
page_id = cursor.lastrowid
@@ -421,6 +421,15 @@ async def update_page(slug: str, body: PageUpdate, user=Depends(get_current_user
421421
is_public = body.is_public if body.is_public is not None else current_is_public
422422
current_page_type = current.get("page_type") or "document"
423423
page_type = body.page_type if body.page_type is not None else current_page_type
424+
# mindmap_layout is `Optional[Literal[...]]` — explicit NULL is meaningful
425+
# (revert to default), so distinguish "field not in payload" from
426+
# "field set to None" via model_fields_set, like parent_id above.
427+
current_mindmap_layout = current.get("mindmap_layout")
428+
mindmap_layout = (
429+
body.mindmap_layout
430+
if "mindmap_layout" in body.model_fields_set
431+
else current_mindmap_layout
432+
)
424433

425434
content_changed = body.content_md is not None and body.content_md != current["content_md"]
426435
title_changed = body.title is not None and body.title != current["title"]
@@ -436,8 +445,8 @@ async def update_page(slug: str, body: PageUpdate, user=Depends(get_current_user
436445

437446
await db.execute(
438447
"""UPDATE pages SET title = ?, content_md = ?, parent_id = ?, sort_order = ?,
439-
is_public = ?, page_type = ?, version = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?""",
440-
(title, content, parent_id, sort_order, 1 if is_public else 0, page_type, new_version, slug),
448+
is_public = ?, page_type = ?, mindmap_layout = ?, version = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?""",
449+
(title, content, parent_id, sort_order, 1 if is_public else 0, page_type, mindmap_layout, new_version, slug),
441450
)
442451

443452
# Update search index

backend/app/routers/public.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def get_public_page(slug: str, request: Request):
4040

4141
db = await get_db()
4242
rows = await db.execute_fetchall(
43-
"""SELECT p.slug, p.title, p.content_md, p.page_type, p.updated_at,
43+
"""SELECT p.slug, p.title, p.content_md, p.page_type, p.mindmap_layout, p.updated_at,
4444
CASE WHEN u.display_name IS NOT NULL AND u.display_name != ''
4545
THEN u.display_name ELSE u.username END AS author_name
4646
FROM pages p

backend/app/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
# Add new types here (plus a frontend renderer) — no migration required.
99
PageType = Literal["document", "mindmap"]
1010

11+
# Mindmap layout strategy. Author-chosen, persisted on the page row. NULL is
12+
# treated as 'lr' by the frontend so existing rows render unchanged.
13+
MindmapLayout = Literal["lr", "rl", "radial"]
14+
1115

1216
# ── Auth ──
1317
class LoginRequest(BaseModel):
@@ -33,6 +37,7 @@ class PageCreate(BaseModel):
3337
template_id: Optional[int] = None
3438
slug: Optional[str] = None
3539
page_type: PageType = "document"
40+
mindmap_layout: Optional[MindmapLayout] = None
3641

3742

3843
class PageUpdate(BaseModel):
@@ -42,6 +47,7 @@ class PageUpdate(BaseModel):
4247
sort_order: Optional[int] = None
4348
is_public: Optional[bool] = None
4449
page_type: Optional[PageType] = None
50+
mindmap_layout: Optional[MindmapLayout] = None
4551
base_version: Optional[int] = None # for optimistic locking
4652

4753

@@ -61,6 +67,7 @@ class PageResponse(BaseModel):
6167
version: int = 1
6268
is_public: bool = False
6369
page_type: PageType = "document"
70+
mindmap_layout: Optional[MindmapLayout] = None
6471
created_by: Optional[int] = None
6572
author_name: Optional[str] = None
6673
created_at: Optional[str] = None
@@ -73,6 +80,7 @@ class PublicPageResponse(BaseModel):
7380
title: str
7481
content_md: str
7582
page_type: PageType = "document"
83+
mindmap_layout: Optional[MindmapLayout] = None
7684
updated_at: Optional[str] = None
7785
author_name: Optional[str] = None
7886
diagrams: dict[str, str] = {}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Backend coverage for the `pages.mindmap_layout` column.
2+
3+
The frontend treats NULL as `'lr'`, so existing rows must keep returning
4+
`null` and the field must round-trip through both create and update without
5+
bumping `pages.version`.
6+
"""
7+
import pytest
8+
9+
10+
@pytest.mark.asyncio
11+
async def test_create_mindmap_page_omits_layout_returns_null(auth_client):
12+
response = await auth_client.post(
13+
"/api/pages",
14+
json={
15+
"title": "ML Default",
16+
"slug": "ml-default",
17+
"page_type": "mindmap",
18+
"content_md": "# Root",
19+
},
20+
)
21+
assert response.status_code == 201
22+
data = response.json()
23+
assert data["page_type"] == "mindmap"
24+
assert "mindmap_layout" in data
25+
assert data["mindmap_layout"] is None
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_create_with_explicit_layout_persists(auth_client):
30+
response = await auth_client.post(
31+
"/api/pages",
32+
json={
33+
"title": "ML Radial",
34+
"slug": "ml-radial",
35+
"page_type": "mindmap",
36+
"mindmap_layout": "radial",
37+
"content_md": "# Root",
38+
},
39+
)
40+
assert response.status_code == 201
41+
assert response.json()["mindmap_layout"] == "radial"
42+
43+
fetched = await auth_client.get("/api/pages/ml-radial")
44+
assert fetched.status_code == 200
45+
assert fetched.json()["mindmap_layout"] == "radial"
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_update_layout_does_not_bump_version(auth_client):
50+
create = await auth_client.post(
51+
"/api/pages",
52+
json={
53+
"title": "ML Vbump",
54+
"slug": "ml-vbump",
55+
"page_type": "mindmap",
56+
"content_md": "# Root",
57+
},
58+
)
59+
assert create.status_code == 201
60+
starting_version = create.json()["version"]
61+
62+
# Layout-only edit: must persist and must NOT bump the version counter
63+
# (matches is_public / page_type behavior — see pages.py optimistic-lock
64+
# logic: only content_md / title bump version).
65+
res = await auth_client.put(
66+
"/api/pages/ml-vbump",
67+
json={"mindmap_layout": "rl"},
68+
)
69+
assert res.status_code == 200
70+
body = res.json()
71+
assert body["mindmap_layout"] == "rl"
72+
assert body["version"] == starting_version
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_update_layout_to_invalid_value_returns_422(auth_client):
77+
await auth_client.post(
78+
"/api/pages",
79+
json={
80+
"title": "ML Bad",
81+
"slug": "ml-bad",
82+
"page_type": "mindmap",
83+
"content_md": "# Root",
84+
},
85+
)
86+
res = await auth_client.put(
87+
"/api/pages/ml-bad",
88+
json={"mindmap_layout": "diagonal"},
89+
)
90+
assert res.status_code == 422
91+
92+
93+
@pytest.mark.asyncio
94+
async def test_update_layout_back_to_null(auth_client):
95+
await auth_client.post(
96+
"/api/pages",
97+
json={
98+
"title": "ML Reset",
99+
"slug": "ml-reset",
100+
"page_type": "mindmap",
101+
"mindmap_layout": "radial",
102+
"content_md": "# Root",
103+
},
104+
)
105+
res = await auth_client.put(
106+
"/api/pages/ml-reset",
107+
json={"mindmap_layout": None},
108+
)
109+
assert res.status_code == 200
110+
assert res.json()["mindmap_layout"] is None
111+
112+
fetched = await auth_client.get("/api/pages/ml-reset")
113+
assert fetched.json()["mindmap_layout"] is None
114+
115+
116+
@pytest.mark.asyncio
117+
async def test_layout_allowed_on_document_page(auth_client):
118+
"""Layout column has no meaning for `page_type='document'`, but we accept
119+
it anyway so toggling page_type doesn't lose the setting."""
120+
res = await auth_client.post(
121+
"/api/pages",
122+
json={
123+
"title": "Doc With Layout",
124+
"slug": "doc-with-layout",
125+
"page_type": "document",
126+
"mindmap_layout": "radial",
127+
"content_md": "Body",
128+
},
129+
)
130+
assert res.status_code == 201
131+
assert res.json()["mindmap_layout"] == "radial"
132+
133+
134+
@pytest.mark.asyncio
135+
async def test_get_response_always_includes_layout_key(auth_client):
136+
await auth_client.post(
137+
"/api/pages",
138+
json={"title": "Plain Doc", "slug": "plain-doc", "content_md": "x"},
139+
)
140+
res = await auth_client.get("/api/pages/plain-doc")
141+
assert res.status_code == 200
142+
assert "mindmap_layout" in res.json()

0 commit comments

Comments
 (0)