<% if allowed_to?(@plan, :publish?) || allowed_to?(@plan, :hide?) %>
<%# The visibility eye: open eye = shared with everyone, slashed = Private.
@@ -89,8 +111,10 @@
<%# The document's back matter, one hop below the outline — slightly
set apart so it reads as "after the document", not part of it. %>
@@ -99,21 +123,29 @@
re-run anchor highlighting once each diagram settles so comments
anchored to diagram labels get their marks (and pending ?thread=
deep links can resolve). %>
-
-
- <%= render partial: "coplan/plans/content_body", locals: { plan: @plan } %>
-
+
+ <%# Selection-to-comment is scoped to this inner wrapper: the back
+ matter below shares the column but must not offer comment
+ anchors — they'd never resolve against the document body. %>
+
+
+ <%= render partial: "coplan/plans/content_body", locals: { plan: @plan } %>
+
+
+
-
- <%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %>
+ <%= render partial: "coplan/plans/footnotes",
+ locals: { plan: @plan, references: @references, attachments: @attachments } %>
This plan has no content yet.
+ <%# No content, no layout columns — back matter still belongs on the page. %>
+ <%= render partial: "coplan/plans/footnotes",
+ locals: { plan: @plan, references: @references, attachments: @attachments } %>
<% end %>
-<%# Back matter: references and attachments live under the document like
- footnotes — same page, quieter surface, each with a one-click "+". %>
-
-
<%= render partial: "coplan/shared/folder_picker", locals: { folders: @my_folders } %>
<%# Headless comment navigation — no floating toolbar, just the keyboard:
diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb
index 311e385..ede01f2 100644
--- a/engine/app/views/layouts/coplan/application.html.erb
+++ b/engine/app/views/layouts/coplan/application.html.erb
@@ -93,6 +93,10 @@
<% end %>
<%= yield %>
+ <%# Toast rail: client JS and server Turbo Streams both land feedback
+ here (bottom-right), so in-place actions never scroll the page to a
+ top-of-page flash. %>
+
<%= render "coplan/shared/web_push_banner" %>
<% if signed_in? %>
<%= render "coplan/search/modal" %>
diff --git a/spec/models/folder_spec.rb b/spec/models/folder_spec.rb
index ed824da..db4a3c9 100644
--- a/spec/models/folder_spec.rb
+++ b/spec/models/folder_spec.rb
@@ -204,5 +204,28 @@
other.id => "Infra"
)
end
+
+ it "renders the full path for hierarchies deeper than MAX_DEPTH" do
+ # Creation is capped at MAX_DEPTH, but data deeper than the cap (seeds,
+ # imports, a future cap raise) must still render its whole path — the
+ # guard in the walk is for cycles, not depth.
+ parent = nil
+ chain = ("A".."F").map do |name|
+ folder = described_class.new(name: name, parent: parent, library: library, created_by_user: user)
+ folder.save!(validate: false)
+ parent = folder
+ end
+
+ expect(described_class.paths_by_id[chain.last.id]).to eq("A/B/C/D/E/F")
+ end
+
+ it "does not loop forever on cyclic bad data" do
+ a = create(:folder, name: "A", created_by_user: user)
+ b = create(:folder, name: "B", parent: a, created_by_user: user)
+ a.update_columns(parent_id: b.id)
+
+ paths = described_class.paths_by_id
+ expect(paths[b.id]).to eq("A/B")
+ end
end
end
diff --git a/spec/requests/attachments_spec.rb b/spec/requests/attachments_spec.rb
index 8737568..090d294 100644
--- a/spec/requests/attachments_spec.rb
+++ b/spec/requests/attachments_spec.rb
@@ -25,6 +25,26 @@
expect(event.actor_id).to eq(author.id)
end
+ it "swaps the attachments section in place (no redirect) for Turbo requests" do
+ sign_in_as(author)
+
+ post plan_attachments_path(plan),
+ params: { files: [ fixture_file_upload("sample.png", "image/png") ] },
+ headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
+
+ # In-place UX: replace #plan-attachments where it stands + a toast —
+ # never a full-page redirect that scrolls the reader to the top.
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include('target="plan-attachments"')
+ expect(response.body).to include('target="coplan-toasts"')
+ # Both visible counts update too: the footnote header and the outline.
+ expect(response.body).to include('target="attachments-count"')
+ expect(response.body).to include('target="nav-attachments-count"')
+ expect(response.body).to include("1 file uploaded")
+ expect(response.body).to include("sample.png")
+ end
+
it "surfaces per-file validation errors" do
sign_in_as(author)
@@ -85,6 +105,21 @@
expect(event.before_value).to eq("sample.png")
end
+ it "removes in place (no redirect) for Turbo requests" do
+ sign_in_as(author)
+ attachment = plan.attachments_attachments.first
+
+ delete plan_attachment_path(plan, attachment),
+ headers: { "Accept" => "text/vnd.turbo-stream.html, text/html" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(response.body).to include('target="plan-attachments"')
+ expect(response.body).to include('target="attachments-count"')
+ expect(response.body).to include('target="nav-attachments-count"')
+ expect(response.body).to include("Attachment removed.")
+ end
+
it "rejects deletes from non-authors" do
sign_in_as(viewer)
attachment = plan.attachments_attachments.first
diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb
index 7a48e1d..5fec026 100644
--- a/spec/requests/plans_spec.rb
+++ b/spec/requests/plans_spec.rb
@@ -41,12 +41,14 @@
expect(response.body).not_to include("Other Plan")
end
- it "index shows tag badges on plan cards" do
+ it "index lists tags in the sidebar, not as chips on the rows" do
plan.tag_names = [ "infra", "api" ]
get plans_path
- expect(response.body).to include("badge--tag")
- expect(response.body).to include("infra")
- expect(response.body).to include("api")
+ expect(response.body).to include("#infra")
+ expect(response.body).to include("#api")
+ # Row tag chips were dropped: they crowded the byline off its corner
+ # and the sidebar already filters by tag.
+ expect(response.body).not_to include("badge--tag")
end
it "index shows active tag filter bar" do
@@ -850,7 +852,7 @@ def alice_placement
}.not_to change(CoPlan::PlanEvent, :count)
end
- it "renders drag handles and save bookmarks on every row" do
+ it "renders every row draggable into the viewer's library" do
plan # alice's plan
bobs_plan = create(:plan, :considering, created_by_user: bob, title: "Bobs Plan")
get plans_path(scope: "all")
@@ -860,9 +862,11 @@ def alice_placement
# Anyone can shelve any visible plan into their own library.
expect(alice_row).to include('draggable="true"')
expect(bob_row).to include('draggable="true"')
- expect(response.body.scan("plan-row__save").size).to be >= 2
- # One shared navigator popover per page, with the folder tree inside.
- expect(response.body.scan('id="folder-picker-modal"').size).to eq(1)
+ # No per-row bookmark (and so no navigator popover) on the workspace:
+ # rows file via drag & drop; the plan page's title bookmark is the
+ # click path.
+ expect(response.body).not_to include("plan-row__save")
+ expect(response.body).not_to include('id="folder-picker-modal"')
end
end
diff --git a/spec/system/checkbox_spec.rb b/spec/system/checkbox_spec.rb
index 66aee78..21d9dab 100644
--- a/spec/system/checkbox_spec.rb
+++ b/spec/system/checkbox_spec.rb
@@ -130,6 +130,11 @@ def sign_in(user)
first_cb = all('input[type="checkbox"]')[0]
first_cb.click
wait_for_version(plan, 2)
+ # The version bump broadcasts a whole-body re-render (Broadcaster
+ # .replace_plan_content) that the DB poll above can't see. Grab the
+ # second checkbox only after the swap lands, or the handle can detach
+ # between find and click.
+ wait_for_content_refresh(2)
second_cb = all('input[type="checkbox"]')[1]
second_cb.click
@@ -187,6 +192,15 @@ def sign_in(user)
private
+ # The live-update broadcast replaces the children of #plan-content-body and
+ # stamps the new revision on it — waiting for the stamp is the only
+ # race-free way to know the swap has already happened.
+ def wait_for_content_refresh(revision)
+ expect(page).to have_css(
+ "#plan-content-body[data-coplan--live-update-revision-value='#{revision}']"
+ )
+ end
+
def wait_for_version(plan, expected_revision, timeout: 5)
Timeout.timeout(timeout) do
loop do
diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb
index f77598f..01788b3 100644
--- a/spec/system/folders_workspace_spec.rb
+++ b/spec/system/folders_workspace_spec.rb
@@ -48,6 +48,31 @@ def sign_in(user)
expect(page).to have_css(".plan-row[data-plan-id='#{developing_plan.id}']")
end
+ it "returns to the folder you came from with Backspace on a plan page" do
+ # Turbo navigations never update document.referrer, so this relies on
+ # the controller's own in-app visit tracking — a regression here sends
+ # Backspace to the workspace root, losing your place.
+ visit plans_path
+ find(".folder-row", text: "Team EBT").click
+ find(".folder-row", text: "Q3").click
+ find(".plan-row", text: "Q3 Launch Plan").click
+ expect(page).to have_css("h1", text: "Q3 Launch Plan")
+
+ find("body").send_keys(:backspace)
+ expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Q3")
+ end
+
+ it "falls back to the plan's folder on Backspace after a cold open" do
+ # Direct visit = no in-app history. Backspace should land where the
+ # plan lives in the viewer's library, not the workspace root.
+ visit plan_path(foldered_plan)
+ expect(page).to have_css("h1", text: "Q3 Launch Plan")
+
+ find("body").send_keys(:backspace)
+ expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Q3")
+ expect(page).to have_current_path(plans_path(folder: q3.id))
+ end
+
it "quietly flags private plans in the level view" do
visit plans_path
row = find(".plan-row[data-plan-id='#{brainstorm_plan.id}']")
@@ -98,7 +123,7 @@ def sign_in(user)
end
it "filters by tag from the sidebar" do
- developing_plan.tag_names = ["security"]
+ developing_plan.tag_names = [ "security" ]
visit plans_path
within(".workspace__sidebar") { click_link "#security" }
@@ -182,44 +207,134 @@ def sign_in(user)
expect(infra.reload.parent).to eq(team)
end
- it "files a plan via the row bookmark's folder navigator" do
- visit plans_path
-
- row = find(".plan-row[data-plan-id='#{developing_plan.id}']")
- row.find(".plan-row__save", visible: :all).click
+ it "saves from the plan page via the bookmark beside the title" do
+ visit plan_path(developing_plan)
+ # The bookmark mounts into the title row (it's stamped client-side —
+ # the broadcast-replaced header can't render viewer state itself).
+ # It's also the only click path into the folder navigator: workspace
+ # rows file via drag & drop and carry no bookmark of their own.
+ find(".page-header__title-row .plan-bookmark").click
within("#folder-picker-modal") do
# The tree is hierarchical: Q3 nests under Team EBT.
expect(page).to have_css(".folder-picker__tree--nested .folder-picker__name", text: "Q3")
- find(".folder-picker__option", text: "Q3").click
+ find(".folder-picker__option", text: "Infra").click
end
- expect(page).to have_css(".flash--notice", text: "Team EBT/Q3", wait: 5)
- expect(author.library.placements.find_by(plan_id: developing_plan.id).folder).to eq(q3)
+ expect(page).to have_css(".flash--notice", text: "Infra", wait: 5)
+ expect(author.library.placements.find_by(plan_id: developing_plan.id).folder).to eq(infra)
+ # After the reload the bookmark reads as saved.
+ expect(page).to have_css(".plan-bookmark--saved")
end
it "unsaves a filed plan with a second bookmark click — no dialog, no toast" do
CoPlan::Plans::Place.call(plan: developing_plan, folder: q3, actor: author)
- visit plans_path(folder: q3.id)
+ visit plan_path(developing_plan)
- row = find(".plan-row[data-plan-id='#{developing_plan.id}']")
- row.find(".plan-row__save--saved", visible: :all).click
+ find(".page-header__title-row .plan-bookmark--saved").click
# The bookmark just lets go: no navigator, no confirmation, no toast —
- # the page re-renders and the plan is out of the folder.
+ # the page re-renders with the bookmark back in its unsaved state.
expect(page).not_to have_css("#folder-picker-modal:popover-open")
- expect(page).not_to have_css(".plan-row[data-plan-id='#{developing_plan.id}']", wait: 5)
+ expect(page).to have_css(".page-header__title-row .plan-bookmark:not(.plan-bookmark--saved)", wait: 5)
expect(page).not_to have_css(".flash--notice")
expect(author.library.placements.where(plan_id: developing_plan.id)).to be_empty
end
- it "offers save controls on other users' plans too (shelving)" do
+ it "offers shelving on other users' plans too" do
other_plan = create(:plan, :considering, created_by_user: other, title: "Someone Elses Plan")
visit plans_path(scope: "all")
+ # The row drags into your library like any of your own…
row = find(".plan-row[data-plan-id='#{other_plan.id}']")
expect(row["draggable"]).to eq("true")
- expect(row).to have_css(".plan-row__save", visible: :all)
+
+ # …and the plan page offers the bookmark.
+ visit plan_path(other_plan)
+ expect(page).to have_css(".page-header__title-row .plan-bookmark")
+ end
+ end
+
+ describe "spring-loaded folders" do
+ let!(:q3sub) { create(:folder, name: "Q3 Sub", parent: q3, created_by_user: author) }
+
+ # Capybara's drag_to is atomic — no way to hover mid-drag — so these
+ # drive the controller with synthetic DragEvents sharing one
+ # DataTransfer, exactly the objects the real drag hands it.
+ def fire_drag_event(element, type)
+ page.execute_script(<<~JS, element)
+ if (#{(type == "dragstart").to_json}) window.__springDT = new DataTransfer()
+ arguments[0].dispatchEvent(new DragEvent(#{type.to_json}, {
+ bubbles: true, cancelable: true, dataTransfer: window.__springDT
+ }))
+ JS
+ end
+
+ it "springs a collapsed sidebar branch open after a hover, and shut when the drag moves away" do
+ visit plans_path
+
+ row = find(".plan-row[data-plan-id='#{developing_plan.id}']")
+ branch_link = find(".folder-tree__link", text: "Team EBT")
+
+ # Q3 is buried in a collapsed
branch.
+ expect(page).not_to have_css(".folder-tree__link", text: "Q3")
+
+ fire_drag_event(row, "dragstart")
+ fire_drag_event(branch_link, "dragover")
+
+ # Two pulses first, then the branch springs open (650ms).
+ expect(branch_link[:class]).to include("dnd-spring")
+ expect(page).to have_css(".folder-tree__branch[open] .folder-tree__link", text: "Q3", wait: 2)
+
+ # Drag away — the sprung branch snaps shut ("temporarily there").
+ fire_drag_event(find(".folder-tree__link", text: "Infra"), "dragover")
+ expect(page).not_to have_css(".folder-tree__link", text: "Q3")
+
+ fire_drag_event(row, "dragend")
+ end
+
+ it "tunnels the pane into a hovered folder, level by level, and files a dead-space drop right there" do
+ visit plans_path
+
+ row = find(".plan-row[data-plan-id='#{developing_plan.id}']")
+ fire_drag_event(row, "dragstart")
+ fire_drag_event(find(".folder-row", text: "Team EBT"), "dragover")
+
+ # Two pulses later the pane IS Team EBT's level view — real crumbs,
+ # real rows — while the drag is still in flight.
+ expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Team EBT", wait: 4)
+ expect(page).to have_css(".folder-row", text: "Q3")
+
+ # Keep diving: hover Q3 to tunnel one level deeper.
+ fire_drag_event(find(".folder-row", text: "Q3"), "dragover")
+ expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Q3", wait: 4)
+ expect(page).to have_css(".folder-row", text: "Q3 Sub")
+
+ # Dead space in the pane files into the folder you're looking at.
+ fire_drag_event(find(".workspace__main"), "drop")
+ fire_drag_event(row, "dragend")
+
+ expect(page).to have_css(".flash--notice", wait: 5)
+ expect(author.library.placements.find_by(plan_id: developing_plan.id).folder).to eq(q3)
+ # The drop leaves you where you dropped — inside Q3, for real.
+ expect(page).to have_current_path(plans_path(folder: q3.id), wait: 5)
+ end
+
+ it "restores the original pane when a tunneled drag ends without a drop" do
+ visit plans_path
+
+ row = find(".plan-row[data-plan-id='#{developing_plan.id}']")
+ fire_drag_event(row, "dragstart")
+ fire_drag_event(find(".folder-row", text: "Team EBT"), "dragover")
+ expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Team EBT", wait: 4)
+
+ # Abandon the drag: everything snaps back — root crumb, root rows,
+ # and the dragged row itself.
+ fire_drag_event(row, "dragend")
+ expect(page).to have_css(".workspace-crumbs__crumb--current", text: "My Plans")
+ expect(page).to have_css(".folder-row", text: "Team EBT")
+ expect(page).to have_css(".plan-row[data-plan-id='#{developing_plan.id}']")
+ expect(author.library.placements.where(plan_id: developing_plan.id)).to be_empty
end
end
diff --git a/spec/system/references_spec.rb b/spec/system/references_spec.rb
index 1362262..a396419 100644
--- a/spec/system/references_spec.rb
+++ b/spec/system/references_spec.rb
@@ -31,7 +31,9 @@ def open_add_reference_modal
visit plan_path(plan)
expect(page).to have_content("Some content here")
- expect(page).to have_content("No references yet")
+ # Empty state is one quiet line, scoped to the section (attachments
+ # has its own "None yet").
+ expect(page).to have_css("#footnote-references .plan-footnote__empty", text: "None yet")
expect(page).to have_css("#footnote-references .plan-footnote__title", text: /references/i)
end
end
@@ -66,10 +68,12 @@ def open_add_reference_modal
# Turbo Stream replaces the list — reference appears without navigation
expect(page).to have_link("My Repo", href: "https://github.com/org/repo")
- expect(page).not_to have_content("No references yet")
+ expect(page).not_to have_css("#footnote-references .plan-footnote__empty")
- # Count span updated in-place via Turbo Stream (separate stream target)
+ # Count spans updated in-place via Turbo Stream (separate stream
+ # targets) — the footnote header and the document outline.
expect(page).to have_css("#references-count", text: "1")
+ expect(page).to have_css("#nav-references-count", text: "1")
# The document is still right there — same page, no tabs.
expect(page).to have_content("Some content here")
@@ -119,7 +123,7 @@ def open_add_reference_modal
# Turbo Stream removes the reference and updates count
expect(page).not_to have_content("Doomed")
expect(page).to have_css("#references-count", text: "0")
- expect(page).to have_content("No references yet")
+ expect(page).to have_css("#footnote-references .plan-footnote__empty", text: "None yet")
end
end