feat(sidebar): drag-and-drop category + room reorder (LC-79 phase 2) #131

Merged
nrupard merged 1 commit from feat/lc-79-phase2-drag-and-drop into main 2026-05-18 17:28:25 +02:00
Owner

Summary

LC-79 phase 2: drag-and-drop reorder for sidebar categories and the rooms inside them. Closes LC-79 phase 2.

What's draggable

  • Category sections (reorder categories in their group via [data-category-list] -> PATCH /sidebar/categories/positions).
  • Room rows inside per-category lists (reorder within a category; drag across categories upserts the assignment row alongside setting the position via PATCH /sidebar/categories/{cat_id}/positions).

Uncategorized rooms are intentionally not draggable in this phase: the existing "Move to category" / "Remove from category" dropdown from phase 1b already covers those transitions without the cross-list drag complexity. Future phase can promote the All-rooms list to a drop target too.

DnD implementation

Native HTML5 DnD in a new server/assets/sidebar_dnd.js (~80 lines, no library). Event delegation off document so HTMX-driven sidebar swaps don't break the handlers. On drop the JS reads the new DOM order, posts ids=a,b,c to the list's data-positions-url, replaces #sidebar with the server's rebuilt fragment, and reprocesses HTMX attributes on the new tree.

Endpoints

  • PATCH /sidebar/categories/positions -> set_category_positions (UPDATEs position per id in a transaction, scoped to the calling user so a spoofed id from a different user is a no-op).
  • PATCH /sidebar/categories/{cat_id}/positions -> set_room_positions handles three shapes in one call: same-category reorder, cross-category drag (room currently in a different category gets upserted), and newly categorizing (room previously uncategorized lands here). Membership-checks every room id via is_room_accessible first so the endpoint cannot disclose private-room existence.

Body shape

Comma-separated single field (ids=3,1,2) rather than the repeated-key form (ids[]=...). The latter is not supported by axum's default Form extractor (returns 422 at decode); the comma-split parser surfaces a 400 BadRequest for any non-integer entry instead of silently skipping it.

Test plan

  • ./dev/cargo test -p lets-chat-server --test routes_sidebar_categories (9 tests pass: 6 from phases 1a/1b plus 3 new: category reorder, cross-category room move, access refusal).
  • just check (fmt + clippy across standalone + saas).
  • just test (full standalone suite, all green).
  • Manual: drag a category up; drag a room within a category; drag a room from one category to another; refresh page (all orderings persist).

Follow-ups

  • Phase 3: aggregate unread / mention badge on collapsed categories; mobile responsive polish.
  • Possible phase 2b: make the uncategorized "All rooms" list a drop target so users can drag rooms there to uncategorize.
## Summary LC-79 phase 2: drag-and-drop reorder for sidebar categories and the rooms inside them. Closes [LC-79](https://niceguyit.myjetbrains.com/youtrack/issue/LC-79) phase 2. ## What's draggable - Category sections (reorder categories in their group via `[data-category-list]` -> `PATCH /sidebar/categories/positions`). - Room rows inside per-category lists (reorder within a category; drag across categories upserts the assignment row alongside setting the position via `PATCH /sidebar/categories/{cat_id}/positions`). Uncategorized rooms are intentionally not draggable in this phase: the existing "Move to category" / "Remove from category" dropdown from phase 1b already covers those transitions without the cross-list drag complexity. Future phase can promote the All-rooms list to a drop target too. ## DnD implementation Native HTML5 DnD in a new `server/assets/sidebar_dnd.js` (~80 lines, no library). Event delegation off `document` so HTMX-driven sidebar swaps don't break the handlers. On drop the JS reads the new DOM order, posts `ids=a,b,c` to the list's `data-positions-url`, replaces `#sidebar` with the server's rebuilt fragment, and reprocesses HTMX attributes on the new tree. ## Endpoints - `PATCH /sidebar/categories/positions` -> `set_category_positions` (UPDATEs `position` per id in a transaction, scoped to the calling user so a spoofed id from a different user is a no-op). - `PATCH /sidebar/categories/{cat_id}/positions` -> `set_room_positions` handles three shapes in one call: same-category reorder, cross-category drag (room currently in a different category gets upserted), and newly categorizing (room previously uncategorized lands here). Membership-checks every room id via `is_room_accessible` first so the endpoint cannot disclose private-room existence. ## Body shape Comma-separated single field (`ids=3,1,2`) rather than the repeated-key form (`ids[]=...`). The latter is not supported by axum's default Form extractor (returns 422 at decode); the comma-split parser surfaces a 400 BadRequest for any non-integer entry instead of silently skipping it. ## Test plan - [x] `./dev/cargo test -p lets-chat-server --test routes_sidebar_categories` (9 tests pass: 6 from phases 1a/1b plus 3 new: category reorder, cross-category room move, access refusal). - [x] `just check` (fmt + clippy across standalone + saas). - [x] `just test` (full standalone suite, all green). - [ ] Manual: drag a category up; drag a room within a category; drag a room from one category to another; refresh page (all orderings persist). ## Follow-ups - Phase 3: aggregate unread / mention badge on collapsed categories; mobile responsive polish. - Possible phase 2b: make the uncategorized "All rooms" list a drop target so users can drag rooms there to uncategorize.
feat(sidebar): drag-and-drop category + room reorder (LC-79 phase 2)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 48s
ba4a506a39
Native HTML5 DnD in `server/assets/sidebar_dnd.js` (~80 lines, no library). Two reorderable surfaces share one handler via event delegation off `document`, so HTMX sidebar swaps don't break the bindings: category sections inside `[data-category-list]`, and room rows inside per-category `[data-room-list]` lists. On drop the JS reads the new DOM order, posts `ids=a,b,c` to the list's `data-positions-url`, replaces `#sidebar` with the returned fragment, and reprocesses HTMX attributes on the new tree.

Two new endpoints back the JS, both inside `routes/sidebar_categories.rs`:

- `PATCH /sidebar/categories/positions` -> `set_category_positions` (UPDATEs `position` per id in a transaction, scoped to the calling user so a spoofed id from a different user is a no-op).
- `PATCH /sidebar/categories/{cat_id}/positions` -> `set_room_positions` handles same-category reorder, cross-category drag (room currently in a different category gets upserted), and newly categorizing (room previously uncategorized lands here). Membership-checks every room id via `is_room_accessible` first so the endpoint cannot disclose private-room existence.

Body shape is comma-separated (`ids=3,1,2`) rather than the repeated-key form (`ids[]=…`); the latter is not supported by axum's default Form extractor (returns 422 at decode), and the comma-split parser surfaces a 400 BadRequest for any non-integer entry.

Uncategorized rooms are intentionally not draggable in phase 2: the existing per-row "move to category" / "Remove from category" dropdown (phase 1b) already covers those transitions without the cross-list drag complexity.

3 new tests cover the endpoints (category reorder, cross-category move, access refusal). All 9 sidebar tests pass; `just check` and `just test` green.
nrupard deleted branch feat/lc-79-phase2-drag-and-drop 2026-05-18 17:28:25 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!131
No description provided.