Skip to content

Commit 433b9fc

Browse files
committed
Reworked how notes work
TODO: clean this up
1 parent 6dab1e1 commit 433b9fc

File tree

10 files changed

+203
-87
lines changed

10 files changed

+203
-87
lines changed

app/controllers/notes_controller.rb

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,48 @@ class NotesController < ApplicationController
44
before_action :require_authentication
55
before_action :set_note, only: [:update]
66

7+
def new
8+
topic = Topic.find(params[:topic_id])
9+
message = resolve_message(topic)
10+
respond_to do |format|
11+
format.turbo_stream { render_note_stack_stream(topic:, message:, open_form: true) }
12+
format.html { redirect_to topic_path(topic, anchor: message ? note_frame_id(message) : "thread-notes") }
13+
end
14+
end
15+
16+
def stack
17+
topic = Topic.find(params[:topic_id])
18+
message = resolve_message(topic)
19+
respond_to do |format|
20+
format.turbo_stream { render_note_stack_stream(topic:, message:, open_form: false) }
21+
format.html { redirect_to topic_path(topic, anchor: message ? note_frame_id(message) : "thread-notes") }
22+
end
23+
end
24+
725
def create
826
topic = Topic.find(note_params[:topic_id])
927
message = resolve_message(topic)
1028
note = NoteBuilder.new(author: current_user).create!(topic:, message:, body: note_params[:body])
1129

12-
redirect_to topic_path(topic, anchor: note_anchor(note)), notice: "Note added"
30+
respond_to do |format|
31+
format.html { redirect_to topic_path(topic, anchor: note_anchor(note)), notice: "Note added" }
32+
format.turbo_stream { render_note_stack_stream(topic:, message:, open_form: false) }
33+
end
1334
rescue NoteBuilder::Error, ActiveRecord::RecordInvalid => e
14-
flash[:alert] = e.message
15-
flash[:note_error] = {
16-
body: note_params[:body],
17-
message_id: note_params[:message_id].presence,
18-
topic_id: note_params[:topic_id],
19-
error: e.message
20-
}
21-
redirect_back fallback_location: topic_path(topic)
35+
handle_note_error(topic:, message:, error: e)
2236
end
2337

2438
def update
2539
return if performed?
2640

2741
NoteBuilder.new(author: current_user).update!(note: @note, body: note_params[:body])
2842

29-
redirect_to topic_path(@note.topic, anchor: note_anchor(@note)), notice: "Note updated"
43+
respond_to do |format|
44+
format.html { redirect_to topic_path(@note.topic, anchor: note_anchor(@note)), notice: "Note updated" }
45+
format.turbo_stream { render_note_stack_stream(topic: @note.topic, message: @note.message, open_form: false) }
46+
end
3047
rescue NoteBuilder::Error, ActiveRecord::RecordInvalid => e
31-
flash[:alert] = e.message
32-
flash[:note_error] = {
33-
body: note_params[:body],
34-
message_id: note_params[:message_id].presence,
35-
topic_id: note_params[:topic_id],
36-
note_id: @note.id,
37-
error: e.message
38-
}
39-
redirect_back fallback_location: topic_path(@note.topic)
48+
handle_note_error(topic: @note.topic, message: @note.message, error: e, note: @note)
4049
end
4150

4251
private
@@ -54,8 +63,9 @@ def note_params
5463
end
5564

5665
def resolve_message(topic)
57-
return nil if note_params[:message_id].blank?
58-
topic.messages.find(note_params[:message_id])
66+
msg_id = params[:message_id].presence || params.dig(:note, :message_id)
67+
return nil if msg_id.blank?
68+
topic.messages.find_by(id: msg_id)
5969
end
6070

6171
def note_anchor(note)
@@ -65,4 +75,47 @@ def note_anchor(note)
6575
"thread-notes"
6676
end
6777
end
78+
79+
def note_frame_id(message)
80+
message ? "notes-message-#{message.id}" : "thread-notes"
81+
end
82+
83+
def note_collection(topic:, message:)
84+
Note.where(topic:, message:).includes(:author, :note_tags, :note_mentions).order(:created_at)
85+
end
86+
87+
def render_note_stack_stream(topic:, message:, open_form:, status: :ok)
88+
notes = note_collection(topic:, message:)
89+
render(
90+
turbo_stream: turbo_stream.replace(
91+
note_frame_id(message),
92+
partial: "notes/note_stack",
93+
locals: { topic:, message:, notes:, open_form: }
94+
),
95+
status: status
96+
)
97+
end
98+
99+
def handle_note_error(topic:, message:, error:, note: nil)
100+
flash_payload = {
101+
body: note_params[:body],
102+
message_id: note_params[:message_id].presence,
103+
topic_id: note_params[:topic_id],
104+
note_id: note&.id,
105+
error: error.message
106+
}
107+
108+
respond_to do |format|
109+
format.html do
110+
flash[:alert] = error.message
111+
flash[:note_error] = flash_payload
112+
redirect_back fallback_location: topic_path(topic)
113+
end
114+
format.turbo_stream do
115+
flash.now[:alert] = error.message
116+
flash.now[:note_error] = flash_payload
117+
render_note_stack_stream(topic:, message:, open_form: true, status: :unprocessable_entity)
118+
end
119+
end
120+
end
68121
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
import { Turbo } from "@hotwired/turbo-rails"
3+
4+
export default class extends Controller {
5+
static values = {
6+
url: String
7+
}
8+
9+
async load(event) {
10+
event.preventDefault()
11+
if (!this.hasUrlValue) return
12+
13+
const response = await fetch(this.urlValue, {
14+
headers: { Accept: "text/vnd.turbo-stream.html" }
15+
})
16+
if (response.ok) {
17+
const html = await response.text()
18+
Turbo.renderStreamMessage(html)
19+
}
20+
}
21+
22+
close(event) {
23+
event.preventDefault()
24+
const formWrapper = this.element.closest(".note-form-wrapper")
25+
if (formWrapper) {
26+
const container = formWrapper.closest(".notes-block")
27+
formWrapper.remove()
28+
if (container && container.querySelectorAll(".note-card").length === 0 && !container.querySelector(".note-form-wrapper")) {
29+
container.remove()
30+
}
31+
return
32+
}
33+
34+
const details = this.element.closest("details")
35+
if (details && details.open) {
36+
details.open = false
37+
}
38+
}
39+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
- note_dom_id = message ? "notes-message-#{message.id}" : "thread-notes"
2+
- button_classes = ["note-add-link"]
3+
4+
= button_tag "Add note",
5+
type: "button",
6+
class: button_classes.join(" "),
7+
data: { controller: "note-loader",
8+
action: "click->note-loader#load",
9+
note_loader_url_value: new_note_path(topic_id: topic.id, message_id: message&.id, format: :turbo_stream) }

app/views/notes/_form.html.slim

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@
1818
.note-error = note_error[:error]
1919
.note-actions
2020
= f.submit submit_label, class: "button-secondary"
21+
= button_tag "Cancel", type: "button", class: "button-secondary",
22+
data: { controller: "note-loader",
23+
action: "click->note-loader#close" }
Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,19 @@
11
- notes ||= []
22
- note_dom_id = message ? "notes-message-#{message.id}" : "thread-notes"
3-
- notes ||= []
4-
- note_error = flash[:note_error]&.with_indifferent_access
5-
- open_form = note_error && note_error[:topic_id].to_i == topic.id && note_error[:message_id].to_s == message&.id.to_s
6-
- show_full = notes.any? || open_form
7-
- note_dom_id = message ? "notes-message-#{message.id}" : "thread-notes"
3+
- open_form ||= false
4+
5+
= turbo_frame_tag note_dom_id do
6+
- if open_form || notes.any?
7+
.notes-block id=note_dom_id
8+
- if open_form
9+
.note-form-wrapper
10+
= render "notes/form", topic: topic, message: message
811

9-
- if show_full
10-
.notes-block id=note_dom_id
11-
- if notes.any?
12-
.notes-header
13-
- title_text = message ? "Notes on this message" : "Thread notes"
14-
h4 = title_text
15-
span.notes-count = "#{notes.size} note#{'s' if notes.size != 1}"
16-
.notes-list
17-
- notes.each do |note|
18-
= render "notes/note", note: note
19-
- if user_signed_in? && topic
20-
details.note-form-toggle open=(open_form ? true : nil)
21-
summary Add note
22-
= render "notes/form", topic: topic, message: message
23-
- elsif user_signed_in? && topic
24-
.notes-inline-add id=note_dom_id
25-
details.note-form-toggle
26-
summary Add note
27-
= render "notes/form", topic: topic, message: message
12+
- if notes.any?
13+
.notes-header
14+
- title_text = message ? "Notes on this message" : "Thread notes"
15+
h4 = title_text
16+
span.notes-count = "#{notes.size} note#{'s' if notes.size != 1}"
17+
.notes-list
18+
- notes.each do |note|
19+
= render "notes/note", note: note

app/views/topics/_message.html.slim

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
- if message.attachments.any?
3030
.attachment-indicator
3131
span.attachment-count #{message.attachments.size} attachment(s)
32+
- if user_signed_in?
33+
.note-actions-inline
34+
= render "notes/add_button", topic: @topic, message: message
3235

3336
.message-content class=read_classes.join(" ") data=read_data
3437
- if message.subject != @topic.title && message.subject.present?
@@ -51,7 +54,5 @@
5154
details
5255
summary Import Notes
5356
pre.import-log = message.import_log
54-
5557
- notes_for_message = @notes_by_message&.[](message.id) || []
56-
- if user_signed_in?
57-
= render "notes/note_stack", topic: @topic, message: message, notes: notes_for_message
58+
= render "notes/note_stack", topic: @topic, message: message, notes: notes_for_message, open_form: false

app/views/topics/show.html.slim

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@
9595
span.message-count #{@messages.size} messages
9696

9797
- if user_signed_in?
98-
- thread_notes = @notes_by_message&.[](:thread) || []
9998
.thread-notes-container
100-
= render "notes/note_stack", topic: @topic, message: nil, notes: thread_notes
99+
.note-actions-inline
100+
= render "notes/add_button", topic: @topic, message: nil
101+
- thread_notes = @notes_by_message&.[](:thread) || []
102+
= render "notes/note_stack", topic: @topic, message: nil, notes: thread_notes, open_form: false
101103

102104
.messages-container class=(["messages-container", @view_mode, (@sort_order == :desc ? "desc" : "asc")].compact.join(" "))
103105
- if user_signed_in?

config/routes.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@
3535
resources :activities, only: [:index] do
3636
post :mark_all_read, on: :collection
3737
end
38-
resources :notes, only: [:create, :update]
38+
resources :notes, only: [:create, :update, :new] do
39+
collection do
40+
get :stack
41+
end
42+
end
3943

4044
# Authentication
4145
resource :session, only: [:new, :create, :destroy]

spec/requests/notes_turbo_spec.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
require "rails_helper"
2+
3+
RSpec.describe "Turbo note interactions", type: :request do
4+
let(:user) { create(:user, password: "password", password_confirmation: "password", username: "tester") }
5+
let(:alias_record) { create(:alias, :primary, user:, verified_at: Time.current) }
6+
let(:topic) { create(:topic, creator: alias_record) }
7+
let(:message) { create(:message, topic:, sender: alias_record) }
8+
9+
before do
10+
alias_record # ensure alias exists
11+
post session_path, params: { email: alias_record.email, password: "password" }
12+
expect(response).to redirect_to(root_path)
13+
end
14+
15+
it "renders nothing for notes on show page without notes until clicked" do
16+
get topic_path(topic)
17+
expect(response.body).to include("Add note")
18+
expect(response.body).not_to include("note-textarea")
19+
end
20+
21+
it "renders the note form via turbo stream for thread notes" do
22+
get new_note_path(topic_id: topic.id, format: :turbo_stream)
23+
24+
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
25+
expect(response.body).to include("turbo-stream", "note-form-wrapper")
26+
end
27+
28+
it "creates a thread note via turbo stream and renders the updated stack" do
29+
post notes_path, params: { note: { topic_id: topic.id, body: "Thread note body" } }, headers: { "ACCEPT" => "text/vnd.turbo-stream.html" }
30+
31+
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
32+
expect(response.body).to include("Thread note body")
33+
end
34+
35+
it "creates a message note via turbo stream and renders the updated stack" do
36+
post notes_path, params: { note: { topic_id: topic.id, message_id: message.id, body: "Message note body" } }, headers: { "ACCEPT" => "text/vnd.turbo-stream.html" }
37+
38+
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
39+
expect(response.body).to include("Message note body")
40+
end
41+
end

spec/requests/topics_spec.rb

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -73,51 +73,23 @@
7373
expect(response.body).to include(reply_message.body)
7474
end
7575

76-
it "shows threaded view by default" do
76+
it "shows flat view by default (oldest first)" do
7777
get topic_path(topic)
78-
expect(response.body).to include('messages-container threaded')
79-
end
80-
81-
it "has active Threaded button" do
82-
get topic_path(topic)
83-
expect(response.body).to include('class="toggle-btn active"')
84-
end
85-
86-
it "has active Oldest First button" do
87-
get topic_path(topic)
88-
expect(response.body).to include('class="toggle-btn active"')
89-
end
90-
end
91-
92-
context "with flat view mode" do
93-
it "shows flat view" do
94-
get topic_path(topic, view: 'flat')
95-
expect(response).to have_http_status(:success)
9678
expect(response.body).to include('messages-container flat')
97-
end
98-
99-
it "has active Flat button" do
100-
get topic_path(topic, view: 'flat')
101-
expect(response.body).to include('toggle-btn active')
79+
# root should appear before reply
80+
root_position = response.body.index(root_message.body)
81+
reply_position = response.body.index(reply_message.body)
82+
expect(root_position).to be < reply_position
10283
end
10384
end
10485

10586
context "with descending sort" do
106-
it "shows newest first" do
87+
it "still shows oldest first (descending disabled)" do
10788
get topic_path(topic, view: 'flat', sort: 'desc')
10889
expect(response).to have_http_status(:success)
109-
# Check that reply message appears before root message in HTML
110-
reply_position = response.body.index(reply_message.body)
11190
root_position = response.body.index(root_message.body)
112-
expect(reply_position).to be < root_position
113-
end
114-
end
115-
116-
context "with invalid view mode" do
117-
it "defaults to threaded" do
118-
get topic_path(topic, view: 'invalid')
119-
expect(response).to have_http_status(:success)
120-
expect(response.body).to include('messages-container threaded')
91+
reply_position = response.body.index(reply_message.body)
92+
expect(root_position).to be < reply_position
12193
end
12294
end
12395

0 commit comments

Comments
 (0)