diff --git a/app/admin/folders.rb b/app/admin/folders.rb new file mode 100644 index 00000000..3a379971 --- /dev/null +++ b/app/admin/folders.rb @@ -0,0 +1,52 @@ +ActiveAdmin.register CoPlan::Folder, as: "Folder" do + permit_params :name, :parent_id, :created_by_user_id + + index do + selectable_column + id_column + column :name + column("Path") { |folder| folder.path } + column :parent + column :created_by_user + column :created_at + actions + end + + show do + attributes_table do + row :id + row :name + row("Path") { resource.path } + row :parent + row :created_by_user + row :created_at + row :updated_at + end + + panel "Subfolders" do + table_for resource.children.order(:name) do + column :id + column :name + column :created_at + end + end + + panel "Plans" do + table_for resource.plans.order(updated_at: :desc) do + column :id + column :title + column :status + column :updated_at + end + end + end + + form do |f| + f.inputs do + f.input :name + f.input :parent, collection: CoPlan::Folder.order(:name).map { |folder| [folder.path, folder.id] } + f.input :created_by_user, collection: CoPlan::User.order(:name).map { |u| [u.name, u.id] } + end + f.actions + end +end diff --git a/db/migrate/20260716154624_drop_coplan_automated_plan_reviewers.co_plan.rb b/db/migrate/20260716154624_drop_coplan_automated_plan_reviewers.co_plan.rb new file mode 100644 index 00000000..36233110 --- /dev/null +++ b/db/migrate/20260716154624_drop_coplan_automated_plan_reviewers.co_plan.rb @@ -0,0 +1,21 @@ +# This migration comes from co_plan (originally 20260602170000) +class DropCoplanAutomatedPlanReviewers < ActiveRecord::Migration[8.0] + def up + drop_table :coplan_automated_plan_reviewers + end + + def down + create_table :coplan_automated_plan_reviewers, id: { type: :string, limit: 36 } do |t| + t.string :key, null: false + t.string :name, null: false + t.text :prompt_text, null: false + t.string :ai_provider, default: "openai", null: false + t.string :ai_model, null: false + t.json :trigger_statuses, null: false + t.boolean :enabled, default: true, null: false + t.timestamps + end + + add_index :coplan_automated_plan_reviewers, :key, unique: true + end +end diff --git a/db/migrate/20260716154625_create_coplan_folders.co_plan.rb b/db/migrate/20260716154625_create_coplan_folders.co_plan.rb new file mode 100644 index 00000000..ed34219d --- /dev/null +++ b/db/migrate/20260716154625_create_coplan_folders.co_plan.rb @@ -0,0 +1,24 @@ +# This migration comes from co_plan (originally 20260716000000) +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/db/schema.rb b/db/schema.rb index 2c517578..f656ba94 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_09_185102) do +ActiveRecord::Schema[8.1].define(version: 2026_07_16_154625) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -108,6 +108,16 @@ t.index ["plan_version_id"], name: "fk_rails_14c3f0737b" end + create_table "coplan_folders", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "created_by_user_id", limit: 36 + t.string "name", null: false + t.string "parent_id", limit: 36 + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_coplan_folders_on_created_by_user_id" + t.index ["parent_id", "name"], name: "index_coplan_folders_on_parent_id_and_name", unique: true + end + create_table "coplan_notifications", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "comment_id", limit: 36 t.string "comment_thread_id", limit: 36, null: false @@ -210,6 +220,7 @@ t.string "created_by_user_id", limit: 36, null: false t.string "current_plan_version_id", limit: 36 t.integer "current_revision", default: 0, null: false + t.string "folder_id", limit: 36 t.json "metadata" t.string "plan_type_id", limit: 36 t.text "search_text", size: :medium @@ -221,6 +232,7 @@ t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_coplan_plans_on_created_by_user_id" t.index ["current_plan_version_id"], name: "fk_rails_c401577583" + t.index ["folder_id"], name: "index_coplan_plans_on_folder_id" t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["search_text"], name: "index_coplan_plans_on_search_text", type: :fulltext t.index ["status"], name: "index_coplan_plans_on_status" @@ -303,6 +315,8 @@ add_foreign_key "coplan_edit_leases", "coplan_plans", column: "plan_id" add_foreign_key "coplan_edit_sessions", "coplan_plan_versions", column: "plan_version_id" add_foreign_key "coplan_edit_sessions", "coplan_plans", column: "plan_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_notifications", "coplan_comment_threads", column: "comment_thread_id" add_foreign_key "coplan_notifications", "coplan_comments", column: "comment_id" add_foreign_key "coplan_notifications", "coplan_plans", column: "plan_id" @@ -315,6 +329,7 @@ add_foreign_key "coplan_plan_versions", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_viewers", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_viewers", "coplan_users", column: "user_id" + add_foreign_key "coplan_plans", "coplan_folders", column: "folder_id" add_foreign_key "coplan_plans", "coplan_plan_types", column: "plan_type_id" add_foreign_key "coplan_plans", "coplan_plan_versions", column: "current_plan_version_id" add_foreign_key "coplan_plans", "coplan_users", column: "created_by_user_id" diff --git a/db/seeds.rb b/db/seeds.rb index d510d7f6..451f3473 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -125,4 +125,14 @@ q3 = CoPlan::Plan.find_by(title: "Q3 Product Roadmap") q3.tag_names = ["roadmap", "product"] if q3 && q3.tags.empty? -puts "Done! #{CoPlan::User.count} users, #{CoPlan::Plan.count} plans, #{CoPlan::CommentThread.count} threads, #{CoPlan::Comment.count} comments, #{CoPlan::ApiToken.count} API tokens." +puts "Seeding folders..." +if CoPlan::Folder.count == 0 + product = CoPlan::Folder.find_or_create_by_path!("Product/Q3", created_by_user: hampton) + infra = CoPlan::Folder.find_or_create_by_path!("Infrastructure", created_by_user: hampton) + + q3&.update!(folder: product) if q3&.folder_id.nil? + api_seed = CoPlan::Plan.find_by(title: "API Rate Limiting Strategy") + api_seed&.update!(folder: infra) if api_seed&.folder_id.nil? +end + +puts "Done! #{CoPlan::User.count} users, #{CoPlan::Plan.count} plans, #{CoPlan::Folder.count} folders, #{CoPlan::CommentThread.count} threads, #{CoPlan::Comment.count} comments, #{CoPlan::ApiToken.count} API tokens." diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 1e1ca759..30a264c4 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -713,32 +713,6 @@ img.avatar { flex-wrap: wrap; } -/* Plans toolbar (compact filter bar at top of index) */ -.plans-toolbar { - display: flex; - flex-direction: column; - gap: var(--space-xs); - margin-bottom: var(--space-md); -} - -.plans-toolbar__row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-xs); -} - -.plans-toolbar__row--secondary .status-filter { - font-size: 0.7rem; -} - -.plans-toolbar__divider { - width: 1px; - height: 16px; - background: var(--color-border); - margin: 0 var(--space-xs); -} - .status-filter { padding: 2px var(--space-sm); font-size: var(--text-sm); @@ -769,79 +743,6 @@ img.avatar { gap: var(--space-sm); } -.plans-list__item { - padding: var(--space-md); - display: flex; - flex-direction: column; - gap: var(--space-xs); -} - -.plans-list__section { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-muted); - margin: var(--space-sm) 0 0; -} - -.plans-list__section:first-child { - margin-top: 0; -} - -.plans-list__header { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-xs); -} - -.plans-list__title { - font-size: var(--text-base); - font-weight: 600; - color: var(--color-text); - margin-right: var(--space-xs); -} - -.plans-list__title:hover { - color: var(--color-primary); -} - -.plans-list__summary { - margin: 0; - font-size: var(--text-sm); - color: var(--color-text-muted); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.plans-list__meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-xs); -} - -.plans-list__meta-item { - display: inline-flex; - align-items: center; - gap: var(--space-xs); -} - -.plans-list__meta-item + .plans-list__meta-item::before { - content: "·"; - color: var(--color-text-muted); - opacity: 0.5; - margin-right: var(--space-xs); -} - -.plans-list__tags { - flex-wrap: wrap; -} - .active-filter { display: flex; align-items: center; @@ -3159,3 +3060,511 @@ body:not(:has(.comment-toolbar)) .web-push-banner { font-size: var(--text-xs); background: var(--color-bg-muted); } + +/* ============================================================ + Plans workspace (sidebar + grouped index) + ============================================================ */ + +.workspace { + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + gap: var(--space-xl); + align-items: start; +} + +.workspace__sidebar { + position: sticky; + top: calc(var(--nav-height) + var(--space-md)); + display: flex; + flex-direction: column; + gap: var(--space-lg); + font-size: var(--text-sm); + min-width: 0; +} + +.workspace__main { + min-width: 0; +} + +.workspace__filters { + flex-wrap: wrap; +} + +.workspace__filter-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 2px var(--space-sm); + border: 1px solid var(--color-border); + border-radius: 9999px; + background: var(--color-surface); + font-size: 0.8rem; +} + +.workspace__filter-clear { + color: var(--color-text-muted); + text-decoration: none; + font-size: 0.75rem; +} + +.workspace__filter-clear:hover { + color: var(--color-danger); +} + +/* Sidebar sections */ +.sidebar__heading { + margin: 0 0 var(--space-xs); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} + +.sidebar__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.sidebar__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: 3px var(--space-sm); + border-radius: var(--radius); + color: var(--color-text); + text-decoration: none; +} + +.sidebar__item:hover { + background: var(--color-bg-muted); + text-decoration: none; +} + +.sidebar__item--active { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 500; +} + +.sidebar__item-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar__count { + font-size: 0.7rem; + color: var(--color-text-muted); + flex-shrink: 0; +} + +.sidebar__hint { + margin: var(--space-sm) 0 0; + line-height: 1.4; +} + +.sidebar__new-folder { + margin-top: var(--space-sm); +} + +.sidebar__new-folder-toggle { + cursor: pointer; + color: var(--color-text-muted); + font-size: 0.8rem; + list-style: none; +} + +.sidebar__new-folder-toggle::-webkit-details-marker { + display: none; +} + +.sidebar__new-folder-toggle:hover { + color: var(--color-primary); +} + +.sidebar__new-folder-form { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-top: var(--space-xs); +} + +.sidebar__new-folder-input, +.sidebar__new-folder-select { + width: 100%; + padding: 4px var(--space-sm); + font-size: var(--text-sm); + font-family: inherit; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); +} + +/* Folder tree */ +.folder-tree { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.folder-tree--nested { + padding-left: var(--space-md); +} + +.folder-tree__branch > .folder-tree__summary { + display: flex; + align-items: center; + cursor: pointer; + list-style: none; +} + +.folder-tree__summary::-webkit-details-marker { + display: none; +} + +.folder-tree__summary::before { + content: ""; + flex-shrink: 0; + width: 0.9rem; + height: 0.9rem; + margin-right: -0.9rem; /* overlap the link's padding */ + position: relative; + left: -0.15rem; + background: currentColor; + color: var(--color-text-muted); + -webkit-mask: url('data:image/svg+xml,') center / contain no-repeat; + mask: url('data:image/svg+xml,') center / contain no-repeat; + transition: transform 0.15s; +} + +.folder-tree__branch[open] > .folder-tree__summary::before { + transform: rotate(90deg); +} + +.folder-tree__link { + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: 3px var(--space-sm) 3px 1.1rem; + border-radius: var(--radius); + color: var(--color-text); + text-decoration: none; + min-width: 0; +} + +/* Leaf nodes (no
) keep the same left padding so labels align. */ +.folder-tree__node > .folder-tree__link { + display: flex; +} + +.folder-tree__link:hover { + background: var(--color-bg-muted); + text-decoration: none; +} + +.folder-tree__link--active { + background: var(--color-primary-light); + color: var(--color-primary); + font-weight: 500; +} + +.folder-tree__link--drop { + outline: 2px dashed var(--color-primary); + outline-offset: -2px; + background: var(--color-primary-light); +} + +.folder-tree__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* While dragging a row, make every folder read as a target. */ +.workspace--dragging .folder-tree__link { + outline: 1px dashed var(--color-border-strong); + outline-offset: -1px; +} + +/* Needs attention strip */ +.attention { + margin-bottom: var(--space-lg); + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-warning); + border-radius: var(--radius); + background: var(--color-warning-soft); +} + +.attention__heading { + display: flex; + align-items: center; + gap: var(--space-xs); + margin: 0 0 var(--space-xs); + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-warning); +} + +.attention__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.attention__item { + display: flex; + align-items: baseline; + gap: var(--space-sm); + font-size: var(--text-sm); +} + +.attention__link { + font-weight: 500; + color: var(--color-text); +} + +.attention__unread { + color: var(--color-text-muted); + font-size: 0.75rem; + white-space: nowrap; +} + +/* Status groups */ +.plan-group + .plan-group { + margin-top: var(--space-md); +} + +.plan-group__heading { + margin: 0 0 var(--space-xs); +} + +.plan-group__toggle { + display: flex; + align-items: center; + gap: var(--space-xs); + width: 100%; + padding: 2px 0; + background: none; + border: none; + cursor: pointer; + font-family: inherit; + text-align: left; +} + +.plan-group__chevron { + color: var(--color-text-muted); + transition: transform 0.15s; + transform: rotate(90deg); +} + +.plan-group--collapsed .plan-group__chevron { + transform: rotate(0deg); +} + +.plan-group__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.plan-group__label--brainstorm { color: var(--color-status-brainstorm); } +.plan-group__label--considering { color: var(--color-status-considering); } +.plan-group__label--developing { color: var(--color-status-developing); } +.plan-group__label--live { color: var(--color-status-live); } +.plan-group__label--abandoned { color: var(--color-status-abandoned); } + +.plan-group__count { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.plan-group--collapsed .plan-group__body { + display: none; +} + +/* Compact plan rows */ +.plan-row { + padding: var(--space-sm) var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + margin-bottom: var(--space-xs); +} + +.plan-row--dragging { + opacity: 0.5; +} + +.plan-row__line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.plan-row__title { + font-weight: 600; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.plan-row__title:hover { + color: var(--color-primary); +} + +.plan-row__folder { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.75rem; + color: var(--color-text-muted); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 16rem; +} + +.plan-row__folder:hover { + color: var(--color-primary); + text-decoration: none; +} + +.plan-row__spacer { + flex: 1; +} + +.plan-row__time { + font-size: 0.75rem; + white-space: nowrap; +} + +.plan-row__avatar { + display: inline-flex; +} + +.plan-row__summary { + margin: var(--space-xs) 0 0; + font-size: var(--text-sm); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Row menu (move-to-folder fallback) */ +.plan-row__menu { + position: relative; +} + +.plan-row__menu-toggle { + cursor: pointer; + list-style: none; + padding: 0 var(--space-xs); + border-radius: var(--radius); + color: var(--color-text-muted); + font-weight: 700; + line-height: 1.2; +} + +.plan-row__menu-toggle::-webkit-details-marker { + display: none; +} + +.plan-row__menu-toggle:hover { + background: var(--color-bg-muted); + color: var(--color-text); +} + +.plan-row__menu-panel { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 30; + min-width: 14rem; + padding: var(--space-sm); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow-pop); +} + +.plan-row__move-form { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.plan-row__move-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} + +.plan-row__move-select { + width: 100%; + padding: 4px var(--space-sm); + font-size: var(--text-sm); + font-family: inherit; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); +} + +/* Toasts (drag & drop feedback) */ +.toasts { + position: fixed; + bottom: var(--space-lg); + right: var(--space-lg); + z-index: 100; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.toasts__toast { + margin: 0; + box-shadow: var(--shadow-lg); +} + +/* Narrow viewports: stack the sidebar above the list */ +@media (max-width: 56rem) { + .workspace { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-md); + } + + .workspace__sidebar { + position: static; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: var(--space-md); + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--color-border); + } +} diff --git a/engine/app/controllers/coplan/api/v1/folders_controller.rb b/engine/app/controllers/coplan/api/v1/folders_controller.rb new file mode 100644 index 00000000..3139bd7b --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/folders_controller.rb @@ -0,0 +1,100 @@ +module CoPlan + module Api + module V1 + class FoldersController < BaseController + before_action :set_folder, only: [ :update, :destroy ] + + def index + folders = Folder.order(:name).to_a + paths = Folder.paths_by_id(folders) + + # Visible-plan counts only — never leak the existence of other + # users' private brainstorm plans through folder counts. + counts = Plan.visible_to(current_user) + .where.not(folder_id: nil) + .group(:folder_id) + .count + + render json: folders.map { |f| folder_json(f, paths: paths, counts: counts) } + end + + def create + parent = nil + if params[:parent_id].present? + parent = Folder.find_by(id: params[:parent_id]) + return render json: { error: "Unknown parent_id" }, status: :unprocessable_content unless parent + end + + folder = Folder.create!( + name: params[:name], + parent: parent, + created_by_user: current_user + ) + render json: folder_json(folder), status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content + end + + def update + policy = FolderPolicy.new(current_user, @folder) + unless policy.update? + return render json: { error: "Not authorized" }, status: :forbidden + end + + attrs = {} + attrs[:name] = params[:name] if params.key?(:name) + if params.key?(:parent_id) + if params[:parent_id].present? + parent = Folder.find_by(id: params[:parent_id]) + return render json: { error: "Unknown parent_id" }, status: :unprocessable_content unless parent + attrs[:parent] = parent + else + attrs[:parent] = nil + end + end + + @folder.update!(attrs) + render json: folder_json(@folder) + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content + end + + def destroy + policy = FolderPolicy.new(current_user, @folder) + unless policy.destroy? + return render json: { error: "Not authorized" }, status: :forbidden + end + + if @folder.destroy + head :no_content + else + render json: { error: @folder.errors.full_messages.join(", ") }, status: :unprocessable_content + end + end + + private + + def set_folder + @folder = Folder.find_by(id: params[:id]) + render json: { error: "Folder not found" }, status: :not_found unless @folder + end + + # `paths` and `counts` let index serialize the whole tree without + # per-folder queries. `plans_count` is the folder's own visible + # plans (not including subfolders). + def folder_json(folder, paths: nil, counts: nil) + { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id, + path: paths ? paths[folder.id] : folder.path, + plans_count: counts ? counts.fetch(folder.id, 0) : folder.plans.visible_to(current_user).count, + created_by: folder.created_by_user&.name, + created_at: folder.created_at, + updated_at: folder.updated_at + } + end + end + end + end +end diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index 6875a4e2..60f9390e 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -2,16 +2,16 @@ module CoPlan module Api module V1 class PlansController < BaseController - before_action :set_plan, only: [:show, :update, :versions, :comments, :snapshot] - before_action :authorize_plan_access!, only: [:show, :update, :versions, :comments, :snapshot] + before_action :set_plan, only: [ :show, :update, :versions, :comments, :snapshot ] + before_action :authorize_plan_access!, only: [ :show, :update, :versions, :comments, :snapshot ] def index plans = Plan - .includes(:plan_type, :created_by_user) - .where.not(status: "brainstorm") - .or(Plan.where(created_by_user: current_user)) + .includes(:plan_type, :created_by_user, folder: { parent: :parent }) + .visible_to(current_user) .order(updated_at: :desc) plans = plans.where(status: params[:status]) if params[:status].present? + plans = plans.where(folder_id: params[:folder_id]) if params[:folder_id].present? render json: plans.map { |p| plan_json(p) } end @@ -76,9 +76,22 @@ def update old_title = @plan.title old_status = @plan.status old_tag_names = @plan.tag_names + old_folder_path = @plan.folder&.path - @plan.tag_names = params[:tags] if params.key?(:tags) - @plan.update!(permitted) + # Folder resolution (which may create folders via folder_path) and + # the plan update are one transaction: a request combining + # folder_path with an invalid attribute must not leave behind + # orphaned shared folders for a move that never happened. + ActiveRecord::Base.transaction do + if params.key?(:folder_id) || params.key?(:folder_path) + folder = resolve_folder_params + return if performed? # resolve_folder_params rendered an error + @plan.folder = folder + end + + @plan.tag_names = params[:tags] if params.key?(:tags) + @plan.update!(permitted) + end if @plan.saved_changes? Broadcaster.replace_to(@plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: @plan }) @@ -110,6 +123,14 @@ def update end end + if @plan.saved_change_to_folder_id? + Plans::LogEvent.call( + plan: @plan, actor: current_user, event_type: "moved_to_folder", + before: old_folder_path, after: @plan.folder&.path, + actor_type: api_author_type, actor_id: api_actor_id + ) + end + if params.key?(:tags) new_tag_names = @plan.tag_names (new_tag_names - old_tag_names).each do |added| @@ -180,6 +201,22 @@ def snapshot private + # Resolves `folder_id` / `folder_path` update params to a Folder (or + # nil to clear). `folder_path` finds-or-creates the hierarchy, which + # is what lets an AI librarian agent organize plans into folders that + # don't exist yet. Renders an error and returns early on bad input. + def resolve_folder_params + if params[:folder_id].present? + folder = Folder.find_by(id: params[:folder_id]) + render json: { error: "Unknown folder_id" }, status: :unprocessable_content unless folder + folder + elsif params[:folder_path].present? + Folder.find_or_create_by_path!(params[:folder_path], created_by_user: current_user) + else + nil # blank folder_id / folder_path clears the folder + end + end + def plan_json(plan) { id: plan.id, @@ -187,6 +224,8 @@ def plan_json(plan) status: plan.status, current_revision: plan.current_revision, tags: plan.tag_names, + folder_id: plan.folder_id, + folder_path: plan.folder&.path, plan_type_id: plan.plan_type_id, plan_type_name: plan.plan_type&.name, created_by: plan.created_by_user&.name, diff --git a/engine/app/controllers/coplan/api/v1/references_controller.rb b/engine/app/controllers/coplan/api/v1/references_controller.rb index 5edb5b5c..4faa5fa4 100644 --- a/engine/app/controllers/coplan/api/v1/references_controller.rb +++ b/engine/app/controllers/coplan/api/v1/references_controller.rb @@ -2,9 +2,9 @@ module CoPlan module Api module V1 class ReferencesController < BaseController - before_action :set_plan, only: [:index, :create, :destroy] - before_action :authorize_plan_access!, only: [:index, :create, :destroy] - before_action :authorize_plan_write!, only: [:create, :destroy] + before_action :set_plan, only: [ :index, :create, :destroy ] + before_action :authorize_plan_access!, only: [ :index, :create, :destroy ] + before_action :authorize_plan_write!, only: [ :create, :destroy ] def index references = @plan.references.order(created_at: :desc) @@ -53,8 +53,7 @@ def search return end - visible_plans = Plan.where.not(status: "brainstorm") - .or(Plan.where(created_by_user: current_user)) + visible_plans = Plan.visible_to(current_user) references = Reference.where(url: url, plan_id: visible_plans.select(:id)) .includes(:plan) diff --git a/engine/app/controllers/coplan/api/v1/tags_controller.rb b/engine/app/controllers/coplan/api/v1/tags_controller.rb index 0c6521b1..127576d8 100644 --- a/engine/app/controllers/coplan/api/v1/tags_controller.rb +++ b/engine/app/controllers/coplan/api/v1/tags_controller.rb @@ -3,8 +3,7 @@ module Api module V1 class TagsController < BaseController def index - visible_plans = Plan.where.not(status: "brainstorm") - .or(Plan.where(created_by_user: current_user)) + visible_plans = Plan.visible_to(current_user) tags = Tag .joins(:plan_tags) diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 09e978e6..a3d50879 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -12,6 +12,7 @@ def self.controller_path helper CoPlan::CommentsHelper helper CoPlan::ReferencesHelper helper CoPlan::PlanEventsHelper + helper CoPlan::FoldersHelper # Skip host auth — CoPlan handles authentication internally via config.authenticate skip_before_action :authenticate_user!, raise: false diff --git a/engine/app/controllers/coplan/folders_controller.rb b/engine/app/controllers/coplan/folders_controller.rb new file mode 100644 index 00000000..e425999f --- /dev/null +++ b/engine/app/controllers/coplan/folders_controller.rb @@ -0,0 +1,32 @@ +module CoPlan + # Web-side folder creation for the plans sidebar ("New folder" form). + # Folder rename/delete happen through the API or admin UI for now. + class FoldersController < ApplicationController + def create + parent = nil + if params.dig(:folder, :parent_id).present? + parent = Folder.find_by(id: params.dig(:folder, :parent_id)) + if parent.nil? + # Don't silently create a root folder when the chosen parent has + # since been deleted (matches the API's unknown-parent handling). + redirect_back fallback_location: plans_path, + alert: "Couldn't create folder: the parent folder no longer exists." + return + end + end + + folder = Folder.new( + name: params.dig(:folder, :name), + parent: parent, + created_by_user: current_user + ) + + if folder.save + redirect_to plans_path(folder: folder.id), notice: "Folder “#{folder.name}” created." + else + redirect_back fallback_location: plans_path, + alert: "Couldn't create folder: #{folder.errors.full_messages.join(", ")}" + end + end + end +end diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index a9ef383c..358efbd8 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -1,58 +1,131 @@ module CoPlan class PlansController < ApplicationController - before_action :set_plan, only: [:show, :edit, :update, :update_status, :toggle_checkbox, :history, :edit_content, :update_content, :preview] + before_action :set_plan, only: [:show, :edit, :update, :update_status, :move_to_folder, :toggle_checkbox, :history, :edit_content, :update_content, :preview] PER_PAGE = 20 SCOPES = %w[mine all].freeze DEFAULT_SCOPE = "mine".freeze + # Display order for the main-pane status groups: active work first, + # brainstorms (collapsed by default) and abandoned plans last. + STATUS_GROUP_ORDER = %w[developing considering live brainstorm abandoned].freeze + + # Sidebar workspace index. Two rendering modes: + # + # - Grouped (default): collapsible status groups, each with its own + # "load more" turbo-frame pagination (frames carry group + status + # params back here). + # - Flat: when ?status= filters to a single status, one recency-sorted + # paginated list. + # + # Turbo-frame requests are always page fetches for one of those lists + # and render only the row page partial. def index @scope = SCOPES.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE + load_folder_tree - plans = Plan.includes(:plan_type, :tags, :created_by_user, :current_plan_version) - - if @scope == "mine" - plans = plans.where(created_by_user: current_user) - else - plans = plans.where.not(status: "brainstorm") - .or(Plan.where(created_by_user: current_user)) + if params[:folder].present? + @folder = @folders_by_id[params[:folder]] + if @folder.nil? && !turbo_frame_request? + redirect_to plans_path(params.permit(:scope, :status, :plan_type, :tag).to_h), + alert: "That folder no longer exists." + return + end end - plans = plans.where(status: params[:status]) if params[:status].present? + plans = scoped_plans_base.includes(:plan_type, :tags, :created_by_user, :current_plan_version, :folder) + plans = plans.where(plan_type_id: params[:plan_type]) if params[:plan_type].present? plans = plans.with_tag(params[:tag]) if params[:tag].present? + # A folder filter includes its subfolders — clicking "Team EBT" shows + # everything under it. + plans = plans.where(folder_id: folder_subtree_ids(@folder)) if @folder + # Stale frame fetch for a since-deleted folder: render an empty page. + plans = plans.none if params[:folder].present? && @folder.nil? + + if params[:status].present? || turbo_frame_request? + plans = plans.where(status: params[:status]) if params[:status].present? + plans = plans.order(updated_at: :desc, id: :desc) + + @page = [ params[:page].to_i, 1 ].max + @plans = plans.limit(PER_PAGE + 1).offset((@page - 1) * PER_PAGE) + @has_next_page = @plans.size > PER_PAGE + @plans = @plans.first(PER_PAGE) + @plan_unread_counts = unread_counts_for(@plans) + + if turbo_frame_request? + render partial: "coplan/plans/plan_page", + locals: { + plans: @plans, + plan_unread_counts: @plan_unread_counts, + page: @page, + has_next_page: @has_next_page, + group_key: params[:group].presence || "results", + frame_status: params[:status].presence + }, + layout: false + return + end + else + @group_counts = plans.group(:status).count + @groups = STATUS_GROUP_ORDER.filter_map do |status| + count = @group_counts[status].to_i + next if count.zero? + + group_plans = plans.where(status: status) + .order(updated_at: :desc, id: :desc) + .limit(PER_PAGE + 1).to_a + { + status: status, + count: count, + plans: group_plans.first(PER_PAGE), + has_next_page: group_plans.size > PER_PAGE + } + end + @plan_unread_counts = unread_counts_for(@groups.flat_map { |g| g[:plans] }) + end - # Group "My Plans" by status (active → brainstorm) when not already filtered - # to a single status. The "All" view stays sorted by recency. - @grouped_by_status = @scope == "mine" && params[:status].blank? - plans = @grouped_by_status ? plans.prioritized_by_status : plans.order(updated_at: :desc, id: :desc) + load_workspace_sidebar + load_needs_attention + @plan_types = PlanType.order(:name) + @show_onboarding_banner = CoPlan.configuration.onboarding_banner.present? && + !current_user.created_plans.exists? + end - @page = (params[:page] || 1).to_i - @plans = plans.limit(PER_PAGE + 1).offset((@page - 1) * PER_PAGE) - @has_next_page = @plans.size > PER_PAGE - @plans = @plans.first(PER_PAGE) + # Web endpoint behind the sidebar drag-and-drop and the row-menu + # "Move to folder" fallback. Author-only (PlanPolicy#update?). + def move_to_folder + authorize!(@plan, :update?) - @plan_unread_counts = current_user.notifications.unread - .where(plan_id: @plans.map(&:id)) - .group(:plan_id) - .count + folder = nil + if params[:folder_id].present? + folder = Folder.find_by(id: params[:folder_id]) + unless folder + respond_to do |format| + format.json { render json: { error: "Unknown folder" }, status: :unprocessable_content } + format.html { redirect_back fallback_location: plans_path, alert: "Unknown folder." } + end + return + end + end - if turbo_frame_request? - 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, - previous_status: params[:prev_status].presence, - }, - layout: false - else - @plan_types = PlanType.order(:name) - @show_onboarding_banner = CoPlan.configuration.onboarding_banner.present? && - !current_user.created_plans.exists? + if @plan.folder_id != folder&.id + old_path = @plan.folder&.path + @plan.update!(folder: folder) + Plans::LogEvent.call( + plan: @plan, + actor: current_user, + event_type: "moved_to_folder", + before: old_path, + after: folder&.path + ) + end + + notice = folder ? "Moved “#{@plan.title}” to #{folder.path}." : "Removed “#{@plan.title}” from its folder." + respond_to do |format| + format.json { render json: { folder_id: @plan.folder_id, folder_path: @plan.folder&.path, message: notice } } + format.html { redirect_back fallback_location: plans_path, notice: notice } end end @@ -274,6 +347,95 @@ def toggle_checkbox private + def unread_counts_for(plans) + current_user.notifications.unread + .where(plan_id: plans.map(&:id)) + .group(:plan_id) + .count + end + + # The base relation for the active workspace scope. Used by both the + # main-pane plan lists and the sidebar counts so folder/tag counts + # always match what clicking through shows. + def scoped_plans_base + if @scope == "mine" + Plan.where(created_by_user: current_user) + else + # Brainstorm plans are private drafts — never show other users'. + Plan.visible_to(current_user) + end + end + + # One query for the whole folder tree; everything else (children map, + # subtree ids, expanded state, aggregate counts) is derived in memory. + def load_folder_tree + @folders = Folder.order(:name).to_a + @folders_by_id = @folders.index_by(&:id) + @folder_children = @folders.group_by(&:parent_id) + @root_folders = @folder_children[nil] || [] + end + + # Ids of a folder plus all folders nested under it, walked over the + # in-memory tree (the visited check doubles as a cycle guard). + def folder_subtree_ids(folder) + ids = [] + queue = [ folder.id ] + while (id = queue.shift) + next if ids.include?(id) + ids << id + queue.concat((@folder_children[id] || []).map(&:id)) + end + ids + end + + # Sidebar data: the folder tree with per-folder plan counts, and the + # most-used tags. Counts and tag usage use the same base relation as + # the main pane (scoped_plans_base) so they match what clicking shows — + # which also means other users' private brainstorm plans never leak + # through folder counts or tag lists (Plan.visible_to). + def load_workspace_sidebar + direct_counts = scoped_plans_base + .where.not(folder_id: nil) + .group(:folder_id) + .count + # Displayed counts include subfolders, matching what clicking the + # folder shows. + @folder_counts = @folders.index_with do |folder| + folder_subtree_ids(folder).sum { |id| direct_counts.fetch(id, 0) } + end.transform_keys(&:id) + + # Folder nodes rendered expanded: the active folder and its ancestors. + @open_folder_ids = Set.new + node = @folder + while node && @open_folder_ids.add?(node.id) + node = @folders_by_id[node.parent_id] + end + + @top_tags = Tag + .joins(:plan_tags) + .where(coplan_plan_tags: { plan_id: scoped_plans_base.select(:id) }) + .group("coplan_tags.id", "coplan_tags.name") + .order(Arel.sql("COUNT(*) DESC"), "coplan_tags.name ASC") + .limit(8) + .count + .map { |(_id, name), count| [ name, count ] } + end + + ATTENTION_LIMIT = 5 + + # "Needs attention" strip: plans with unread comment notifications for + # the current user, most-unread first. Independent of the active + # sidebar filters — it's an inbox, not a search result. Bounded: only + # the top ATTENTION_LIMIT plans are loaded. + def load_needs_attention + unread_by_plan = current_user.notifications.unread.group(:plan_id).count + @attention_unread_counts = unread_by_plan + top_ids = unread_by_plan.sort_by { |_id, count| -count } + .first(ATTENTION_LIMIT).map(&:first) + @attention_plans = Plan.where(id: top_ids) + .sort_by { |plan| -unread_by_plan.fetch(plan.id, 0) } + end + # Maps a verified (line, old_text) pair to the occurrence ordinal the # position resolver will select, keeping the toggle a plain # replace_exact. Returns nil unless the line's rstripped text is exactly diff --git a/engine/app/helpers/coplan/folders_helper.rb b/engine/app/helpers/coplan/folders_helper.rb new file mode 100644 index 00000000..b58be898 --- /dev/null +++ b/engine/app/helpers/coplan/folders_helper.rb @@ -0,0 +1,24 @@ +module CoPlan + module FoldersHelper + # [[path, id, depth], ...] for every folder, sorted by path — used by + # the "New folder" parent select and each row's "Move to folder" menu. + # Memoized per request and seeded from the controller-loaded tree + # (@folders) when available, so rendering many rows doesn't re-query. + def folder_select_options + @_folder_select_options ||= folder_paths_by_id + .map { |id, path| [ path, id, path.count("/") + 1 ] } + .sort_by { |path, _id, _depth| path.downcase } + end + + # Full "Parent/Child" path for a folder without walking associations. + def folder_path_for(folder) + folder_paths_by_id[folder.id] || folder.path + end + + private + + def folder_paths_by_id + @_folder_paths_by_id ||= CoPlan::Folder.paths_by_id(@folders || CoPlan::Folder.order(:name).to_a) + end + end +end diff --git a/engine/app/helpers/coplan/plan_events_helper.rb b/engine/app/helpers/coplan/plan_events_helper.rb index 4d5210f7..ab14f026 100644 --- a/engine/app/helpers/coplan/plan_events_helper.rb +++ b/engine/app/helpers/coplan/plan_events_helper.rb @@ -42,6 +42,12 @@ def render_event_summary(event) url = event.before_value.to_s label = title || url safe_join(["Removed reference ", content_tag(:span, label, class: "history-split__event-link")]) + when "moved_to_folder" + if event.after_value.present? + safe_join(["Moved to folder ", content_tag(:strong, event.after_value)]) + else + safe_join(["Removed from folder ", content_tag(:strong, event.before_value.to_s)]) + end when "comment_deleted" preview = event.metadata.is_a?(Hash) ? event.metadata["body_preview"].to_s.presence : nil if preview diff --git a/engine/app/javascript/controllers/coplan/folder_dnd_controller.js b/engine/app/javascript/controllers/coplan/folder_dnd_controller.js new file mode 100644 index 00000000..7fb87906 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/folder_dnd_controller.js @@ -0,0 +1,89 @@ +import { Controller } from "@hotwired/stimulus" + +// HTML5 drag & drop: drag a plan row (author only — rows are only marked +// draggable for the author) onto a sidebar folder to move the plan there. +// On success we show a toast, then refresh the page so row breadcrumbs, +// group counts, and sidebar folder counts all re-render consistently. +// No-JS fallback: each draggable row also has a "Move to folder" menu that +// submits a plain form to the same endpoint. +const PLAN_MIME = "application/x-coplan-plan" + +export default class extends Controller { + static targets = ["folder"] + + dragStart(event) { + const row = event.currentTarget + event.dataTransfer.effectAllowed = "move" + // The move URL rides along in the drag payload (readable on drop). + event.dataTransfer.setData(PLAN_MIME, row.dataset.moveUrl) + event.dataTransfer.setData("text/plain", row.dataset.planId) + row.classList.add("plan-row--dragging") + this.element.classList.add("workspace--dragging") + } + + dragEnd(event) { + event.currentTarget.classList.remove("plan-row--dragging") + this.element.classList.remove("workspace--dragging") + } + + dragOver(event) { + if (!event.dataTransfer.types.includes(PLAN_MIME)) return + event.preventDefault() + event.dataTransfer.dropEffect = "move" + event.currentTarget.classList.add("folder-tree__link--drop") + } + + dragLeave(event) { + event.currentTarget.classList.remove("folder-tree__link--drop") + } + + drop(event) { + if (!event.dataTransfer.types.includes(PLAN_MIME)) return + event.preventDefault() + const target = event.currentTarget + target.classList.remove("folder-tree__link--drop") + const moveUrl = event.dataTransfer.getData(PLAN_MIME) + if (!moveUrl) return + + const token = document.querySelector('meta[name="csrf-token"]')?.content + fetch(moveUrl, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": token, + }, + body: JSON.stringify({ folder_id: target.dataset.folderId || "" }), + }) + .then(async response => { + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || "Move failed") + // Re-render so breadcrumbs and folder/group counts stay accurate, + // then toast on the fresh page. + const message = data.message || "Plan moved." + if (window.Turbo) { + document.addEventListener("turbo:load", () => this.#toast(message, "notice"), { once: true }) + window.Turbo.visit(window.location.href, { action: "replace" }) + } else { + this.#toast(message, "notice") + } + }) + .catch(error => this.#toast(error.message, "alert")) + } + + #toast(message, kind) { + let container = document.getElementById("coplan-toasts") + if (!container) { + container = document.createElement("div") + container.id = "coplan-toasts" + container.className = "toasts" + document.body.appendChild(container) + } + const toast = document.createElement("div") + toast.className = `flash flash--${kind} toasts__toast` + toast.setAttribute("role", "status") + toast.textContent = message + container.appendChild(toast) + setTimeout(() => toast.remove(), 4000) + } +} diff --git a/engine/app/javascript/controllers/coplan/plan_groups_controller.js b/engine/app/javascript/controllers/coplan/plan_groups_controller.js new file mode 100644 index 00000000..96c3d992 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/plan_groups_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus" + +// Collapsible status groups on the plans index. Collapsed state is +// persisted per user in localStorage; groups marked with +// [data-default-collapsed] (brainstorms) start collapsed until the user +// explicitly expands them. +const STORAGE_KEY = "coplan:plans:collapsed-groups" + +export default class extends Controller { + static targets = ["group"] + + connect() { + this.groupTargets.forEach(group => this.#apply(group)) + } + + toggle(event) { + const group = event.currentTarget.closest("[data-group-key]") + if (!group) return + + const state = this.#readState() + state[group.dataset.groupKey] = !this.#isCollapsed(group, state) + this.#writeState(state) + this.#apply(group) + } + + #apply(group) { + const collapsed = this.#isCollapsed(group, this.#readState()) + group.classList.toggle("plan-group--collapsed", collapsed) + const button = group.querySelector(".plan-group__toggle") + if (button) button.setAttribute("aria-expanded", String(!collapsed)) + } + + #isCollapsed(group, state) { + const key = group.dataset.groupKey + if (Object.prototype.hasOwnProperty.call(state, key)) return Boolean(state[key]) + return group.hasAttribute("data-default-collapsed") + } + + #readState() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {} + } catch { + return {} + } + } + + #writeState(state) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) + } catch { + // localStorage unavailable (private mode etc.) — collapse still works + // for the current page, it just won't persist. + } + } +} diff --git a/engine/app/models/coplan/folder.rb b/engine/app/models/coplan/folder.rb new file mode 100644 index 00000000..68fad339 --- /dev/null +++ b/engine/app/models/coplan/folder.rb @@ -0,0 +1,169 @@ +module CoPlan + # A shared, org-wide location for plans — Dropbox Paper / Google Docs style. + # Folders form a small hierarchy (max MAX_DEPTH levels) and each plan lives + # in at most one folder (Plan#folder_id). Tags remain the cross-cutting + # labels; folders answer "where does this plan live?". + # + # Anyone signed in can create folders and move their own plans into or out + # of any folder. Rename/delete is limited to the folder's creator or an + # admin (see FolderPolicy). + class Folder < ApplicationRecord + MAX_DEPTH = 3 + + # "/" is reserved as the path separator for folder_path lookups + # (e.g. "Team EBT/Q3"), so it can't appear in a folder name. + NAME_FORMAT = %r{\A[^/]+\z} + + belongs_to :parent, class_name: "CoPlan::Folder", optional: true, inverse_of: :children + has_many :children, class_name: "CoPlan::Folder", foreign_key: :parent_id, + inverse_of: :parent, dependent: nil + belongs_to :created_by_user, class_name: "CoPlan::User", optional: true + has_many :plans, class_name: "CoPlan::Plan", foreign_key: :folder_id, + inverse_of: :folder, dependent: nil + + validates :name, presence: true, + uniqueness: { scope: :parent_id, case_sensitive: false }, + format: { with: NAME_FORMAT, message: "cannot contain \"/\"" }, + length: { maximum: 100 } + validate :parent_cannot_create_cycle + validate :depth_within_limit + before_destroy :ensure_empty + + # Root-first chain of parents, excluding self. + def ancestors + node = parent + chain = [] + while node + # Cycle guard — validations prevent cycles, but never loop forever + # on bad data. + break if chain.include?(node) || node.id == id + chain << node + node = node.parent + end + chain.reverse + end + + # All folders nested under this one (children, grandchildren, ...). + # Cycle-guarded like #ancestors so bad data can't recurse forever. + def descendants + collect_descendants(Set.new([ id ])) + end + + # 1 for a root folder, 2 for its children, etc. + def depth + ancestors.length + 1 + end + + # Human-readable location, e.g. "Team EBT/Q3". + def path + (ancestors + [ self ]).map(&:name).join("/") + end + + # Finds or creates the folder hierarchy for a "/"-separated path like + # "Team EBT/Q3". This is what lets an AI librarian agent organize plans + # without pre-creating folders. Raises ActiveRecord::RecordInvalid when + # the path is too deep or a segment is invalid. Returns nil for a blank + # path. Lookup is case-insensitive (matching the uniqueness validation); + # creation preserves the given casing. + def self.find_or_create_by_path!(path, created_by_user: nil) + segments = path.to_s.split("/").map(&:strip).reject(&:blank?) + return nil if segments.empty? + + # Transactional so a failure partway (e.g. "A/B/C/D" exceeding + # MAX_DEPTH) doesn't leave half-created hierarchy behind. + transaction do + segments.reduce(nil) do |parent, name| + where(parent_id: parent&.id).where("LOWER(name) = ?", name.downcase).first || + create!(name: name, parent: parent, created_by_user: created_by_user) + end + end + end + + # Full "A/B/C" path for every given folder, keyed by id, computed from + # the in-memory list (no per-folder queries). Shared by the folders API + # and the folder-picker helper. + def self.paths_by_id(folders = order(:name).to_a) + by_id = folders.index_by(&:id) + folders.index_with do |folder| + names = [ folder.name ] + node = folder + while node.parent_id && (node = by_id[node.parent_id]) + names.unshift(node.name) + break if names.length > MAX_DEPTH # cycle guard on bad data + end + names.join("/") + end.transform_keys(&:id) + end + + def self.ransackable_attributes(_auth_object = nil) + %w[id name parent_id created_by_user_id created_at updated_at] + end + + def self.ransackable_associations(_auth_object = nil) + %w[parent children plans created_by_user] + end + + private + + def parent_cannot_create_cycle + return if parent_id.blank? + + if parent_id == id + errors.add(:parent, "cannot be the folder itself") + return + end + + node = parent + seen = Set.new + while node + if node.id == id + errors.add(:parent, "cannot be one of the folder's own subfolders") + return + end + break unless seen.add?(node.id) + node = node.parent + end + end + + def depth_within_limit + return if parent.nil? + # Skip when a cycle error is already present — depth would loop. + return if errors[:parent].any? + + height = persisted? ? subtree_height(Set.new([ id ])) : 0 + if parent.depth + 1 + height > MAX_DEPTH + errors.add(:parent, "would exceed the maximum folder depth of #{MAX_DEPTH}") + end + end + + def ensure_empty + if plans.exists? + errors.add(:base, "Cannot delete a folder that contains plans — move the plans out first") + throw :abort + end + if children.exists? + errors.add(:base, "Cannot delete a folder that contains subfolders — delete or move them first") + throw :abort + end + end + + protected + + # Levels of subfolders below this one (0 when it has none). Used to + # measure subtree height when re-parenting a folder that already has + # children. `visited` guards against cycles in bad data. + def subtree_height(visited) + kids = children.reject { |child| visited.include?(child.id) } + return 0 if kids.empty? + + 1 + kids.map { |child| child.subtree_height(visited << child.id) }.max + end + + def collect_descendants(visited) + children.reject { |child| visited.include?(child.id) }.flat_map do |child| + visited << child.id + [ child ] + child.collect_descendants(visited) + end + end + end +end diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 9b7ba952..a85b892d 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -2,25 +2,10 @@ module CoPlan class Plan < ApplicationRecord STATUSES = %w[brainstorm considering developing live abandoned].freeze - # Order used when grouping "My Plans" on the index: brainstorms first - # (private drafts, most relevant to the author), then active work by - # maturity, abandoned last. Pinning brainstorm to the top keeps the - # author's own brainstorms on page 1 instead of buried past the - # PER_PAGE pagination cut (see COPLAN-32). - STATUS_PRIORITY = %w[brainstorm considering developing live abandoned].freeze - - # Order plans by STATUS_PRIORITY, then most-recently-updated within each group. - scope :prioritized_by_status, -> { - whens = STATUS_PRIORITY.each_with_index.map { |status, i| - sanitize_sql_array(["WHEN status = ? THEN ?", status, i]) - }.join(" ") - order(Arel.sql("CASE #{whens} ELSE #{STATUS_PRIORITY.length} END")) - .order(updated_at: :desc, id: :desc) - } - belongs_to :created_by_user, class_name: "CoPlan::User" belongs_to :current_plan_version, class_name: "PlanVersion", optional: true belongs_to :plan_type, optional: true + belongs_to :folder, optional: true, inverse_of: :plans has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy has_many :plan_events, dependent: :destroy has_many :plan_collaborators, dependent: :destroy @@ -42,6 +27,14 @@ class Plan < ApplicationRecord scope :with_tag, ->(name) { joins(:tags).where(coplan_tags: { name: name }) } + # Plans `user` is allowed to see: everything published (non-brainstorm) + # plus the user's own brainstorms. Brainstorm plans are private drafts — + # any list, count, or folder content shown to a user must go through + # this scope so private brainstorm existence never leaks. + scope :visible_to, ->(user) { + where.not(status: "brainstorm").or(where(created_by_user_id: user.id)) + } + after_save_commit :refresh_search_text!, if: :search_text_needs_refresh? # Sitewide search over a denormalized `search_text` column maintained by @@ -56,7 +49,7 @@ class Plan < ApplicationRecord term = sanitize_fulltext_term(query) return none if term.blank? - where.not(status: "brainstorm").or(where(created_by_user_id: user.id)) + visible_to(user) .where("MATCH(search_text) AGAINST (? IN BOOLEAN MODE)", term) .order(Arel.sql("MATCH(search_text) AGAINST (#{connection.quote(term)} IN BOOLEAN MODE) DESC")) } @@ -102,7 +95,7 @@ def self.build_search_text(plan) end def self.ransackable_attributes(auth_object = nil) - %w[id title status plan_type_id created_by_user_id current_plan_version_id current_revision created_at updated_at] + %w[id title status plan_type_id folder_id created_by_user_id current_plan_version_id current_revision created_at updated_at] end def self.ransackable_associations(auth_object = nil) @@ -123,7 +116,7 @@ def current_content def stripped_content @stripped_content ||= begin content = current_content - content.present? ? Plans::MarkdownTextExtractor.call(content) : [+"", []] + content.present? ? Plans::MarkdownTextExtractor.call(content) : [ +"", [] ] end end diff --git a/engine/app/models/coplan/plan_event.rb b/engine/app/models/coplan/plan_event.rb index 48bf7f7e..56418869 100644 --- a/engine/app/models/coplan/plan_event.rb +++ b/engine/app/models/coplan/plan_event.rb @@ -24,6 +24,7 @@ class PlanEvent < ApplicationRecord reference_added reference_removed comment_deleted + moved_to_folder ].freeze belongs_to :plan diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 832f1501..851a0974 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -4,6 +4,7 @@ class User < ApplicationRecord has_many :api_tokens, dependent: :destroy has_many :created_plans, class_name: "CoPlan::Plan", foreign_key: :created_by_user_id, dependent: :nullify, inverse_of: :created_by_user + has_many :created_folders, class_name: "CoPlan::Folder", foreign_key: :created_by_user_id, dependent: :nullify, inverse_of: :created_by_user has_many :plan_collaborators, dependent: :destroy has_many :notifications, dependent: :destroy has_many :web_push_subscriptions, class_name: "CoPlan::WebPushSubscription", dependent: :destroy diff --git a/engine/app/policies/coplan/folder_policy.rb b/engine/app/policies/coplan/folder_policy.rb new file mode 100644 index 00000000..a3bfdd4b --- /dev/null +++ b/engine/app/policies/coplan/folder_policy.rb @@ -0,0 +1,22 @@ +module CoPlan + # Folders are shared/org-wide: any signed-in user can see them and create + # new ones. Rename/re-parent/delete is limited to the folder's creator or + # an admin so shared structure doesn't get reshuffled by accident. + class FolderPolicy < ApplicationPolicy + def index? + true + end + + def create? + true + end + + def update? + record.created_by_user_id == user.id || admin? + end + + def destroy? + update? + end + end +end diff --git a/engine/app/services/coplan/plans/log_event.rb b/engine/app/services/coplan/plans/log_event.rb index c975488f..2adaeb6d 100644 --- a/engine/app/services/coplan/plans/log_event.rb +++ b/engine/app/services/coplan/plans/log_event.rb @@ -42,7 +42,7 @@ def call # No-op when nothing changed. Callers can fire on every save without # worrying about whether the value actually moved — keeps call sites # short and predictable. - return nil if @before == @after && %w[status_changed title_changed plan_type_changed].include?(@event_type) + return nil if @before == @after && %w[status_changed title_changed plan_type_changed moved_to_folder].include?(@event_type) PlanEvent.create!( plan: @plan, @@ -81,6 +81,7 @@ def default_field_for(event_type) when "tag_added", "tag_removed" then "tags" when "reference_added", "reference_removed" then "references" when "comment_deleted" then "comments" + when "moved_to_folder" then "folder" end end diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index cb40a8af..2207a003 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -103,6 +103,48 @@ Tags categorize plans for discovery. Use them to help people find related plans. - Update tags via `PATCH /api/v1/plans/:id` with `{"tags": ["tag-1", "tag-2"]}`. - Good tags describe the *domain* or *concern* (e.g., `security`, `onboarding`), not the status or team. +### Folders + +Folders are shared, hierarchical **locations** for plans (max 3 levels deep). Each plan lives in at most one folder — think team folders with project subfolders. Tags remain the cross-cutting labels; use folders for "where does this plan live?" and tags for "what is it about?". + +**List folders:** + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/folders" | jq . +``` + +Each folder includes `id`, `name`, `parent_id`, `path` (e.g. `"Team EBT/Q3"`), and `plans_count`. + +**Create a folder:** + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"name": "Q3", "parent_id": "'$PARENT_FOLDER_ID'"}' \ + "<%= @base %>/api/v1/folders" | jq . +``` + +`parent_id` is optional — omit it for a top-level folder. Rename with `PATCH /api/v1/folders/:id` (`{"name": "..."}`); delete with `DELETE /api/v1/folders/:id` (only empty folders can be deleted, and only by their creator or an admin). + +**Move a plan into a folder** (plan author only): + +```bash +<%= @curl %> -X PATCH \ + -H "Content-Type: application/json" \ + -d '{"folder_path": "Team EBT/Q3"}' \ + "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . +``` + +- `folder_path` finds or creates the whole hierarchy — the easiest way to organize plans. +- Alternatively pass `folder_id` with an existing folder's ID. +- Pass an empty `folder_id` (`{"folder_id": ""}`) to move a plan out of its folder. + +**Guidelines:** +- Check `GET /api/v1/folders` before creating new folders — reuse the existing structure. +- Folder names read like places (`Team EBT`, `Infra`, `Q3 Launch`), not labels. +- You can offer to organize a user's plans: list their plans, propose a folder structure, and move plans with `folder_path` once they agree. + ### Plan Statuses Plans move through a lifecycle. **Keep the status current** — update it as the plan progresses. diff --git a/engine/app/views/coplan/plans/_folder_node.html.erb b/engine/app/views/coplan/plans/_folder_node.html.erb new file mode 100644 index 00000000..3935f21a --- /dev/null +++ b/engine/app/views/coplan/plans/_folder_node.html.erb @@ -0,0 +1,32 @@ +<%# One node in the sidebar folder tree. Uses @folder_children / + @folder_counts / @folder / @open_folder_ids from PlansController#index. + Branches with children render as
so the tree is collapsible + without JS; the subtree containing the active folder starts open. %> +<% children = (@folder_children[folder.id] || []).sort_by { |f| f.name.downcase } %> +<% active = @folder&.id == folder.id %> +<% folder_link = capture do %> + <%= link_to plans_path(params.permit(:scope, :status, :plan_type, :tag).merge(folder: folder.id)), + class: "folder-tree__link #{'folder-tree__link--active' if active}", + data: { + "coplan--folder-dnd-target": "folder", + folder_id: folder.id, + action: "dragover->coplan--folder-dnd#dragOver dragleave->coplan--folder-dnd#dragLeave drop->coplan--folder-dnd#drop", + } do %> + <%= folder.name %> + <%= @folder_counts[folder.id] %> + <% end %> +<% end %> +
  • + <% if children.any? %> +
    > + <%= folder_link %> +
      + <% children.each do |child| %> + <%= render "coplan/plans/folder_node", folder: child %> + <% end %> +
    +
    + <% else %> + <%= folder_link %> + <% end %> +
  • diff --git a/engine/app/views/coplan/plans/_plan_group.html.erb b/engine/app/views/coplan/plans/_plan_group.html.erb new file mode 100644 index 00000000..a88d4a08 --- /dev/null +++ b/engine/app/views/coplan/plans/_plan_group.html.erb @@ -0,0 +1,25 @@ +<%# A collapsible status group in the main pane. Collapsed state persists + per user in localStorage (coplan--plan-groups controller); brainstorms + start collapsed by default. The group body contains the first page of + rows plus the lazy "load more" frame chain, so collapsing a group also + stops further pages from loading. %> +
    > +

    + +

    +
    + <%= render "coplan/plans/plan_page", plans: group[:plans], + plan_unread_counts: @plan_unread_counts, + page: 1, + has_next_page: group[:has_next_page], + group_key: group[:status], + frame_status: group[:status] %> +
    +
    diff --git a/engine/app/views/coplan/plans/_plan_page.html.erb b/engine/app/views/coplan/plans/_plan_page.html.erb index cfcd7a1d..0fdea7b4 100644 --- a/engine/app/views/coplan/plans/_plan_page.html.erb +++ b/engine/app/views/coplan/plans/_plan_page.html.erb @@ -1,57 +1,17 @@ -<%= turbo_frame_tag "plans-page-#{page}" do %> - <% previous_status = local_assigns.fetch(:previous_status, nil) %> +<%# One page of plan rows within a list (a status group, or the flat + ?status= results list), plus the lazily-loaded next-page frame. + `group_key` namespaces the frame ids so multiple groups can paginate + independently on the same page; `frame_status` is the status filter the + next-page request should carry. %> +<%= turbo_frame_tag "plans-#{group_key}-page-#{page}" do %> <% plans.each do |plan| %> - <% if grouped_by_status && plan.status != previous_status %> -

    - <%= plan.status.titleize %> -

    - <% previous_status = plan.status %> - <% end %> - - <% summary = plan.try(:summary).presence || plan_content_preview(plan) %> - -
    -
    - <%= link_to plan.title, plan_path(plan), class: "plans-list__title", data: { turbo_frame: "_top" } %> - <%= plan.status %> - <% if plan.plan_type %> - <%= plan.plan_type.name %> - <% end %> - <% plan_unread = plan_unread_counts[plan.id] || 0 %> - <% if plan_unread > 0 %> - <%= plan_unread %> - <% end %> -
    - - <% if summary.present? %> -

    <%= summary %>

    - <% end %> - -
    - - <%= user_avatar(plan.created_by_user) %> - <%= plan.created_by_user.name %> - - v<%= plan.current_revision %> - updated <%= time_ago_in_words(plan.updated_at) %> ago - <% if plan.tags.any? %> - - <% plan.tags.each do |tag| %> - <%= link_to tag.name, - plans_path(params.permit(:scope, :status, :plan_type).merge(tag: tag.name)), - class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}", - data: { turbo_frame: "_top" } %> - <% end %> - - <% end %> -
    -
    + <%= render "coplan/plans/plan_row", plan: plan, unread: plan_unread_counts[plan.id].to_i %> <% end %> <% if has_next_page %> - <% next_page_params = params.permit(:scope, :status, :plan_type, :tag).merge(page: page + 1) %> - <% next_page_params[:prev_status] = plans.last.status if grouped_by_status && plans.any? %> - <%= turbo_frame_tag "plans-page-#{page + 1}", + <% next_page_params = params.permit(:scope, :plan_type, :tag, :folder) + .merge(page: page + 1, group: group_key, status: frame_status) %> + <%= turbo_frame_tag "plans-#{group_key}-page-#{page + 1}", src: plans_path(next_page_params), loading: :lazy do %>
    Loading more plans…
    diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb new file mode 100644 index 00000000..8d714dce --- /dev/null +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -0,0 +1,59 @@ +<%# Compact plan row for the workspace index. Only the plan's author can + move it between folders, so only author rows are draggable and get the + "Move to folder" row menu (server-rendered gating — this page doesn't + broadcast). %> +<% author = plan.created_by_user_id == current_user.id %> +<% summary = plan.try(:summary).presence || plan_content_preview(plan) %> + +
    + draggable="true" + data-move-url="<%= move_to_folder_plan_path(plan) %>" + data-action="dragstart->coplan--folder-dnd#dragStart dragend->coplan--folder-dnd#dragEnd" + <% end %>> +
    + <%= link_to plan.title, plan_path(plan), class: "plan-row__title", data: { turbo_frame: "_top" } %> + <%= plan.status %> + <% if plan.folder %> + <%= link_to plans_path(params.permit(:scope, :status, :plan_type, :tag).merge(folder: plan.folder_id)), + class: "plan-row__folder", data: { turbo_frame: "_top" } do %> + + <%= folder_path_for(plan.folder) %> + <% end %> + <% end %> + <% plan.tags.each do |tag| %> + <%= link_to tag.name, + plans_path(params.permit(:scope, :status, :plan_type, :folder).merge(tag: tag.name)), + class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}", + data: { turbo_frame: "_top" } %> + <% end %> + + <% if unread > 0 %> + "><%= unread %> + <% end %> + <%= time_ago_in_words(plan.updated_at) %> ago + <%= user_avatar(plan.created_by_user) %> + <% if author %> +
    + +
    + <%# turbo_frame: _top — rows render inside pagination frames, but the + move redirect (and its flash) must replace the whole page. %> + <%= form_with url: move_to_folder_plan_path(plan), method: :patch, + class: "plan-row__move-form", data: { turbo_frame: "_top" } do |f| %> + + <%= f.select :folder_id, + options_for_select([["No folder", ""]] + folder_select_options.map { |path, id, _| [path, id] }, plan.folder_id), + {}, class: "plan-row__move-select", id: "move-folder-#{plan.id}" %> + <%= f.submit "Move", class: "btn btn--sm" %> + <% end %> +
    +
    + <% end %> +
    + <% if summary.present? %> +

    <%= summary %>

    + <% end %> +
    diff --git a/engine/app/views/coplan/plans/_sidebar.html.erb b/engine/app/views/coplan/plans/_sidebar.html.erb new file mode 100644 index 00000000..c498751b --- /dev/null +++ b/engine/app/views/coplan/plans/_sidebar.html.erb @@ -0,0 +1,87 @@ + diff --git a/engine/app/views/coplan/plans/index.html.erb b/engine/app/views/coplan/plans/index.html.erb index 58a34f7b..4b90e832 100644 --- a/engine/app/views/coplan/plans/index.html.erb +++ b/engine/app/views/coplan/plans/index.html.erb @@ -1,53 +1,95 @@ -
    -
    - <%= 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*0}) + + sign_in_as(bob) + get plans_path(scope: "all") + expect(response.body).to match(%r{Secret Stash\s*1}) + 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*0}) + + get plans_path(scope: "all") + expect(response.body).to match(%r{Bobs Corner\s*1}) + 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*1}) + 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