Notes Sidebar View — Design
Date: 2026-04-24 Status: Approved for planning
Summary
Add a second view mode to /notes: a Notion-style sidebar (default) alongside the existing card view. Notes gain an emoji field and a 2-level parent/child hierarchy. Users reorder and re-nest notes via drag-and-drop. A shared emoji picker replaces the per-feature icon grids in habits, goals, and countdowns.
Goals
- Sidebar view as the default on
/notes, with a toggle to switch to card view. - Emoji on every note, rendered in both sidebar and card views.
- 2-level hierarchy (root + one level of children) with drag-and-drop reorder and re-nest.
- Title + rich-text content search in the sidebar.
- A single searchable emoji picker used across notes, habits, goals, and countdowns.
Non-goals
- Inline editing in the main pane (editing continues via the modal form).
- Grandchild nesting.
- Mobile split-view (mobile uses the card view).
- Cross-device sync of sidebar collapse/expand state or selected view (localStorage only).
- Removing
Habit::ICONS/Goal::ICONSconstants (view-level only; constants stay for now).
Data model
Single migration on notes:
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
emoji |
string | yes | null | Free-form emoji character (e.g. 📖). |
parent_id |
integer | yes | null | Self-FK, on_delete: :cascade. |
position |
integer | no | 0 | Per-sibling ordering. |
Indexes:
[user_id, parent_id, position](tree ordering queries)[parent_id](children lookup)
Note model additions
belongs_to :parent, class_name: "Note", optional: truehas_many :children, -> { order(:position) }, class_name: "Note", foreign_key: :parent_id, dependent: :destroyscope :roots, -> { where(parent_id: nil) }- Validation:
parent_idmust not equalidand must point to a root note (parent's ownparent_idmust benil) — enforces the 2-level depth cap. - Validation: if
parent_idis set, this note must itself have no children (would create grandchildren). - Validation: parent must belong to the same user.
- Permit params adds
:emoji,:parent_id.
Note::Mover (namespaced model class, app/models/note/mover.rb)
Handles drag-and-drop moves atomically.
Note::Mover.call(note:, parent_id:, position:)
- Authorizes same-user parent.
- Rejects: self-parenting, moves that would violate 2-level cap (moving a note that has children under another parent), cross-user parents.
- Resequences siblings under the old and new parent inside a transaction.
- Returns the updated note or raises.
Shared emoji picker
Files
app/helpers/emoji_catalog.rb— a module exposingEmojiCatalog::ALLas an array of hashes:{ emoji: "🎯", name: "target", keywords: "goal aim bullseye", category: "symbols" }~80–100 entries grouped in categories: Smileys, People, Activities, Objects, Food, Travel, Symbols, Nature. Must be a strict superset of the current
Habit::ICONS,Goal::ICONS, and the countdown inline list.-
app/views/shared/_emoji_picker.html.erb— partial with a trigger button (shows current emoji, or a+placeholder) and a popover containing a search input and a filtered grid. app/javascript/controllers/emoji_picker_controller.js— Stimulus controller:- Toggles the popover on trigger click.
- Filters grid by matching
nameandkeywords(case-insensitive substring). - Writes the selected emoji into a hidden form field and updates the trigger display.
- Closes on selection, outside click, or
Esc.
Partial API
<%= render "shared/emoji_picker", form: form, field: :emoji, current: @note.emoji %>
Adoption
Replace the inline icon/emoji radio grids in:
app/views/habits/_form.html.erbapp/views/goals/_form.html.erbapp/views/countdowns/_form.html.erbapp/views/notes/_form.html.erb(new usage)
Model-level ICONS constants are kept (no downstream impact on views) for now.
Notes index — view toggle & card view
Controller
NotesController#index loads both data shapes every request (view switch is client-side):
@roots— root notes (parent_id: nil), with children eager-loaded, pinned-first — for sidebar view.@notes— existing flat list for card view (unchanged).
View structure
header (title + view toggle + archive link + new button)
├─ cards container (data-notes-view-target="cards")
└─ sidebar container (data-notes-view-target="sidebar")
notes_view_controller.js
- On
connect:- If viewport ≤ 768 px (media query), forces
"cards"and ignores localStorage. - Otherwise reads
localStorage["notes.view"](default"sidebar").
- If viewport ≤ 768 px (media query), forces
- Toggles visibility via CSS classes.
- Persists on user toggle.
Toggle UI
Two small icon buttons in the header (cards icon, sidebar icon). Active one is highlighted.
Card view changes
Prefix each card's title with note.emoji (when present) inline before the <h3>. No structural changes.
Notes index — sidebar view
Layout (desktop, ≥768 px)
┌─ 280px sidebar ──┬─ main pane (flex-1) ───┐
│ [Search] │ │
│ ─ Pinned ▾ │ turbo-frame#note_pane │
│ 📌 Metas 2026 │ (renders notes/show │
│ ─ Notes ▾ │ or empty state) │
│ 📖 Book Notes │ │
│ └ Atomic Hab. │ │
│ └ Deep Work │ │
│ 📝 Weekly Rev. │ │
│ [+ New note] │ │
└──────────────────┴─────────────────────────┘
Partials
notes/_sidebar.html.erb— sidebar shell (search, pinned group, roots group, new-note button).notes/_tree_item.html.erb— recursive partial for one note + its children (disclosure arrow, emoji, title, hover-revealed+and⋯).notes/_pane.html.erb— turbo-frameid="note_pane"wrapping the existingshowcontent or an empty state.
Interactions
- Clicking a sidebar item navigates to
/notes/:idwithdata-turbo-frame="note_pane". Main pane updates; sidebar persists; URL and back button behave naturally. - Sidebar highlights the active item (compare
params[:id]). - Parents with children show a disclosure arrow; collapsed state stored in
localStoragekeyed by note id. - Empty pane state when no note is selected.
Search (GET /notes?q=<term>)
- New scope
Note.search(term)→ matchestitle ILIKE :qORaction_text_rich_texts.body ILIKE :q, viajoins(:rich_text_content). - Input in the sidebar debounced at 300 ms, Turbo-frame swap of the sidebar tree region only.
- When
qis present: show a flat filtered list (no hierarchy, no Pinned/Notes groups). Otherwise show the tree. - Title matches highlighted via
<mark>.
Per-item ⋯ menu (reuses dropdown_controller.js)
- Rename (opens existing edit modal)
- Pin / Unpin
- Archive
- Delete (with confirm)
Hover + on parent items
Opens the new-note modal with parent_id pre-filled.
Pinned section
- Displayed at the top of the sidebar, collapsible.
- Mirrors the current
pinned_firstordering. - Not drag-sortable — pin/unpin via the
⋯menu moves items in/out.
Drag-and-drop
Library: sortablejs (already installed; used in kanban_controller.js).
notes_tree_controller.js
Attached to the sidebar root. On connect:
- Initializes a
Sortableon the root list AND on each children list, all withgroup: "notes-tree"so items can move between lists. fallbackOnBody: trueand a smallemptyInsertThresholdso a parent can accept a first child.onMovecallback vetoes drops that would create a grandchild (dropping onto a list whose parent is itself a child).
onEnd handler
POSTs to the new endpoint:
POST /notes/:id/move
Body: { parent_id: <int or null>, position: <int> }
→ 204 No Content on success
→ 422 on validation failure; the controller reverts the DOM move
NotesController#move
- Authorizes with
NotePolicy#move?(same asupdate?: owner only). - Delegates to
Note::Mover.call(...). - Responds
head :no_contenton success,head :unprocessable_entityonActiveRecord::RecordInvalid.
2-level cap enforcement
- Root → can become a child of another root, or reorder at root.
- Child → can reorder within its parent, move between parents, or become a root.
- A child with no descendants cannot be dropped onto another child (creates a grandchild). Blocked in
onMoveclient-side and inNote::Moverserver-side. - A root WITH children cannot become a child of another root (would create a grandchild transitively). Same enforcement.
Routes
resources :notes do
member do
post :toggle_pin
post :archive
post :move # new
end
end
Authorization
NotePolicy#move? → owner? (alias of update?).
Testing
Minitest, per project convention.
test/models/note_test.rb- Emoji persistence.
- Parent/child associations.
- Cycle prevention (note cannot be its own parent).
- Depth cap: rejecting grandchildren.
destroycascades to children.
test/models/note/mover_test.rb- Reorder within parent.
- Move between parents.
- Move to root.
- Reject cross-user parent.
- Reject self-parenting.
- Reject depth violations (both directions).
- Resequencing correctness under/around the move.
test/controllers/notes_controller_test.rb#indexunchanged for card view consumers.#movehappy path → 204; unauthorized → 403; invalid → 422.?q=<term>filters by title and by rich-text body.
test/system/notes_test.rb- Toggle flips views; localStorage persists selection across reloads.
- Emoji renders in sidebar item and as card prefix.
- Sidebar item click updates the main pane without a full reload.
- Search filters the tree and highlights matches.
- (Drag-and-drop can be stubbed via a direct POST to
/notes/:id/moverather than simulating mouse events.)
i18n
Add keys to config/locales/en.yml and config/locales/pt.yml:
notes.sidebar.search_placeholdernotes.sidebar.pinnednotes.sidebar.notesnotes.sidebar.new_notenotes.sidebar.empty_panenotes.toggle.cardsnotes.toggle.sidebarshared.emoji_picker.search_placeholdershared.emoji_picker.no_resultsshared.emoji_picker.remove
Seeds
Update db/seeds.rb notes block:
- Add
emojito each seeded note. - Create one parent-with-children group (e.g. a "Book Notes" root with "Atomic Habits" and "Deep Work" as children) so the sidebar view has content to demonstrate.
Out of scope (explicit)
- Inline (click-to-edit) main-pane editor.
- Grandchild nesting (2-level cap is intentional).
- Mobile split-view.
- Cross-device sync of view preference or collapse state.
- Retiring
Habit::ICONS/Goal::ICONSmodel constants.