M2 drag-reorder: notes.position + reorder API + native DnD
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s

- Migration 0008: notes.position (int). Board orders pinned -> position ->
  updated_at; new notes created at top (max position + 1). POST /api/notes/reorder
  assigns positions from the given order (owner-scoped).
- notes store: position on Note, position-aware sort, optimistic reorder().
- NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves
  the dragged note before the drop target and persists.

Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns
reflow); order persists correctly. Candidate for a polish pass / layout tweak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 22:28:43 -04:00
co-authored by Claude Opus 4.8
parent e4c898cd1b
commit 339cc5c2d2
7 changed files with 135 additions and 7 deletions
+4 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -42,6 +42,8 @@ class Note(Base):
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
# 'text' (freeform body) or 'list' (a checklist of note_items).
kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text")
# Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
# Soft delete: non-null => in Trash. Restore sets it back to null.
@@ -58,6 +60,7 @@ class Note(Base):
"body": self.body,
"color": self.color,
"kind": self.kind,
"position": self.position,
"pinned": self.pinned,
"archived": self.archived,
"trashed": self.deleted_at is not None,
+35 -1
View File
@@ -146,7 +146,7 @@ async def list_notes():
except (ValueError, TypeError):
return jsonify({"error": "invalid label"}), 400
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
stmt = stmt.order_by(Note.pinned.desc(), Note.updated_at.desc())
stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)})
@@ -175,6 +175,33 @@ async def search_notes():
return jsonify({"notes": await _serialize_notes(db, notes)})
@bp.post("/reorder")
@login_required
async def reorder_notes():
data = await request.get_json(silent=True) or {}
ids = data.get("ids")
if not isinstance(ids, list):
return jsonify({"error": "ids must be a list"}), 400
parsed: list = []
for rid in ids:
try:
parsed.append(uuid.UUID(str(rid)))
except (ValueError, TypeError):
return jsonify({"error": "invalid id"}), 400
async with session_scope() as db:
owned = {
n.id: n
for n in (await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.id.in_(parsed)))).all()
}
total = len(parsed)
for index, nid in enumerate(parsed):
note = owned.get(nid)
if note is not None:
note.position = total - index
await db.commit()
return jsonify({"ok": True})
@bp.post("")
@login_required
async def create_note():
@@ -184,11 +211,18 @@ async def create_note():
if is_empty_note(title, body):
return jsonify({"error": "note is empty"}), 400
async with session_scope() as db:
# New notes go to the top of the manual order.
max_pos = await db.scalar(
select(func.coalesce(func.max(Note.position), 0)).where(
Note.owner_id == g.user_id, Note.deleted_at.is_(None)
)
)
note = Note(
owner_id=g.user_id,
title=title.strip() or None,
body=body,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
)
db.add(note)
await db.commit()