-
- <%= link_to "Mine", plans_path(params.permit(:status, :plan_type, :tag)),
- class: "status-filter #{'status-filter--active' if @scope == 'mine'}" %>
- <%= link_to "All", plans_path(params.permit(:status, :plan_type, :tag).merge(scope: "all")),
- class: "status-filter #{'status-filter--active' if @scope == 'all'}" %>
-
- <%= link_to "Any status", plans_path(params.permit(:scope, :plan_type, :tag)),
- class: "status-filter #{'status-filter--active' if params[:status].blank?}" %>
- <% CoPlan::Plan::STATUSES.each do |status| %>
- <%= link_to status.titleize, plans_path(params.permit(:scope, :plan_type, :tag).merge(status: status)),
- class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %>
+
+ <%= render "coplan/plans/sidebar" %>
+
+
+ <% active_filters = [] %>
+ <% if @folder %>
+ <% active_filters << ["Folder: #{folder_path_for(@folder)}", plans_path(params.permit(:scope, :status, :plan_type, :tag))] %>
+ <% end %>
+ <% if params[:tag].present? %>
+ <% active_filters << ["Tag: #{params[:tag]}", plans_path(params.permit(:scope, :status, :plan_type, :folder))] %>
+ <% end %>
+ <% if params[:plan_type].present? %>
+ <% type_name = @plan_types&.find { |pt| pt.id == params[:plan_type] }&.name || "Type" %>
+ <% active_filters << ["Type: #{type_name}", plans_path(params.permit(:scope, :status, :tag, :folder))] %>
+ <% end %>
+ <% if params[:status].present? %>
+ <% active_filters << ["Status: #{params[:status].titleize}", plans_path(params.permit(:scope, :plan_type, :tag, :folder))] %>
<% end %>
-
- <% if @plan_types.any? %>
-
- <%= link_to "Any type", plans_path(params.permit(:scope, :status, :tag)),
- class: "status-filter #{'status-filter--active' if params[:plan_type].blank?}" %>
- <% @plan_types.each do |pt| %>
- <%= link_to pt.name, plans_path(params.permit(:scope, :status, :tag).merge(plan_type: pt.id)),
- class: "status-filter #{'status-filter--active' if params[:plan_type] == pt.id}" %>
- <% end %>
-
- <% end %>
+ <% if active_filters.any? %>
+
+ <% active_filters.each do |label, clear_path| %>
+
+ <%= label %>
+ <%= link_to "✕", clear_path, class: "workspace__filter-clear", aria: { label: "Clear #{label} filter" } %>
+
+ <% end %>
+ <%= link_to "Clear all", plans_path(@scope == "mine" ? {} : { scope: @scope }), class: "active-filter__clear" %>
+
+ <% end %>
- <% if params[:tag].present? %>
-
- Filtered by tag: <%= params[:tag] %>
- <%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %>
-
- <% end %>
-
+ <% if @attention_plans.present? %>
+
+
+
+ Needs attention (<%= @attention_unread_counts.size %>)
+
+
+ <% @attention_plans.each do |plan| %>
+
+ <%= link_to plan.title, plan_path(plan), class: "attention__link" %>
+ <%= pluralize(@attention_unread_counts[plan.id].to_i, "unread comment") %>
+
+ <% end %>
+
+ <% remaining = @attention_unread_counts.size - @attention_plans.size %>
+ <% if remaining > 0 %>
+
+ + <%= pluralize(remaining, "more plan") %> with unread comments —
+ <%= link_to "see notifications", notifications_path, class: "attention__link" %>
+
+ <% end %>
+
+ <% end %>
-<% if @plans.any? %>
-
- <%= render partial: "coplan/plans/plan_page", locals: {
- plans: @plans,
- plan_unread_counts: @plan_unread_counts,
- page: @page,
- has_next_page: @has_next_page,
- grouped_by_status: @grouped_by_status,
- } %>
-
-<% else %>
-
- <% if @scope == "mine" %>
-
You haven't created any plans yet. Plans are created via the API.
+ <% if @groups %>
+ <% if @groups.any? %>
+
+ <% @groups.each do |group| %>
+ <%= render "coplan/plans/plan_group", group: group %>
+ <% end %>
+
+ <% else %>
+
+ <% if @folder %>
+
No plans <%= "you can see " if @scope == "all" %>in <%= folder_path_for(@folder) %> yet.
+
Drag a plan row onto this folder in the sidebar, or use a plan's row menu to move it here.
+ <% elsif params[:tag].present? || params[:plan_type].present? %>
+
No plans match the current filters.
+ <% elsif @scope == "mine" %>
+
You haven't created any plans yet. Plans are created via the API.
+ <% else %>
+
No plans yet. Plans are created via the API.
+ <% end %>
+
+ <% end %>
<% else %>
-
No plans yet. Plans are created via the API.
+ <%# Flat mode: filtered to a single status via ?status= %>
+ <% if @plans.any? %>
+
+ <%= render "coplan/plans/plan_page", plans: @plans,
+ plan_unread_counts: @plan_unread_counts,
+ page: @page,
+ has_next_page: @has_next_page,
+ group_key: "results",
+ frame_status: params[:status].presence %>
+
+ <% else %>
+
+
No <%= params[:status] %> plans match the current filters.
+
+ <% end %>
<% end %>
-<% end %>
+
diff --git a/engine/config/routes.rb b/engine/config/routes.rb
index e0661a74..dda44b35 100644
--- a/engine/config/routes.rb
+++ b/engine/config/routes.rb
@@ -2,6 +2,7 @@
resources :plans, only: [:index, :show, :edit, :update] do
patch :update_status, on: :member
patch :toggle_checkbox, on: :member
+ patch :move_to_folder, on: :member
get :history, on: :member
get :edit_content, on: :member
patch :update_content, on: :member
@@ -27,9 +28,14 @@
patch "theme", to: "settings#update_theme"
end
+ # Web folder creation (sidebar "New folder" form). Rename/delete go
+ # through the API or admin for now.
+ resources :folders, only: [:create]
+
namespace :api do
namespace :v1 do
resources :tags, only: [:index]
+ resources :folders, only: [:index, :create, :update, :destroy]
resources :plans, only: [:index, :show, :create, :update] do
get :versions, on: :member
get :comments, on: :member
diff --git a/engine/db/migrate/20260716000000_create_coplan_folders.rb b/engine/db/migrate/20260716000000_create_coplan_folders.rb
new file mode 100644
index 00000000..d896cffe
--- /dev/null
+++ b/engine/db/migrate/20260716000000_create_coplan_folders.rb
@@ -0,0 +1,23 @@
+class CreateCoplanFolders < ActiveRecord::Migration[8.1]
+ def change
+ create_table :coplan_folders, id: { type: :string, limit: 36 } do |t|
+ t.string :name, null: false
+ t.string :parent_id, limit: 36
+ t.string :created_by_user_id, limit: 36
+ t.timestamps
+
+ # Unique per sibling group. MySQL treats NULLs as distinct in unique
+ # indexes, so root-level (parent_id IS NULL) uniqueness is enforced by
+ # the model validation instead — same approach either way for app code.
+ t.index [:parent_id, :name], unique: true
+ t.index :created_by_user_id
+ end
+
+ add_column :coplan_plans, :folder_id, :string, limit: 36
+ add_index :coplan_plans, :folder_id
+
+ add_foreign_key :coplan_folders, :coplan_folders, column: :parent_id
+ add_foreign_key :coplan_folders, :coplan_users, column: :created_by_user_id
+ add_foreign_key :coplan_plans, :coplan_folders, column: :folder_id
+ end
+end
diff --git a/spec/factories/folders.rb b/spec/factories/folders.rb
new file mode 100644
index 00000000..93a80a9e
--- /dev/null
+++ b/spec/factories/folders.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :folder, class: "CoPlan::Folder" do
+ sequence(:name) { |n| "Folder #{n}" }
+ created_by_user { association(:coplan_user) }
+ end
+end
diff --git a/spec/models/folder_spec.rb b/spec/models/folder_spec.rb
new file mode 100644
index 00000000..b0c0682c
--- /dev/null
+++ b/spec/models/folder_spec.rb
@@ -0,0 +1,184 @@
+require "rails_helper"
+
+RSpec.describe CoPlan::Folder, type: :model do
+ let(:user) { create(:coplan_user) }
+
+ describe "validations" do
+ it "requires a name" do
+ folder = build(:folder, name: "")
+ expect(folder).not_to be_valid
+ expect(folder.errors[:name]).to be_present
+ end
+
+ it "rejects slashes in names (reserved as the path separator)" do
+ folder = build(:folder, name: "Team/EBT")
+ expect(folder).not_to be_valid
+ expect(folder.errors[:name].join).to include("cannot contain")
+ end
+
+ it "enforces unique names among siblings, case-insensitively" do
+ parent = create(:folder, name: "Team EBT")
+ create(:folder, name: "Q3", parent: parent)
+ dup = build(:folder, name: "q3", parent: parent)
+ expect(dup).not_to be_valid
+ expect(dup.errors[:name]).to be_present
+ end
+
+ it "allows the same name under different parents" do
+ a = create(:folder, name: "Team A")
+ b = create(:folder, name: "Team B")
+ create(:folder, name: "Q3", parent: a)
+ expect(build(:folder, name: "Q3", parent: b)).to be_valid
+ end
+ end
+
+ describe "depth limit" do
+ it "allows nesting up to MAX_DEPTH levels" do
+ root = create(:folder, name: "L1")
+ mid = create(:folder, name: "L2", parent: root)
+ expect(build(:folder, name: "L3", parent: mid)).to be_valid
+ end
+
+ it "rejects nesting beyond MAX_DEPTH levels" do
+ root = create(:folder, name: "L1")
+ mid = create(:folder, name: "L2", parent: root)
+ leaf = create(:folder, name: "L3", parent: mid)
+ too_deep = build(:folder, name: "L4", parent: leaf)
+ expect(too_deep).not_to be_valid
+ expect(too_deep.errors[:parent].join).to include("maximum folder depth")
+ end
+
+ it "rejects re-parenting a folder whose subtree would exceed MAX_DEPTH" do
+ root = create(:folder, name: "Root")
+ mid = create(:folder, name: "Mid", parent: root)
+ mover = create(:folder, name: "Mover")
+ create(:folder, name: "Child", parent: mover)
+ mover.parent = mid
+ expect(mover).not_to be_valid
+ expect(mover.errors[:parent].join).to include("maximum folder depth")
+ end
+ end
+
+ describe "cycle prevention" do
+ it "rejects a folder as its own parent" do
+ folder = create(:folder)
+ folder.parent_id = folder.id
+ expect(folder).not_to be_valid
+ expect(folder.errors[:parent].join).to include("itself")
+ end
+
+ it "rejects a descendant as parent" do
+ root = create(:folder, name: "Root")
+ child = create(:folder, name: "Child", parent: root)
+ root.parent = child
+ expect(root).not_to be_valid
+ expect(root.errors[:parent].join).to include("subfolders")
+ end
+ end
+
+ describe "#ancestors / #descendants / #path / #depth" do
+ let!(:root) { create(:folder, name: "Team EBT") }
+ let!(:mid) { create(:folder, name: "Q3", parent: root) }
+ let!(:leaf) { create(:folder, name: "Launch", parent: mid) }
+ let!(:sibling) { create(:folder, name: "Q4", parent: root) }
+
+ it "returns ancestors root-first" do
+ expect(leaf.ancestors).to eq([ root, mid ])
+ expect(root.ancestors).to eq([])
+ end
+
+ it "returns all nested descendants" do
+ expect(root.descendants).to match_array([ mid, leaf, sibling ])
+ expect(leaf.descendants).to eq([])
+ end
+
+ it "computes path and depth" do
+ expect(leaf.path).to eq("Team EBT/Q3/Launch")
+ expect(leaf.depth).to eq(3)
+ expect(root.depth).to eq(1)
+ end
+ end
+
+ describe "deletion rules" do
+ it "cannot be deleted while it contains plans" do
+ folder = create(:folder)
+ plan = create(:plan, :considering)
+ plan.update!(folder: folder)
+
+ expect(folder.destroy).to be false
+ expect(folder.errors[:base].join).to include("contains plans")
+ expect(described_class.exists?(folder.id)).to be true
+ end
+
+ it "cannot be deleted while it has subfolders" do
+ folder = create(:folder)
+ create(:folder, parent: folder)
+
+ expect(folder.destroy).to be false
+ expect(folder.errors[:base].join).to include("subfolders")
+ expect(described_class.exists?(folder.id)).to be true
+ end
+
+ it "deletes cleanly when empty" do
+ folder = create(:folder)
+ expect(folder.destroy).to be_truthy
+ expect(described_class.exists?(folder.id)).to be false
+ end
+ end
+
+ describe ".find_or_create_by_path!" do
+ it "creates the full hierarchy" do
+ leaf = described_class.find_or_create_by_path!("Team EBT/Q3/Launch", created_by_user: user)
+ expect(leaf.name).to eq("Launch")
+ expect(leaf.path).to eq("Team EBT/Q3/Launch")
+ expect(leaf.created_by_user).to eq(user)
+ expect(described_class.count).to eq(3)
+ end
+
+ it "reuses existing folders case-insensitively" do
+ existing = create(:folder, name: "Team EBT")
+ leaf = described_class.find_or_create_by_path!("team ebt/Q3", created_by_user: user)
+ expect(leaf.parent).to eq(existing)
+ expect(described_class.count).to eq(2)
+ end
+
+ it "returns the existing folder for an exact match" do
+ leaf = described_class.find_or_create_by_path!("Team EBT/Q3", created_by_user: user)
+ again = described_class.find_or_create_by_path!("Team EBT/Q3", created_by_user: user)
+ expect(again).to eq(leaf)
+ end
+
+ it "returns nil for a blank path" do
+ expect(described_class.find_or_create_by_path!("", created_by_user: user)).to be_nil
+ expect(described_class.find_or_create_by_path!(" / ", created_by_user: user)).to be_nil
+ end
+
+ it "raises when the path exceeds MAX_DEPTH" do
+ expect {
+ described_class.find_or_create_by_path!("A/B/C/D", created_by_user: user)
+ }.to raise_error(ActiveRecord::RecordInvalid, /maximum folder depth/)
+ end
+
+ it "creates nothing when the path is too deep (transactional)" do
+ expect {
+ described_class.find_or_create_by_path!("A/B/C/D", created_by_user: user)
+ }.to raise_error(ActiveRecord::RecordInvalid)
+ expect(described_class.count).to eq(0)
+ end
+ end
+
+ describe ".paths_by_id" do
+ it "returns the full path for every folder without per-folder queries" do
+ root = create(:folder, name: "Team EBT")
+ sub = create(:folder, name: "Q3", parent: root)
+ other = create(:folder, name: "Infra")
+
+ paths = described_class.paths_by_id
+ expect(paths).to eq(
+ root.id => "Team EBT",
+ sub.id => "Team EBT/Q3",
+ other.id => "Infra"
+ )
+ end
+ end
+end
diff --git a/spec/requests/api/v1/folders_spec.rb b/spec/requests/api/v1/folders_spec.rb
new file mode 100644
index 00000000..2872ca01
--- /dev/null
+++ b/spec/requests/api/v1/folders_spec.rb
@@ -0,0 +1,174 @@
+require "rails_helper"
+
+RSpec.describe "Api::V1::Folders", type: :request do
+ let(:alice) { create(:coplan_user) }
+ let(:bob) { create(:coplan_user) }
+ let(:admin) { create(:coplan_user, :admin) }
+ let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") }
+ let(:bob_token) { create(:api_token, user: bob, raw_token: "test-token-bob") }
+ let(:admin_token) { create(:api_token, user: admin, raw_token: "test-token-admin") }
+ let(:headers) { { "Authorization" => "Bearer test-token-alice" } }
+ let(:bob_headers) { { "Authorization" => "Bearer test-token-bob" } }
+ let(:admin_headers) { { "Authorization" => "Bearer test-token-admin" } }
+
+ before do
+ alice_token
+ bob_token
+ admin_token
+ end
+
+ describe "GET /api/v1/folders" do
+ it "requires authentication" do
+ get api_v1_folders_path
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it "returns folders with paths and visible plan counts" do
+ root = create(:folder, name: "Team EBT", created_by_user: alice)
+ sub = create(:folder, name: "Q3", parent: root, created_by_user: alice)
+ create(:plan, :considering, created_by_user: bob).update!(folder: sub)
+
+ get api_v1_folders_path, headers: headers
+ expect(response).to have_http_status(:success)
+ folders = JSON.parse(response.body)
+ sub_json = folders.find { |f| f["id"] == sub.id }
+ expect(sub_json["path"]).to eq("Team EBT/Q3")
+ expect(sub_json["parent_id"]).to eq(root.id)
+ expect(sub_json["plans_count"]).to eq(1)
+ expect(folders.find { |f| f["id"] == root.id }["plans_count"]).to eq(0)
+ end
+
+ it "does not count other users' brainstorm plans" do
+ folder = create(:folder, created_by_user: alice)
+ create(:plan, :brainstorm, created_by_user: bob).update!(folder: folder)
+ create(:plan, :brainstorm, created_by_user: alice).update!(folder: folder)
+ create(:plan, :considering, created_by_user: bob).update!(folder: folder)
+
+ get api_v1_folders_path, headers: headers
+ counts = JSON.parse(response.body).find { |f| f["id"] == folder.id }
+ # Alice sees Bob's published plan and her own brainstorm — not Bob's brainstorm.
+ expect(counts["plans_count"]).to eq(2)
+
+ get api_v1_folders_path, headers: bob_headers
+ counts = JSON.parse(response.body).find { |f| f["id"] == folder.id }
+ expect(counts["plans_count"]).to eq(2)
+ end
+ end
+
+ describe "POST /api/v1/folders" do
+ it "creates a root folder" do
+ post api_v1_folders_path, params: { name: "Infra" }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:created)
+ json = JSON.parse(response.body)
+ expect(json["name"]).to eq("Infra")
+ expect(json["parent_id"]).to be_nil
+ expect(CoPlan::Folder.find(json["id"]).created_by_user).to eq(alice)
+ end
+
+ it "creates a nested folder" do
+ root = create(:folder, name: "Infra", created_by_user: bob)
+ post api_v1_folders_path, params: { name: "Q3", parent_id: root.id }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:created)
+ expect(JSON.parse(response.body)["path"]).to eq("Infra/Q3")
+ end
+
+ it "rejects an unknown parent_id" do
+ post api_v1_folders_path, params: { name: "Q3", parent_id: "nope" }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(JSON.parse(response.body)["error"]).to include("Unknown parent_id")
+ end
+
+ it "rejects invalid names" do
+ post api_v1_folders_path, params: { name: "a/b" }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "rejects duplicate sibling names" do
+ create(:folder, name: "Infra")
+ post api_v1_folders_path, params: { name: "Infra" }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+
+ describe "PATCH /api/v1/folders/:id" do
+ let(:folder) { create(:folder, name: "Old Name", created_by_user: alice) }
+
+ it "lets the creator rename" do
+ patch api_v1_folder_path(folder), params: { name: "New Name" }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:success)
+ expect(folder.reload.name).to eq("New Name")
+ end
+
+ it "lets an admin rename" do
+ patch api_v1_folder_path(folder), params: { name: "Admin Name" }.to_json,
+ headers: admin_headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:success)
+ expect(folder.reload.name).to eq("Admin Name")
+ end
+
+ it "forbids other users from renaming" do
+ patch api_v1_folder_path(folder), params: { name: "Bob Name" }.to_json,
+ headers: bob_headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:forbidden)
+ expect(folder.reload.name).to eq("Old Name")
+ end
+
+ it "can re-parent a folder" do
+ new_parent = create(:folder, name: "Parent")
+ patch api_v1_folder_path(folder), params: { parent_id: new_parent.id }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:success)
+ expect(folder.reload.parent).to eq(new_parent)
+ end
+
+ it "rejects a cycle" do
+ child = create(:folder, name: "Child", parent: folder, created_by_user: alice)
+ patch api_v1_folder_path(folder), params: { parent_id: child.id }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "404s for a missing folder" do
+ patch api_v1_folder_path("missing"), params: { name: "X" }.to_json,
+ headers: headers.merge("Content-Type" => "application/json")
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "DELETE /api/v1/folders/:id" do
+ let(:folder) { create(:folder, created_by_user: alice) }
+
+ it "deletes an empty folder as creator" do
+ delete api_v1_folder_path(folder), headers: headers
+ expect(response).to have_http_status(:no_content)
+ expect(CoPlan::Folder.exists?(folder.id)).to be false
+ end
+
+ it "forbids non-creator non-admins" do
+ delete api_v1_folder_path(folder), headers: bob_headers
+ expect(response).to have_http_status(:forbidden)
+ expect(CoPlan::Folder.exists?(folder.id)).to be true
+ end
+
+ it "refuses to delete a folder containing plans" do
+ create(:plan, :considering, created_by_user: alice).update!(folder: folder)
+ delete api_v1_folder_path(folder), headers: headers
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(JSON.parse(response.body)["error"]).to include("contains plans")
+ expect(CoPlan::Folder.exists?(folder.id)).to be true
+ end
+
+ it "refuses to delete a folder with subfolders" do
+ create(:folder, parent: folder)
+ delete api_v1_folder_path(folder), headers: headers
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(CoPlan::Folder.exists?(folder.id)).to be true
+ end
+ end
+end
diff --git a/spec/requests/api/v1/plans_spec.rb b/spec/requests/api/v1/plans_spec.rb
index 68ef780a..a47eef85 100644
--- a/spec/requests/api/v1/plans_spec.rb
+++ b/spec/requests/api/v1/plans_spec.rb
@@ -108,25 +108,25 @@
end
it "updates plan tags" do
- patch api_v1_plan_path(plan), params: { tags: ["infra", "api"] }, headers: headers, as: :json
+ patch api_v1_plan_path(plan), params: { tags: [ "infra", "api" ] }, headers: headers, as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
- expect(body["tags"]).to match_array(["infra", "api"])
- expect(plan.reload.tag_names).to match_array(["infra", "api"])
+ expect(body["tags"]).to match_array([ "infra", "api" ])
+ expect(plan.reload.tag_names).to match_array([ "infra", "api" ])
end
it "updates multiple fields at once" do
- patch api_v1_plan_path(plan), params: { title: "Updated", status: "developing", tags: ["v2"] }, headers: headers, as: :json
+ patch api_v1_plan_path(plan), params: { title: "Updated", status: "developing", tags: [ "v2" ] }, headers: headers, as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body)
expect(body["title"]).to eq("Updated")
expect(body["status"]).to eq("developing")
- expect(body["tags"]).to eq(["v2"])
+ expect(body["tags"]).to eq([ "v2" ])
end
it "leaves unchanged fields alone" do
original_title = plan.title
- patch api_v1_plan_path(plan), params: { tags: ["new-tag"] }, headers: headers, as: :json
+ patch api_v1_plan_path(plan), params: { tags: [ "new-tag" ] }, headers: headers, as: :json
expect(response).to have_http_status(:success)
expect(plan.reload.title).to eq(original_title)
end
@@ -153,6 +153,91 @@
patch api_v1_plan_path(plan), params: { title: "No Auth" }, as: :json
expect(response).to have_http_status(:unauthorized)
end
+
+ describe "folder assignment" do
+ it "moves the plan into a folder by folder_id and logs an event" do
+ folder = create(:folder, name: "Infra")
+ expect {
+ patch api_v1_plan_path(plan), params: { folder_id: folder.id }, headers: headers, as: :json
+ }.to change(CoPlan::PlanEvent, :count).by(1)
+ expect(response).to have_http_status(:success)
+ body = JSON.parse(response.body)
+ expect(body["folder_id"]).to eq(folder.id)
+ expect(body["folder_path"]).to eq("Infra")
+ expect(plan.reload.folder).to eq(folder)
+
+ event = CoPlan::PlanEvent.order(:created_at).last
+ expect(event.event_type).to eq("moved_to_folder")
+ expect(event.field).to eq("folder")
+ expect(event.before_value).to be_nil
+ expect(event.after_value).to eq("Infra")
+ end
+
+ it "finds-or-creates the hierarchy via folder_path" do
+ expect {
+ patch api_v1_plan_path(plan), params: { folder_path: "Team EBT/Q3" }, headers: headers, as: :json
+ }.to change(CoPlan::Folder, :count).by(2)
+ expect(response).to have_http_status(:success)
+ expect(JSON.parse(response.body)["folder_path"]).to eq("Team EBT/Q3")
+ expect(plan.reload.folder.path).to eq("Team EBT/Q3")
+ expect(plan.folder.created_by_user).to eq(alice)
+ end
+
+ it "reuses existing folders via folder_path" do
+ root = create(:folder, name: "Team EBT")
+ sub = create(:folder, name: "Q3", parent: root)
+ expect {
+ patch api_v1_plan_path(plan), params: { folder_path: "Team EBT/Q3" }, headers: headers, as: :json
+ }.not_to change(CoPlan::Folder, :count)
+ expect(plan.reload.folder).to eq(sub)
+ end
+
+ it "moves the plan out of a folder with a blank folder_id" do
+ folder = create(:folder, name: "Infra")
+ plan.update!(folder: folder)
+
+ patch api_v1_plan_path(plan), params: { folder_id: "" }, headers: headers, as: :json
+ expect(response).to have_http_status(:success)
+ expect(plan.reload.folder).to be_nil
+
+ event = CoPlan::PlanEvent.order(:created_at).last
+ expect(event.event_type).to eq("moved_to_folder")
+ expect(event.before_value).to eq("Infra")
+ expect(event.after_value).to be_nil
+ end
+
+ it "rejects an unknown folder_id" do
+ patch api_v1_plan_path(plan), params: { folder_id: "nope" }, headers: headers, as: :json
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(JSON.parse(response.body)["error"]).to include("Unknown folder_id")
+ expect(plan.reload.folder).to be_nil
+ end
+
+ it "rejects a folder_path deeper than the max depth" do
+ patch api_v1_plan_path(plan), params: { folder_path: "A/B/C/D" }, headers: headers, as: :json
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "does not log an event when the folder is unchanged" do
+ folder = create(:folder, name: "Infra")
+ plan.update!(folder: folder)
+ expect {
+ patch api_v1_plan_path(plan), params: { folder_id: folder.id }, headers: headers, as: :json
+ }.not_to change(CoPlan::PlanEvent, :count)
+ end
+
+ it "rolls back folder_path creation when the rest of the update fails" do
+ patch api_v1_plan_path(plan),
+ params: { folder_path: "New Team/Sub", status: "bogus" },
+ headers: headers, as: :json
+
+ expect(response).to have_http_status(:unprocessable_content)
+ expect(plan.reload.folder).to be_nil
+ # The invalid status aborted the whole update — no orphaned shared
+ # folders left behind for a move that never happened.
+ expect(CoPlan::Folder.count).to eq(0)
+ end
+ end
end
it "versions returns version list" do
diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb
index b1cd5880..2a2c7189 100644
--- a/spec/requests/plans_spec.rb
+++ b/spec/requests/plans_spec.rb
@@ -32,9 +32,9 @@
end
it "index filters by tag" do
- plan.tag_names = ["infra"]
+ plan.tag_names = [ "infra" ]
other = create(:plan, :considering, created_by_user: alice, title: "Other Plan")
- other.tag_names = ["frontend"]
+ other.tag_names = [ "frontend" ]
get plans_path(tag: "infra")
expect(response).to have_http_status(:success)
expect(response.body).to include(plan.title)
@@ -42,7 +42,7 @@
end
it "index shows tag badges on plan cards" do
- plan.tag_names = ["infra", "api"]
+ plan.tag_names = [ "infra", "api" ]
get plans_path
expect(response.body).to include("badge--tag")
expect(response.body).to include("infra")
@@ -50,7 +50,7 @@
end
it "index shows active tag filter bar" do
- plan.tag_names = ["infra"]
+ plan.tag_names = [ "infra" ]
get plans_path(tag: "infra")
expect(response.body).to include("active-filter")
expect(response.body).to include("infra")
@@ -58,7 +58,7 @@
end
it "show plan renders tag badges in header" do
- plan.tag_names = ["infra", "security"]
+ plan.tag_names = [ "infra", "security" ]
get plan_path(plan)
expect(response).to have_http_status(:success)
expect(response.body).to include("badge--tag")
@@ -181,21 +181,37 @@
expect(response.body).to include(bobs_plan.title)
end
- it "groups My Plans by status with section headers" do
+ it "groups plans into collapsible status groups, active work first" do
create(:plan, :developing, created_by_user: alice, title: "Developing Plan")
create(:plan, :considering, created_by_user: alice, title: "Considering Plan")
create(:plan, :brainstorm, created_by_user: alice, title: "Brainstorm Plan")
get plans_path
- expect(response.body).to include("plans-list__section")
- # brainstorms are pinned first (COPLAN-32), then active work by maturity
- expect(response.body.index("Brainstorm Plan")).to be < response.body.index("Considering Plan")
- expect(response.body.index("Considering Plan")).to be < response.body.index("Developing Plan")
+ expect(response.body).to include("plan-group")
+ expect(response.body.index("Developing Plan")).to be < response.body.index("Considering Plan")
+ expect(response.body.index("Considering Plan")).to be < response.body.index("Brainstorm Plan")
end
- # COPLAN-32: with more active plans than fit on the first page, the
- # author's own brainstorms must still land on page 1 of the default
- # Mine/Any-status view rather than being buried past the pagination cut.
- it "surfaces the author's own brainstorms on page 1 despite many active plans" do
+ it "marks the brainstorm group as collapsed by default" do
+ create(:plan, :brainstorm, created_by_user: alice)
+ create(:plan, :developing, created_by_user: alice)
+ get plans_path
+ brainstorm_group = response.body[/
]*data-group-key="brainstorm"[^>]*>/]
+ developing_group = response.body[/]*data-group-key="developing"[^>]*>/]
+ expect(brainstorm_group).to include("data-default-collapsed")
+ expect(developing_group).not_to include("data-default-collapsed")
+ end
+
+ it "omits status groups with no plans" do
+ create(:plan, :developing, created_by_user: alice)
+ get plans_path
+ expect(response.body).to include('data-group-key="developing"')
+ expect(response.body).not_to include('data-group-key="abandoned"')
+ end
+
+ # COPLAN-32 successor: every status gets its own group with its own
+ # pagination, so the author's brainstorms always render on the first
+ # page load no matter how many active plans exist.
+ it "surfaces the author's own brainstorms on the first page despite many active plans" do
brainstorm = create(:plan, :brainstorm, created_by_user: alice, title: "Buried Brainstorm")
create_list(:plan, CoPlan::PlansController::PER_PAGE + 5, :developing, created_by_user: alice)
get plans_path
@@ -203,33 +219,41 @@
expect(response.body).to include(brainstorm.title)
end
- it "does not group when filtered to a single status" do
+ it "paginates within a group via a group-scoped lazy frame" do
+ create_list(:plan, CoPlan::PlansController::PER_PAGE + 2, :developing, created_by_user: alice)
+ get plans_path
+ expect(response.body).to include('id="plans-developing-page-2"')
+ expect(response.body).to include("group=developing")
+ end
+
+ it "renders a flat list when filtered to a single status" do
create(:plan, :developing, created_by_user: alice, title: "Developing Plan")
get plans_path(status: "developing")
- expect(response.body).not_to include("plans-list__section")
+ expect(response.body).not_to include("plan-group__toggle")
expect(response.body).to include("Developing Plan")
end
- it "scope=all does not group by status" do
- create(:plan, :developing, created_by_user: alice, title: "Developing Plan")
+ it "scope=all groups everyone's visible plans by status" do
+ create(:plan, :developing, created_by_user: bob, title: "Bobs Developing Plan")
get plans_path(scope: "all")
- expect(response.body).not_to include("plans-list__section")
+ expect(response.body).to include('data-group-key="developing"')
+ expect(response.body).to include("Bobs Developing Plan")
end
end
- describe "content preview on cards" do
+ describe "content preview on rows" do
it "renders a markdown-stripped preview when there is no AI summary" do
plan.current_plan_version.update!(content_markdown: "# Heading\n\nThis is the **plan body** with [links](https://example.com).")
get plans_path
- expect(response.body).to include("plans-list__summary")
+ expect(response.body).to include("plan-row__summary")
expect(response.body).to include("This is the plan body with links")
expect(response.body).not_to include("**plan body**")
end
- it "omits the summary block when the plan has no content" do
+ it "omits the summary line when the plan has no content" do
plan.current_plan_version.update_columns(content_markdown: "", content_sha256: Digest::SHA256.hexdigest(""))
get plans_path
- expect(response.body).not_to include("plans-list__summary")
+ expect(response.body).not_to include("plan-row__summary")
end
end
@@ -270,4 +294,258 @@
CoPlan.configuration.onboarding_banner = original
end
end
+
+ describe "folder filtering" do
+ let!(:root) { create(:folder, name: "Team EBT", created_by_user: alice) }
+ let!(:sub) { create(:folder, name: "Q3", parent: root, created_by_user: alice) }
+ let!(:root_plan) { create(:plan, :considering, created_by_user: alice, title: "Root Level Plan") }
+ let!(:sub_plan) { create(:plan, :considering, created_by_user: alice, title: "Subfolder Plan") }
+ let!(:loose_plan) { create(:plan, :considering, created_by_user: alice, title: "Unfiled Plan") }
+
+ before do
+ root_plan.update!(folder: root)
+ sub_plan.update!(folder: sub)
+ end
+
+ it "filters to a folder including its subfolders" do
+ get plans_path(folder: root.id)
+ expect(response.body).to include("Root Level Plan")
+ expect(response.body).to include("Subfolder Plan")
+ expect(response.body).not_to include("Unfiled Plan")
+ end
+
+ it "filters to a leaf folder only" do
+ get plans_path(folder: sub.id)
+ expect(response.body).to include("Subfolder Plan")
+ expect(response.body).not_to include("Root Level Plan")
+ end
+
+ it "shows a clearable folder filter chip" do
+ get plans_path(folder: root.id)
+ expect(response.body).to include("Folder: Team EBT")
+ expect(response.body).to include("Clear all")
+ end
+
+ it "combines folder with tag and scope filters" do
+ root_plan.tag_names = [ "infra" ]
+ sub_plan.tag_names = [ "frontend" ]
+ bobs_plan = create(:plan, :considering, created_by_user: bob, title: "Bobs Foldered Plan")
+ bobs_plan.update!(folder: root)
+ bobs_plan.tag_names = [ "infra" ]
+
+ get plans_path(folder: root.id, tag: "infra", scope: "all")
+ expect(response.body).to include("Root Level Plan")
+ expect(response.body).to include("Bobs Foldered Plan")
+ expect(response.body).not_to include("Subfolder Plan")
+
+ get plans_path(folder: root.id, tag: "infra", scope: "mine")
+ expect(response.body).to include("Root Level Plan")
+ expect(response.body).not_to include("Bobs Foldered Plan")
+ end
+
+ it "renders a folder-specific empty state" do
+ empty = create(:folder, name: "Empty Folder", created_by_user: alice)
+ get plans_path(folder: empty.id)
+ expect(response.body).to include("empty-state")
+ expect(response.body).to include("Empty Folder")
+ end
+
+ it "shows folder breadcrumbs on rows" do
+ get plans_path
+ expect(response.body).to include("Team EBT/Q3")
+ end
+
+ it "redirects with an alert when the folder no longer exists" do
+ get plans_path(folder: "gone", tag: "infra")
+ expect(response).to redirect_to(plans_path(tag: "infra"))
+ expect(flash[:alert]).to include("no longer exists")
+ end
+
+ it "clamps a non-positive page param instead of erroring" do
+ get plans_path(status: "considering", page: "0")
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Root Level Plan")
+ end
+ end
+
+ describe "sidebar" do
+ it "renders the folder tree with visible-plan counts" do
+ folder = create(:folder, name: "Infra", created_by_user: alice)
+ create(:plan, :considering, created_by_user: alice).update!(folder: folder)
+ get plans_path
+ expect(response.body).to include("folder-tree")
+ expect(response.body).to include("Infra")
+ end
+
+ it "does not count other users' brainstorm plans in folder counts" do
+ folder = create(:folder, name: "Secret Stash", created_by_user: bob)
+ create(:plan, :brainstorm, created_by_user: bob).update!(folder: folder)
+
+ sign_in_as(alice)
+ get plans_path(scope: "all")
+ # The folder shows with a zero count — its only plan is Bob's private brainstorm.
+ expect(response.body).to match(%r{Secret Stash\s*})
+
+ sign_in_as(bob)
+ get plans_path(scope: "all")
+ expect(response.body).to match(%r{Secret Stash\s*})
+ end
+
+ it "scopes folder counts to the active workspace scope" do
+ folder = create(:folder, name: "Bobs Corner", created_by_user: bob)
+ create(:plan, :considering, created_by_user: bob).update!(folder: folder)
+
+ # My plans: the folder holds none of alice's plans, so it counts 0 —
+ # matching the empty list clicking it would show.
+ get plans_path
+ expect(response.body).to match(%r{Bobs Corner\s*})
+
+ get plans_path(scope: "all")
+ expect(response.body).to match(%r{Bobs Corner\s*})
+ end
+
+ it "includes subfolder plans in parent folder counts" do
+ root = create(:folder, name: "Team EBT", created_by_user: alice)
+ sub = create(:folder, name: "Q3", parent: root, created_by_user: alice)
+ create(:plan, :considering, created_by_user: alice).update!(folder: sub)
+ get plans_path
+ expect(response.body).to match(%r{Team EBT\s*})
+ end
+
+ it "does not surface tags used only on other users' brainstorms" do
+ secret = create(:plan, :brainstorm, created_by_user: bob)
+ secret.tag_names = [ "secret-tag" ]
+ visible = create(:plan, :considering, created_by_user: bob)
+ visible.tag_names = [ "public-tag" ]
+
+ get plans_path(scope: "all")
+ expect(response.body).to include("public-tag")
+ expect(response.body).not_to include("secret-tag")
+ end
+
+ it "scopes sidebar tags to the active workspace scope" do
+ others = create(:plan, :considering, created_by_user: bob)
+ others.tag_names = [ "bobs-tag" ]
+
+ get plans_path
+ expect(response.body).not_to include("bobs-tag")
+ end
+
+ it "shows a New folder form" do
+ get plans_path
+ expect(response.body).to include("New folder")
+ expect(response.body).to include('action="/folders"')
+ end
+ end
+
+ describe "needs attention strip" do
+ it "lists plans with unread comments for the current user" do
+ thread = create(:comment_thread, plan: plan, created_by_user: bob)
+ create(:notification, user: alice, plan: plan, comment_thread: thread)
+
+ get plans_path
+ expect(response.body).to include("Needs attention (1)")
+ expect(response.body).to include("1 unread comment")
+ end
+
+ it "is omitted when nothing is unread" do
+ plan # visible plan, no notifications
+ get plans_path
+ expect(response.body).not_to include("Needs attention")
+ end
+ end
+
+ describe "PATCH /plans/:id/move_to_folder" do
+ let(:folder) { create(:folder, name: "Infra", created_by_user: bob) }
+
+ it "moves the author's plan and logs an event" do
+ expect {
+ patch move_to_folder_plan_path(plan), params: { folder_id: folder.id }
+ }.to change(CoPlan::PlanEvent, :count).by(1)
+ expect(response).to redirect_to(plans_path)
+ expect(flash[:notice]).to include("Infra")
+ expect(plan.reload.folder).to eq(folder)
+
+ event = CoPlan::PlanEvent.order(:created_at).last
+ expect(event.event_type).to eq("moved_to_folder")
+ expect(event.after_value).to eq("Infra")
+ end
+
+ it "responds with JSON for the drag-and-drop controller" do
+ patch move_to_folder_plan_path(plan),
+ params: { folder_id: folder.id }.to_json,
+ headers: { "Content-Type" => "application/json", "Accept" => "application/json" }
+ expect(response).to have_http_status(:success)
+ body = JSON.parse(response.body)
+ expect(body["folder_id"]).to eq(folder.id)
+ expect(body["folder_path"]).to eq("Infra")
+ expect(body["message"]).to include("Infra")
+ end
+
+ it "clears the folder with a blank folder_id" do
+ plan.update!(folder: folder)
+ patch move_to_folder_plan_path(plan), params: { folder_id: "" }
+ expect(plan.reload.folder).to be_nil
+ end
+
+ it "denies non-authors" do
+ sign_in_as(bob)
+ patch move_to_folder_plan_path(plan), params: { folder_id: folder.id }
+ expect(response).to have_http_status(:not_found)
+ expect(plan.reload.folder).to be_nil
+ end
+
+ it "rejects an unknown folder" do
+ patch move_to_folder_plan_path(plan),
+ params: { folder_id: "nope" }.to_json,
+ headers: { "Content-Type" => "application/json", "Accept" => "application/json" }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "does not log an event for a no-op move" do
+ plan.update!(folder: folder)
+ expect {
+ patch move_to_folder_plan_path(plan), params: { folder_id: folder.id }
+ }.not_to change(CoPlan::PlanEvent, :count)
+ end
+
+ it "only renders drag handles and move menus for the author's rows" do
+ plan # alice's plan
+ bobs_plan = create(:plan, :considering, created_by_user: bob, title: "Bobs Plan")
+ get plans_path(scope: "all")
+ rows = response.body.scan(/]*>/)
+ alice_row = rows.find { |r| r.include?(plan.id) }
+ bob_row = rows.find { |r| r.include?(bobs_plan.id) }
+ expect(alice_row).to include('draggable="true"')
+ expect(bob_row).not_to include("draggable")
+ end
+ end
+
+ describe "POST /folders (web)" do
+ it "creates a folder and filters to it" do
+ post folders_path, params: { folder: { name: "Team EBT" } }
+ folder = CoPlan::Folder.find_by(name: "Team EBT")
+ expect(folder).to be_present
+ expect(folder.created_by_user).to eq(alice)
+ expect(response).to redirect_to(plans_path(folder: folder.id))
+ end
+
+ it "creates a nested folder" do
+ parent = create(:folder, name: "Team EBT")
+ post folders_path, params: { folder: { name: "Q3", parent_id: parent.id } }
+ expect(CoPlan::Folder.find_by(name: "Q3").parent).to eq(parent)
+ end
+
+ it "surfaces validation errors via flash" do
+ create(:folder, name: "Team EBT")
+ post folders_path, params: { folder: { name: "Team EBT" } }
+ expect(flash[:alert]).to include("Couldn't create folder")
+ end
+
+ it "rejects an unknown parent instead of creating a root folder" do
+ post folders_path, params: { folder: { name: "Q3", parent_id: "gone" } }
+ expect(CoPlan::Folder.find_by(name: "Q3")).to be_nil
+ expect(flash[:alert]).to include("parent folder no longer exists")
+ end
+ end
end
diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb
new file mode 100644
index 00000000..543ef6c6
--- /dev/null
+++ b/spec/system/folders_workspace_spec.rb
@@ -0,0 +1,133 @@
+require "rails_helper"
+
+RSpec.describe "Folders workspace", type: :system do
+ let(:author) { create(:coplan_user, email: "author@example.com") }
+ let(:other) { create(:coplan_user, email: "other@example.com") }
+
+ let!(:infra) { create(:folder, name: "Infra", created_by_user: author) }
+ let!(:team) { create(:folder, name: "Team EBT", created_by_user: author) }
+ let!(:q3) { create(:folder, name: "Q3", parent: team, created_by_user: author) }
+
+ let!(:developing_plan) { create(:plan, :developing, created_by_user: author, title: "Payments Plan") }
+ let!(:brainstorm_plan) { create(:plan, :brainstorm, created_by_user: author, title: "Secret Idea") }
+ let!(:foldered_plan) do
+ plan = create(:plan, :considering, created_by_user: author, title: "Q3 Launch Plan")
+ plan.update!(folder: q3)
+ plan
+ end
+
+ def sign_in(user)
+ visit sign_in_path
+ fill_in "Email address", with: user.email
+ click_button "Sign In"
+ expect(page).to have_content("Sign out")
+ end
+
+ before { sign_in(author) }
+
+ describe "sidebar navigation" do
+ it "filters plans by folder, including subfolders" do
+ visit plans_path
+
+ # Both plans visible before filtering
+ expect(page).to have_content("Payments Plan")
+ expect(page).to have_content("Q3 Launch Plan")
+
+ # Clicking the parent folder shows subfolder contents too
+ within(".workspace__sidebar") { click_link "Team EBT" }
+ expect(page).to have_content("Q3 Launch Plan")
+ expect(page).not_to have_content("Payments Plan")
+ expect(page).to have_content("Folder: Team EBT")
+
+ # Clear the filter
+ click_link "Clear all"
+ expect(page).to have_content("Payments Plan")
+ end
+
+ it "filters by tag from the sidebar" do
+ developing_plan.tag_names = ["security"]
+ visit plans_path
+
+ within(".workspace__sidebar") { click_link "#security" }
+ expect(page).to have_content("Payments Plan")
+ expect(page).not_to have_content("Q3 Launch Plan")
+ end
+
+ it "creates a folder from the sidebar" do
+ visit plans_path
+ find(".sidebar__new-folder-toggle").click
+ fill_in "Folder name", with: "Fresh Folder"
+ click_button "Create"
+
+ expect(page).to have_content("Folder “Fresh Folder” created.")
+ expect(page).to have_content("No plans")
+ within(".workspace__sidebar") { expect(page).to have_content("Fresh Folder") }
+ end
+ end
+
+ describe "collapsible status groups" do
+ it "collapses brainstorms by default and persists toggles across reloads" do
+ visit plans_path
+
+ # Brainstorm group is collapsed by default: header visible, rows hidden.
+ expect(page).to have_css('[data-group-key="brainstorm"]')
+ expect(page).not_to have_content("Secret Idea")
+
+ # Expand it.
+ find('[data-group-key="brainstorm"] .plan-group__toggle').click
+ expect(page).to have_content("Secret Idea")
+
+ # Collapse developing.
+ find('[data-group-key="developing"] .plan-group__toggle').click
+ expect(page).not_to have_content("Payments Plan")
+
+ # State persists across a reload (localStorage).
+ visit plans_path
+ expect(page).to have_content("Secret Idea")
+ expect(page).not_to have_content("Payments Plan")
+ end
+ end
+
+ describe "moving plans to folders" do
+ it "moves a plan by dragging its row onto a sidebar folder" do
+ visit plans_path
+
+ row = find(".plan-row[data-plan-id='#{developing_plan.id}']")
+ target = find(".folder-tree__link", text: "Infra")
+
+ begin
+ row.drag_to(target, html5: true)
+ rescue Capybara::NotSupportedByDriverError, ArgumentError
+ skip "driver does not support HTML5 drag and drop"
+ end
+
+ expect(page).to have_css(".flash--notice", text: "Infra", wait: 5)
+ expect(developing_plan.reload.folder).to eq(infra)
+
+ # The row now shows its folder breadcrumb after the refresh.
+ expect(page).to have_css(".plan-row[data-plan-id='#{developing_plan.id}'] .plan-row__folder", text: "Infra")
+ end
+
+ it "moves a plan via the row menu fallback" do
+ visit plans_path
+
+ within(".plan-row[data-plan-id='#{developing_plan.id}']") do
+ find(".plan-row__menu-toggle").click
+ select "Team EBT/Q3", from: "folder_id"
+ click_button "Move"
+ end
+
+ expect(page).to have_css(".flash--notice", text: "Team EBT/Q3")
+ expect(developing_plan.reload.folder).to eq(q3)
+ end
+
+ it "does not offer move controls on other users' plans" do
+ other_plan = create(:plan, :considering, created_by_user: other, title: "Someone Elses Plan")
+ visit plans_path(scope: "all")
+
+ row = find(".plan-row[data-plan-id='#{other_plan.id}']")
+ expect(row["draggable"]).not_to eq("true")
+ expect(row).to have_no_css(".plan-row__menu")
+ end
+ end
+end