diff --git a/app/admin/references.rb b/app/admin/references.rb new file mode 100644 index 0000000..f9d3402 --- /dev/null +++ b/app/admin/references.rb @@ -0,0 +1,32 @@ +ActiveAdmin.register CoPlan::Reference, as: "Reference" do + permit_params :plan_id, :key, :url, :title, :reference_type, :source, :target_plan_id + + index do + selectable_column + id_column + column :plan + column :key + column :url + column :title + column :reference_type + column :source + column :target_plan_id + column :created_at + actions + end + + show do + attributes_table do + row :id + row :plan + row :key + row :url + row :title + row :reference_type + row :source + row :target_plan_id + row :created_at + row :updated_at + end + end +end diff --git a/db/migrate/20260410182145_create_coplan_references.co_plan.rb b/db/migrate/20260410182145_create_coplan_references.co_plan.rb new file mode 100644 index 0000000..ff671c6 --- /dev/null +++ b/db/migrate/20260410182145_create_coplan_references.co_plan.rb @@ -0,0 +1,22 @@ +# This migration comes from co_plan (originally 20260410000000) +class CreateCoplanReferences < ActiveRecord::Migration[8.1] + def change + create_table :coplan_references, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :key + t.string :url, null: false + t.string :title + t.string :reference_type, null: false + t.string :source, null: false + t.string :target_plan_id, limit: 36 + t.timestamps + end + + add_index :coplan_references, [:plan_id, :key], unique: true + add_index :coplan_references, [:plan_id, :url], unique: true + add_index :coplan_references, :target_plan_id + add_index :coplan_references, :source + add_foreign_key :coplan_references, :coplan_plans, column: :plan_id + add_foreign_key :coplan_references, :coplan_plans, column: :target_plan_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 388165c..0850e46 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_04_06_200000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_10_182145) 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" @@ -70,13 +70,13 @@ t.integer "start_line" t.string "status", default: "pending", null: false t.datetime "updated_at", null: false - t.index ["addressed_in_plan_version_id"], name: "fk_rails_e7003e0df7" - t.index ["created_by_user_id"], name: "fk_rails_88fb5e06ca" - t.index ["out_of_date_since_version_id"], name: "fk_rails_be37c1499d" + t.index ["addressed_in_plan_version_id"], name: "fk_rails_a77cc69a6e" + t.index ["created_by_user_id"], name: "fk_rails_34dfdd2aac" + t.index ["out_of_date_since_version_id"], name: "fk_rails_60a8d49098" t.index ["plan_id", "out_of_date"], name: "index_coplan_comment_threads_on_plan_id_and_out_of_date" t.index ["plan_id", "status"], name: "index_coplan_comment_threads_on_plan_id_and_status" - t.index ["plan_version_id"], name: "fk_rails_676660f283" - t.index ["resolved_by_user_id"], name: "fk_rails_8625e1eb43" + t.index ["plan_version_id"], name: "fk_rails_514df5a253" + t.index ["resolved_by_user_id"], name: "fk_rails_e5ed569cf1" end create_table "coplan_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -117,7 +117,7 @@ t.string "status", default: "open", null: false t.datetime "updated_at", null: false t.index ["plan_id", "status"], name: "index_coplan_edit_sessions_on_plan_id_and_status" - t.index ["plan_version_id"], name: "fk_rails_14c3f0737b" + t.index ["plan_version_id"], name: "fk_rails_55d7ec476a" end create_table "coplan_notifications", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -211,12 +211,28 @@ t.string "title", null: false 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 ["current_plan_version_id"], name: "fk_rails_4193983681" t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["status"], name: "index_coplan_plans_on_status" t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" end + create_table "coplan_references", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "key" + t.string "plan_id", limit: 36, null: false + t.string "reference_type", null: false + t.string "source", null: false + t.string "target_plan_id", limit: 36 + t.string "title" + t.datetime "updated_at", null: false + t.string "url", null: false + t.index ["plan_id", "key"], name: "index_coplan_references_on_plan_id_and_key", unique: true + t.index ["plan_id", "url"], name: "index_coplan_references_on_plan_id_and_url", unique: true + t.index ["source"], name: "index_coplan_references_on_source" + t.index ["target_plan_id"], name: "index_coplan_references_on_target_plan_id" + end + create_table "coplan_tags", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "name", null: false @@ -269,4 +285,6 @@ 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" + add_foreign_key "coplan_references", "coplan_plans", column: "plan_id" + add_foreign_key "coplan_references", "coplan_plans", column: "target_plan_id" end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 33235a0..6c09a6d 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1872,6 +1872,193 @@ body:has(.comment-toolbar) .main-content { margin-top: var(--space-sm); } +/* Plan tabs */ +.plan-tabs__nav { + display: flex; + gap: var(--space-xs); + margin-bottom: calc(-1px); + position: relative; + z-index: 1; +} + +.plan-tabs__tab { + padding: var(--space-sm) var(--space-lg); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); + text-decoration: none; + border: 1px solid transparent; + border-bottom: none; + border-radius: var(--radius) var(--radius) 0 0; + transition: color 0.15s, background 0.15s; + cursor: pointer; +} + +.plan-tabs__tab:hover { + color: var(--color-text); + background: var(--color-bg); + text-decoration: none; +} + +.plan-tabs__tab--active { + color: var(--color-text); + background: var(--color-surface); + border-color: var(--color-border); + font-weight: 600; +} + +.plan-tabs__count { + font-size: 0.75rem; + color: var(--color-text-muted); + font-weight: 400; +} + +.plan-tabs__panel--hidden { + display: none; +} + +/* References section */ +.references-section { + padding: var(--space-lg); +} + +.references-list { + list-style: none; + padding: 0; + margin: 0; +} + +.references-list__item { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--color-border); +} + +.references-list__item:last-child { + border-bottom: none; +} + +.references-list__icon { + flex-shrink: 0; + font-size: var(--text-lg); + line-height: 1.4; +} + +.references-list__content { + flex: 1; + min-width: 0; +} + +.references-list__link { + display: block; + word-break: break-all; + font-size: var(--text-sm); +} + +.references-list__meta { + display: flex; + align-items: center; + gap: var(--space-xs); + margin-top: 2px; +} + +.references-list__source { + font-size: 0.75rem; + color: var(--color-text-muted); + font-style: italic; +} + +.badge--ref-type { + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 4px; + background: var(--color-bg); + color: var(--color-text-muted); + border: 1px solid var(--color-border); +} + +.badge--ref-key { + font-size: 0.7rem; + padding: 1px 6px; + border-radius: 4px; + background: var(--color-surface); + color: var(--color-primary); + border: 1px solid var(--color-primary); + font-family: var(--font-mono, monospace); +} + +.references-list__remove { + flex-shrink: 0; + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + padding: 2px 6px; + font-size: var(--text-sm); + border-radius: var(--radius); + transition: color 0.15s, background 0.15s; +} + +.references-list__remove:hover { + color: var(--color-danger); + background: var(--color-bg); +} + +.references-section__header { + margin-bottom: var(--space-md); +} + +.references-add summary { + cursor: pointer; + list-style: none; +} + +.references-add summary::-webkit-details-marker { + display: none; +} + +.references-add__form { + margin-top: var(--space-sm); + display: flex; + align-items: flex-end; + gap: var(--space-xs); + flex-wrap: wrap; +} + +.references-add__form .form-group { + margin-bottom: 0; + flex: 1; + min-width: 140px; +} + +.references-add__form input[type="text"] { + width: 100%; + padding: 6px var(--space-sm); + font-size: var(--text-sm); + font-family: var(--font-sans); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-input-bg); + color: var(--color-text); + transition: border-color 0.15s; +} + +.references-add__form input[type="text"]:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-focus-ring); +} + +.references-add__form input[type="text"]::placeholder { + color: var(--color-text-muted); +} + +.references-add__form .btn { + flex-shrink: 0; +} + /* Hide sidebar on small screens */ @media (max-width: 1024px) { .content-nav { diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index fe472f7..eed1bb8 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -18,7 +18,18 @@ def index def show render json: plan_json(@plan).merge( current_content: @plan.current_content, - current_revision: @plan.current_revision + current_revision: @plan.current_revision, + references: @plan.references.map { |r| + { + id: r.id, + key: r.key, + url: r.url, + title: r.title, + reference_type: r.reference_type, + source: r.source, + target_plan_id: r.target_plan_id + } + } ) end @@ -33,12 +44,26 @@ def create end end - plan = Plans::Create.call( - title: params[:title], - content: params[:content] || "", - user: current_user, - plan_type_id: plan_type&.id - ) + plan = nil + ActiveRecord::Base.transaction do + plan = Plans::Create.call( + title: params[:title], + content: params[:content] || "", + user: current_user, + plan_type_id: plan_type&.id + ) + + if params[:references].is_a?(Array) + params[:references].each do |ref_params| + next unless ref_params[:url].present? + ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url]) + ref = plan.references.find_or_initialize_by(url: ref_params[:url]) + ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit") + ref.save! + end + end + end + render json: plan_json(plan).merge( current_content: plan.current_content, current_revision: plan.current_revision @@ -68,6 +93,16 @@ def update Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: permitted[:status], triggered_by: current_user) end + if params[:references].is_a?(Array) + params[:references].each do |ref_params| + next unless ref_params[:url].present? + ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url]) + ref = @plan.references.find_or_initialize_by(url: ref_params[:url]) + ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit") + ref.save! + end + end + render json: plan_json(@plan).merge( current_content: @plan.current_content, current_revision: @plan.current_revision diff --git a/engine/app/controllers/coplan/api/v1/references_controller.rb b/engine/app/controllers/coplan/api/v1/references_controller.rb new file mode 100644 index 0000000..5edb5b5 --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/references_controller.rb @@ -0,0 +1,98 @@ +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] + + def index + references = @plan.references.order(created_at: :desc) + references = references.where(reference_type: params[:type]) if params[:type].present? + render json: references.map { |r| reference_json(r) } + end + + def create + ref_type = params[:reference_type].presence || Reference.classify_url(params[:url]) + target_plan_id = nil + if ref_type == "plan" + candidate_id = Reference.extract_target_plan_id(params[:url]) + target_plan_id = candidate_id if candidate_id && candidate_id != @plan.id && Plan.exists?(candidate_id) + end + + ref = @plan.references.find_or_initialize_by(url: params[:url]) + ref.assign_attributes( + key: params[:key], + title: params[:title], + reference_type: ref_type, + source: "explicit", + target_plan_id: target_plan_id || params[:target_plan_id] + ) + ref.save! + + render json: reference_json(ref), status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_content + end + + def destroy + ref = @plan.references.find_by(id: params[:id]) + unless ref + render json: { error: "Reference not found" }, status: :not_found + return + end + + ref.destroy! + head :no_content + end + + def search + url = params[:url] + unless url.present? + render json: { error: "url parameter is required" }, status: :unprocessable_content + return + end + + visible_plans = Plan.where.not(status: "brainstorm") + .or(Plan.where(created_by_user: current_user)) + + references = Reference.where(url: url, plan_id: visible_plans.select(:id)) + .includes(:plan) + .order(created_at: :desc) + + render json: references.map { |r| + reference_json(r).merge( + plan_id: r.plan_id, + plan_title: r.plan.title, + plan_status: r.plan.status + ) + } + end + + private + + def authorize_plan_write! + return unless @plan + policy = CoPlan::PlanPolicy.new(current_user, @plan) + unless policy.update? + render json: { error: "Not authorized" }, status: :forbidden + end + end + + def reference_json(ref) + { + id: ref.id, + key: ref.key, + url: ref.url, + title: ref.title, + reference_type: ref.reference_type, + source: ref.source, + target_plan_id: ref.target_plan_id, + created_at: ref.created_at, + updated_at: ref.updated_at + } + end + end + end + end +end diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index 6a15fd1..1031c09 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -10,6 +10,7 @@ def self.controller_path helper CoPlan::ApplicationHelper helper CoPlan::MarkdownHelper helper CoPlan::CommentsHelper + helper CoPlan::ReferencesHelper # Skip host auth — CoPlan handles authentication internally via config.authenticate skip_before_action :authenticate_user!, raise: false diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index a2e8241..aad921d 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -26,6 +26,7 @@ def index def show authorize!(@plan, :show?) @threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at) + @references = @plan.references.order(reference_type: :asc, created_at: :desc) PlanViewer.track(plan: @plan, user: current_user) end diff --git a/engine/app/controllers/coplan/references_controller.rb b/engine/app/controllers/coplan/references_controller.rb new file mode 100644 index 0000000..d4db257 --- /dev/null +++ b/engine/app/controllers/coplan/references_controller.rb @@ -0,0 +1,70 @@ +module CoPlan + class ReferencesController < ApplicationController + before_action :set_plan + + def create + authorize!(@plan, :update?) + + url = params[:reference][:url] + ref_type = Reference.classify_url(url) + target_plan_id = nil + if ref_type == "plan" + candidate_id = Reference.extract_target_plan_id(url) + target_plan_id = candidate_id if candidate_id && candidate_id != @plan.id && Plan.exists?(candidate_id) + end + + ref = @plan.references.find_or_initialize_by(url: url) + ref.assign_attributes( + key: params[:reference][:key].presence || ref.key, + title: params[:reference][:title].presence || ref.title, + reference_type: ref_type, + source: "explicit", + target_plan_id: target_plan_id + ) + ref.save! + + respond_to do |format| + format.turbo_stream { render_references_stream } + format.html { redirect_to plan_path(@plan, tab: "references"), notice: "Reference added." } + end + rescue ActiveRecord::RecordInvalid => e + respond_to do |format| + format.turbo_stream { render_references_stream } + format.html { redirect_to plan_path(@plan, tab: "references"), alert: e.message } + end + end + + def destroy + authorize!(@plan, :update?) + + ref = @plan.references.find(params[:id]) + ref.destroy! + + respond_to do |format| + format.turbo_stream { render_references_stream } + format.html { redirect_to plan_path(@plan, tab: "references"), notice: "Reference removed." } + end + end + + private + + def set_plan + @plan = Plan.find(params[:plan_id]) + end + + def render_references_stream + references = @plan.references.reload.order(reference_type: :asc, created_at: :desc) + render turbo_stream: [ + turbo_stream.replace( + "plan-references", + partial: "coplan/plans/references", + locals: { references: references, plan: @plan } + ), + turbo_stream.replace( + "references-count", + html: helpers.content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") + ) + ] + end + end +end diff --git a/engine/app/helpers/coplan/references_helper.rb b/engine/app/helpers/coplan/references_helper.rb new file mode 100644 index 0000000..7bbed8a --- /dev/null +++ b/engine/app/helpers/coplan/references_helper.rb @@ -0,0 +1,13 @@ +module CoPlan + module ReferencesHelper + def reference_icon(reference_type) + case reference_type + when "plan" then "📋" + when "repository" then "📦" + when "pull_request" then "🔀" + when "document" then "📄" + else "🔗" + end + end + end +end diff --git a/engine/app/javascript/controllers/coplan/tabs_controller.js b/engine/app/javascript/controllers/coplan/tabs_controller.js new file mode 100644 index 0000000..4bd72b2 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/tabs_controller.js @@ -0,0 +1,28 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["tab", "panel"] + + switch(event) { + event.preventDefault() + const targetId = event.currentTarget.getAttribute("href").replace("#", "") + const tabName = event.currentTarget.dataset.tabName + + this.tabTargets.forEach(tab => { + tab.classList.toggle("plan-tabs__tab--active", tab.getAttribute("href") === `#${targetId}`) + }) + + this.panelTargets.forEach(panel => { + panel.classList.toggle("plan-tabs__panel--hidden", panel.id !== targetId) + }) + + // Update URL with tab param (omit for default "content" tab) + const url = new URL(window.location) + if (tabName && tabName !== "content") { + url.searchParams.set("tab", tabName) + } else { + url.searchParams.delete("tab") + } + history.replaceState(null, "", url) + } +} diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index e45b5cf..79e1dbe 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -15,6 +15,7 @@ class Plan < ApplicationRecord has_many :tags, through: :plan_tags, source: :tag has_many :plan_viewers, dependent: :destroy has_many :notifications, dependent: :destroy + has_many :references, dependent: :destroy after_initialize { self.metadata ||= {} } diff --git a/engine/app/models/coplan/plan_version.rb b/engine/app/models/coplan/plan_version.rb index ccd341d..4bf71aa 100644 --- a/engine/app/models/coplan/plan_version.rb +++ b/engine/app/models/coplan/plan_version.rb @@ -13,9 +13,30 @@ class PlanVersion < ApplicationRecord validates :actor_type, presence: true, inclusion: { in: ACTOR_TYPES } before_validation :compute_sha256, if: -> { content_markdown.present? && content_sha256.blank? } + after_create_commit :extract_references private + def extract_references + CoPlan::References::ExtractFromContent.call(plan: plan, content: content_markdown) + broadcast_references_update + end + + def broadcast_references_update + references = plan.references.reload.order(reference_type: :asc, created_at: :desc) + Broadcaster.replace_to( + plan, + target: "plan-references", + partial: "coplan/plans/references", + locals: { references: references, plan: plan } + ) + Broadcaster.replace_to( + plan, + target: "references-count", + html: ApplicationController.helpers.content_tag(:span, references.size, class: "plan-tabs__count", id: "references-count") + ) + end + def compute_sha256 self.content_sha256 = Digest::SHA256.hexdigest(content_markdown) end diff --git a/engine/app/models/coplan/reference.rb b/engine/app/models/coplan/reference.rb new file mode 100644 index 0000000..69ed100 --- /dev/null +++ b/engine/app/models/coplan/reference.rb @@ -0,0 +1,51 @@ +module CoPlan + class Reference < ApplicationRecord + SOURCES = %w[extracted explicit].freeze + REFERENCE_TYPES = %w[plan repository pull_request document link].freeze + + belongs_to :plan + belongs_to :target_plan, class_name: "CoPlan::Plan", optional: true + + validates :url, presence: true, uniqueness: { scope: :plan_id }, format: { with: /\Ahttps?:\/\//i, message: "must start with http:// or https://" } + validates :key, uniqueness: { scope: :plan_id }, allow_nil: true, + format: { with: /\A[a-z0-9][a-z0-9_-]*\z/, message: "must be lowercase alphanumeric with hyphens/underscores" }, length: { maximum: 64 } + validates :reference_type, presence: true, inclusion: { in: REFERENCE_TYPES } + validates :source, presence: true, inclusion: { in: SOURCES } + + scope :extracted, -> { where(source: "extracted") } + scope :explicit, -> { where(source: "explicit") } + + def self.classify_url(url) + case url + when %r{\Ahttps?://github\.com/[^/]+/[^/]+/pull/\d+} + "pull_request" + when %r{\Ahttps?://github\.com/[^/]+/[^/]+/?(\z|#|\?|/tree/|/blob/|/commit/)} + "repository" + when %r{/plans/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}} + "plan" + when %r{\Ahttps?://docs\.google\.com/}, %r{\Ahttps?://drive\.google\.com/} + "document" + when %r{\Ahttps?://[^/]*notion\.(so|site)/} + "document" + when %r{\Ahttps?://[^/]*confluence[^/]*/} + "document" + else + "link" + end + end + + def self.extract_target_plan_id(url) + return nil if url.blank? + match = url.match(%r{/plans/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})}) + match&.[](1) + end + + def self.ransackable_attributes(auth_object = nil) + %w[id plan_id key url title reference_type source target_plan_id created_at updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[plan target_plan] + end + end +end diff --git a/engine/app/services/coplan/references/extract_from_content.rb b/engine/app/services/coplan/references/extract_from_content.rb new file mode 100644 index 0000000..7fca729 --- /dev/null +++ b/engine/app/services/coplan/references/extract_from_content.rb @@ -0,0 +1,87 @@ +module CoPlan + module References + class ExtractFromContent + def self.call(plan:, content: nil) + new(plan:, content:).call + end + + def initialize(plan:, content: nil) + @plan = plan + @content = content + end + + def call + content = @content || @plan.current_content + return remove_all_extracted if content.blank? + + found_urls = extract_urls(content) + + # Remove extracted references for URLs no longer in content + @plan.references.extracted.where.not(url: found_urls.keys).delete_all + + # Batch-check plan existence for plan-type references + candidate_plan_ids = found_urls.keys + .select { |url| Reference.classify_url(url) == "plan" } + .filter_map { |url| Reference.extract_target_plan_id(url) } + .reject { |id| id == @plan.id } + existing_plan_ids = candidate_plan_ids.any? ? Plan.where(id: candidate_plan_ids).pluck(:id).to_set : Set.new + + # Create or update references for found URLs + found_urls.each do |url, meta| + ref_type = Reference.classify_url(url) + target_plan_id = nil + if ref_type == "plan" + candidate_id = Reference.extract_target_plan_id(url) + target_plan_id = candidate_id if candidate_id && existing_plan_ids.include?(candidate_id) + end + + ref = @plan.references.find_or_initialize_by(url: url) + # Don't overwrite explicit references + next if ref.persisted? && ref.source == "explicit" + + ref.assign_attributes( + key: meta[:key].presence || ref.key, + title: meta[:title].presence || ref.title, + reference_type: ref_type, + source: "extracted", + target_plan_id: target_plan_id + ) + ref.save! + end + end + + private + + def remove_all_extracted + @plan.references.extracted.delete_all + end + + def extract_urls(content) + urls = {} # url => { title:, key: } + + # Match markdown reference-style link definitions: [key]: url "optional title" + content.scan(/^\[([^\]]+)\]:\s+(https?:\/\/\S+)(?:\s+"([^"]*)")?/m) do |key, url, title| + url = url.strip + k = key.strip.downcase.gsub(/[^a-z0-9_-]/, "-").gsub(/-+/, "-").truncate(64, omission: "") + urls[url] ||= { title: title&.strip, key: k } + end + + # Match markdown inline links: [title](url) + content.scan(/\[([^\]]*)\]\(([^)]+)\)/) do |title, url| + url = url.strip + next unless url.match?(%r{\Ahttps?://}) + urls[url] ||= { title: title.strip, key: nil } + end + + # Match bare URLs that aren't already inside markdown link syntax + stripped = content.gsub(/\[([^\]]*)\]\(([^)]+)\)/, "").gsub(/^\[([^\]]+)\]:\s+\S+.*$/, "") + stripped.scan(%r{https?://[^\s<>\]\)]+}) do |url| + url = url.chomp(".").chomp(",").chomp(")").chomp(";") + urls[url] ||= { title: nil, key: nil } + end + + urls + end + end + 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 e8201c8..d2c663c 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -22,7 +22,7 @@ Optional query param: `?status=considering` "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`. +Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`, `references` (array of linked resources). ### Create Plan @@ -46,7 +46,7 @@ Update plan metadata (title, status, tags). Only fields included in the request "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Allowed fields: `title` (string), `status` (string), `tags` (array of strings). +Allowed fields: `title` (string), `status` (string), `tags` (array of strings), `references` (array — see [References](#references) below). ### List Tags @@ -99,6 +99,64 @@ Plan types categorize plans and provide default tags. When creating a plan, pass No plan types are currently configured. <% end %> +### References + +References connect plans to external resources (repos, PRs, docs) and to other plans. **Always add references** when your plan mentions GitHub repos, PRs, design docs, or other plans — they make plans discoverable and show the connective tissue between work. + +Links in plan content are **auto-extracted** as references. You can also add explicit references via the API. + +**Add references on create or update:** + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"title": "My Plan", "content": "...", "references": [{"url": "https://github.com/org/repo", "key": "main-repo", "title": "Main Repo"}, {"url": "https://github.com/org/repo/pull/42", "key": "impl-pr", "title": "Implementation PR"}]}' \ + "<%= @base %>/api/v1/plans" | jq . +``` + +Each reference object takes: `url` (required), `key` (optional — a short identifier like `auth-repo` or `impl-pr`), `title` (optional). The `reference_type` is auto-classified from the URL (repository, pull_request, plan, document, link). + +Keys let you give references semantic meaning that persists across edits. Use lowercase alphanumeric characters, hyphens, and underscores (e.g., `auth-service`, `main_repo`, `impl-pr`). Keys must be unique per plan. You can also define keys using markdown reference-style links in your content: + +``` +See the [auth-repo] for implementation details. + +[auth-repo]: https://github.com/org/auth-service "Auth Service" +``` + +These are auto-extracted with both the key and title preserved. + +**List references for a plan:** + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/plans/$PLAN_ID/references" | jq . +``` + +**Add a reference to an existing plan:** + +```bash +<%= @curl %> -X POST \ + -H "Content-Type: application/json" \ + -d '{"url": "https://github.com/org/repo", "key": "service-repo", "title": "Service Repo"}' \ + "<%= @base %>/api/v1/plans/$PLAN_ID/references" | jq . +``` + +**Find plans that reference a URL:** + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/references/search?url=https://github.com/org/repo" | jq . +``` + +**Guidelines:** +- **Always set a `key`** when adding explicit references — it gives the reference a stable semantic identity (e.g., `auth-repo`, `impl-pr`, `design-doc`). +- Add references to any GitHub repos, PRs, design docs, or related plans mentioned in your plan. +- Links in plan content are auto-extracted — you don't need to add those manually. +- Use explicit references for resources not linked in the content but still relevant. +- Reference titles should be human-readable (e.g., "Auth Service Repo", not the raw URL). +- Use markdown reference-style links (`[key]: url "title"`) in content to auto-extract keyed references. + ### Get Versions ```bash diff --git a/engine/app/views/coplan/plans/_references.html.erb b/engine/app/views/coplan/plans/_references.html.erb new file mode 100644 index 0000000..9884cea --- /dev/null +++ b/engine/app/views/coplan/plans/_references.html.erb @@ -0,0 +1,52 @@ +
+
+
+ + Add Reference + <%= form_with url: plan_references_path(plan), method: :post, class: "references-add__form" do |f| %> +
+ <%= f.text_field :url, placeholder: "https://...", required: true, name: "reference[url]" %> +
+
+ <%= f.text_field :title, placeholder: "Title (optional)", name: "reference[title]" %> +
+
+ <%= f.text_field :key, placeholder: "Key (optional, e.g. auth-repo)", name: "reference[key]" %> +
+ <%= f.submit "Add", class: "btn btn--primary btn--sm" %> + <% end %> +
+
+ + <% if references.any? %> + + <% else %> +
+

No references yet. Links in the plan content are auto-extracted, or add one manually.

+
+ <% end %> +
diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 28ccdfe..19f759d 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -15,41 +15,52 @@ <%= render partial: "coplan/plans/header", locals: { plan: @plan } %> -
- <% if @plan.current_content.present? %> -
- - +
+ + +
+ <% if @plan.current_content.present? %> +
+ + -
- <%= render_markdown(@plan.current_content) %> +
+ <%= render_markdown(@plan.current_content) %> - - <%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %> +
+ <% @threads.select(&:anchored?).each do |thread| %> + <%= render partial: "coplan/comment_threads/thread_popover", locals: { thread: thread, plan: @plan, current_user: current_user } %> + <% end %> +
- -
- <% @threads.select(&:anchored?).each do |thread| %> - <%= render partial: "coplan/comment_threads/thread_popover", locals: { thread: thread, plan: @plan, current_user: current_user } %> - <% end %> + <% else %> +
+

This plan has no content yet.

-
- <% else %> -
-

This plan has no content yet.

-
- <% end %> + <% end %> +
+ +
+ <%= render partial: "coplan/plans/references", locals: { references: @references, plan: @plan } %> +
<% open_count = @threads.count(&:open?) %> diff --git a/engine/config/routes.rb b/engine/config/routes.rb index ae6fb3e..be03e39 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -3,6 +3,7 @@ patch :update_status, on: :member patch :toggle_checkbox, on: :member resources :versions, controller: "plan_versions", only: [:index, :show] + resources :references, controller: "references", only: [:create, :destroy] resources :automated_reviews, only: [:create] resources :comment_threads, only: [:create] do member do @@ -39,6 +40,10 @@ patch :resolve, on: :member patch :discard, on: :member end + resources :references, only: [:index, :create, :destroy] + end + resources :references, only: [] do + get :search, on: :collection end end end diff --git a/engine/db/migrate/20260410000000_create_coplan_references.rb b/engine/db/migrate/20260410000000_create_coplan_references.rb new file mode 100644 index 0000000..6497a35 --- /dev/null +++ b/engine/db/migrate/20260410000000_create_coplan_references.rb @@ -0,0 +1,21 @@ +class CreateCoplanReferences < ActiveRecord::Migration[8.1] + def change + create_table :coplan_references, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :key + t.string :url, null: false + t.string :title + t.string :reference_type, null: false + t.string :source, null: false + t.string :target_plan_id, limit: 36 + t.timestamps + end + + add_index :coplan_references, [:plan_id, :key], unique: true + add_index :coplan_references, [:plan_id, :url], unique: true + add_index :coplan_references, :target_plan_id + add_index :coplan_references, :source + add_foreign_key :coplan_references, :coplan_plans, column: :plan_id + add_foreign_key :coplan_references, :coplan_plans, column: :target_plan_id + end +end diff --git a/spec/factories/references.rb b/spec/factories/references.rb new file mode 100644 index 0000000..e29b9b4 --- /dev/null +++ b/spec/factories/references.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :reference, class: "CoPlan::Reference" do + plan + sequence(:url) { |n| "https://example.com/page-#{n}" } + title { "Example Reference" } + reference_type { "link" } + source { "extracted" } + + trait :extracted do + source { "extracted" } + end + end +end diff --git a/spec/models/coplan/plan_version_references_spec.rb b/spec/models/coplan/plan_version_references_spec.rb new file mode 100644 index 0000000..cb4325a --- /dev/null +++ b/spec/models/coplan/plan_version_references_spec.rb @@ -0,0 +1,19 @@ +require "rails_helper" + +RSpec.describe "PlanVersion reference extraction", type: :model do + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + + it "extracts references when a new version is created" do + CoPlan::PlanVersion.create!( + plan: plan, + revision: plan.current_revision + 1, + content_markdown: "See [Rails](https://rubyonrails.org) for details.", + actor_type: "human", + actor_id: user.id + ) + + expect(plan.references.count).to eq(1) + expect(plan.references.first.url).to eq("https://rubyonrails.org") + end +end diff --git a/spec/models/coplan/reference_spec.rb b/spec/models/coplan/reference_spec.rb new file mode 100644 index 0000000..41e2d7c --- /dev/null +++ b/spec/models/coplan/reference_spec.rb @@ -0,0 +1,110 @@ +require "rails_helper" + +RSpec.describe CoPlan::Reference, type: :model do + describe "validations" do + let(:plan) { create(:plan) } + + it "requires url" do + ref = build(:reference, plan: plan, url: nil) + expect(ref).not_to be_valid + expect(ref.errors[:url]).to include("can't be blank") + end + + it "requires reference_type" do + ref = build(:reference, plan: plan, reference_type: nil) + expect(ref).not_to be_valid + end + + it "requires source" do + ref = build(:reference, plan: plan, source: nil) + expect(ref).not_to be_valid + end + + it "validates reference_type inclusion" do + ref = build(:reference, plan: plan, reference_type: "invalid") + expect(ref).not_to be_valid + end + + it "validates source inclusion" do + ref = build(:reference, plan: plan, source: "invalid") + expect(ref).not_to be_valid + end + + it "enforces uniqueness of url per plan" do + create(:reference, plan: plan, url: "https://example.com") + ref = build(:reference, plan: plan, url: "https://example.com") + expect(ref).not_to be_valid + end + + it "allows same url on different plans" do + other_plan = create(:plan) + create(:reference, plan: plan, url: "https://example.com") + ref = build(:reference, plan: other_plan, url: "https://example.com") + expect(ref).to be_valid + end + end + + describe ".classify_url" do + it "classifies GitHub PR URLs" do + expect(described_class.classify_url("https://github.com/org/repo/pull/123")).to eq("pull_request") + end + + it "classifies GitHub repo URLs" do + expect(described_class.classify_url("https://github.com/org/repo")).to eq("repository") + expect(described_class.classify_url("https://github.com/org/repo/")).to eq("repository") + expect(described_class.classify_url("https://github.com/org/repo/tree/main")).to eq("repository") + expect(described_class.classify_url("https://github.com/org/repo/blob/main/file.rb")).to eq("repository") + end + + it "classifies CoPlan plan URLs" do + expect(described_class.classify_url("https://coplan.example.com/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939a")).to eq("plan") + end + + it "classifies Google Docs URLs" do + expect(described_class.classify_url("https://docs.google.com/document/d/abc123")).to eq("document") + expect(described_class.classify_url("https://drive.google.com/file/d/abc123")).to eq("document") + end + + it "classifies Notion URLs" do + expect(described_class.classify_url("https://www.notion.so/page-abc123")).to eq("document") + expect(described_class.classify_url("https://team.notion.site/page-abc123")).to eq("document") + end + + it "classifies Confluence URLs" do + expect(described_class.classify_url("https://wiki.confluence.example.com/display/TEAM/Page")).to eq("document") + end + + it "defaults to link for unknown URLs" do + expect(described_class.classify_url("https://example.com/something")).to eq("link") + end + end + + describe ".extract_target_plan_id" do + it "extracts UUID from plan URLs" do + url = "https://coplan.example.com/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939a" + expect(described_class.extract_target_plan_id(url)).to eq("019d54a7-ea13-72d5-bc54-fc44cb9b939a") + end + + it "returns nil for non-plan URLs" do + expect(described_class.extract_target_plan_id("https://example.com")).to be_nil + end + end + + describe "scopes" do + let(:plan) { create(:plan) } + + it ".extracted returns only extracted references" do + extracted = create(:reference, :extracted, plan: plan, url: "https://a.com") + create(:reference, plan: plan, url: "https://b.com", source: "explicit") + + expect(described_class.extracted).to eq([extracted]) + end + + it ".explicit returns only explicit references" do + create(:reference, :extracted, plan: plan, url: "https://a.com") + explicit = create(:reference, plan: plan, url: "https://b.com", source: "explicit") + + expect(described_class.explicit).to eq([explicit]) + end + end +end diff --git a/spec/requests/api/v1/references_spec.rb b/spec/requests/api/v1/references_spec.rb new file mode 100644 index 0000000..0ea51d3 --- /dev/null +++ b/spec/requests/api/v1/references_spec.rb @@ -0,0 +1,102 @@ +require "rails_helper" + +RSpec.describe "Api::V1::References", type: :request do + let(:user) { create(:coplan_user) } + let(:token) { create(:api_token, user: user, raw_token: "test-token-refs") } + let(:headers) { { "Authorization" => "Bearer test-token-refs", "Content-Type" => "application/json" } } + let(:plan) { create(:plan, :considering, created_by_user: user) } + + before { token } + + describe "GET /api/v1/plans/:plan_id/references" do + it "lists references for a plan" do + create(:reference, plan: plan, url: "https://github.com/org/repo", reference_type: "repository") + create(:reference, plan: plan, url: "https://example.com", reference_type: "link") + + get api_v1_plan_references_path(plan), headers: headers + expect(response).to have_http_status(:ok) + + data = JSON.parse(response.body) + expect(data.length).to eq(2) + expect(data.first).to include("url", "reference_type", "source") + end + + it "filters by type" do + create(:reference, plan: plan, url: "https://github.com/org/repo", reference_type: "repository") + create(:reference, plan: plan, url: "https://example.com", reference_type: "link") + + get api_v1_plan_references_path(plan), params: { type: "repository" }, headers: headers + data = JSON.parse(response.body) + expect(data.length).to eq(1) + expect(data.first["reference_type"]).to eq("repository") + end + end + + describe "POST /api/v1/plans/:plan_id/references" do + it "creates an explicit reference" do + post api_v1_plan_references_path(plan), + params: { url: "https://github.com/org/repo", title: "My Repo" }.to_json, + headers: headers + + expect(response).to have_http_status(:created) + data = JSON.parse(response.body) + expect(data["url"]).to eq("https://github.com/org/repo") + expect(data["reference_type"]).to eq("repository") + expect(data["source"]).to eq("explicit") + expect(data["title"]).to eq("My Repo") + end + + it "auto-classifies URL type" do + post api_v1_plan_references_path(plan), + params: { url: "https://github.com/org/repo/pull/42" }.to_json, + headers: headers + + data = JSON.parse(response.body) + expect(data["reference_type"]).to eq("pull_request") + end + end + + describe "DELETE /api/v1/plans/:plan_id/references/:id" do + it "deletes a reference" do + ref = create(:reference, plan: plan, url: "https://example.com") + + delete api_v1_plan_reference_path(plan, ref), headers: headers + expect(response).to have_http_status(:no_content) + expect(plan.references.count).to eq(0) + end + + it "returns not found for unknown reference" do + delete api_v1_plan_reference_path(plan, "nonexistent-id"), headers: headers + expect(response).to have_http_status(:not_found) + end + end + + describe "GET /api/v1/references/search" do + it "finds plans by reference URL" do + create(:reference, plan: plan, url: "https://github.com/org/repo") + + get search_api_v1_references_path, params: { url: "https://github.com/org/repo" }, headers: headers + expect(response).to have_http_status(:ok) + + data = JSON.parse(response.body) + expect(data.length).to eq(1) + expect(data.first["plan_id"]).to eq(plan.id) + expect(data.first["plan_title"]).to eq(plan.title) + end + + it "requires url parameter" do + get search_api_v1_references_path, headers: headers + expect(response).to have_http_status(:unprocessable_content) + end + + it "excludes brainstorm plans from other users" do + other_user = create(:coplan_user) + brainstorm_plan = create(:plan, :brainstorm, created_by_user: other_user) + create(:reference, plan: brainstorm_plan, url: "https://github.com/org/repo") + + get search_api_v1_references_path, params: { url: "https://github.com/org/repo" }, headers: headers + data = JSON.parse(response.body) + expect(data.length).to eq(0) + end + end +end diff --git a/spec/services/coplan/references/extract_from_content_spec.rb b/spec/services/coplan/references/extract_from_content_spec.rb new file mode 100644 index 0000000..d06c99b --- /dev/null +++ b/spec/services/coplan/references/extract_from_content_spec.rb @@ -0,0 +1,120 @@ +require "rails_helper" + +RSpec.describe CoPlan::References::ExtractFromContent do + let(:user) { create(:coplan_user) } + let(:plan) { create(:plan, created_by_user: user) } + + def update_content(plan, content) + version = plan.current_plan_version + version.update!(content_markdown: content) + end + + describe ".call" do + it "extracts markdown links from content" do + update_content(plan, "Check out [Rails](https://rubyonrails.org) for details.") + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + ref = plan.references.first + expect(ref.url).to eq("https://rubyonrails.org") + expect(ref.title).to eq("Rails") + expect(ref.reference_type).to eq("link") + expect(ref.source).to eq("extracted") + end + + it "classifies GitHub repo URLs" do + update_content(plan, "See [repo](https://github.com/org/my-repo) for code.") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("repository") + end + + it "classifies GitHub PR URLs" do + update_content(plan, "See [PR](https://github.com/org/repo/pull/123) for changes.") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("pull_request") + end + + it "classifies Google Docs URLs" do + update_content(plan, "See [doc](https://docs.google.com/document/d/abc123) for details.") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("document") + end + + it "removes extracted references when links are removed from content" do + update_content(plan, "See [Rails](https://rubyonrails.org) and [Ruby](https://ruby-lang.org).") + described_class.call(plan: plan) + expect(plan.references.count).to eq(2) + + update_content(plan, "See [Rails](https://rubyonrails.org) only.") + described_class.call(plan: plan) + expect(plan.references.count).to eq(1) + expect(plan.references.first.url).to eq("https://rubyonrails.org") + end + + it "does not remove explicit references" do + create(:reference, plan: plan, url: "https://example.com", source: "explicit") + update_content(plan, "No links here.") + described_class.call(plan: plan) + + refs = plan.references.reload + expect(refs.count).to eq(1) + expect(refs.first.source).to eq("explicit") + end + + it "does not overwrite explicit references with extracted ones" do + create(:reference, plan: plan, url: "https://rubyonrails.org", source: "explicit", title: "My Title") + update_content(plan, "See [Rails](https://rubyonrails.org).") + described_class.call(plan: plan) + + ref = plan.references.find_by(url: "https://rubyonrails.org") + expect(ref.source).to eq("explicit") + expect(ref.title).to eq("My Title") + end + + it "is idempotent" do + update_content(plan, "See [Rails](https://rubyonrails.org).") + described_class.call(plan: plan) + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + end + + it "handles empty content" do + plan.current_plan_version.update_column(:content_markdown, "") + described_class.call(plan: plan) + expect(plan.references.count).to eq(0) + end + + it "sets target_plan_id for plan references" do + target_plan = create(:plan, created_by_user: user) + update_content(plan, "See [other plan](https://coplan.example.com/plans/#{target_plan.id}).") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.reference_type).to eq("plan") + expect(ref.target_plan_id).to eq(target_plan.id) + end + + it "does not set target_plan_id for self-references" do + update_content(plan, "See [this plan](https://coplan.example.com/plans/#{plan.id}).") + described_class.call(plan: plan) + + ref = plan.references.first + expect(ref.target_plan_id).to be_nil + end + + it "extracts bare URLs" do + update_content(plan, "Visit https://example.com for more info.") + described_class.call(plan: plan) + + expect(plan.references.count).to eq(1) + expect(plan.references.first.url).to eq("https://example.com") + end + end +end diff --git a/spec/system/references_spec.rb b/spec/system/references_spec.rb new file mode 100644 index 0000000..97e5a2a --- /dev/null +++ b/spec/system/references_spec.rb @@ -0,0 +1,144 @@ +require "rails_helper" + +RSpec.describe "Plan references", type: :system do + let(:user) { create(:coplan_user, email: "refuser@example.com") } + let(:plan) do + p = CoPlan::Plan.create!(title: "Test Plan", created_by_user: user) + version = CoPlan::PlanVersion.create!( + plan: p, revision: 1, + content_markdown: "# Hello\n\nSome content here.", + actor_type: "human", actor_id: user.id + ) + p.update!(current_plan_version: version, current_revision: 1) + p + end + + before do + visit sign_in_path + fill_in "Email address", with: user.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + end + + describe "Stimulus tab switching" do + it "toggles panel visibility and updates URL via replaceState" do + visit plan_path(plan) + + # Content visible, references hidden (display:none) + expect(page).to have_content("Some content here") + expect(page).not_to have_content("No references yet") + + click_link "References" + + # JS toggles the hidden class — no page reload + expect(page).to have_content("No references yet") + expect(page).not_to have_content("Some content here") + + # Stimulus controller pushes ?tab=references via replaceState + uri = URI.parse(current_url) + expect(Rack::Utils.parse_query(uri.query)).to include("tab" => "references") + + click_link "Content" + + # Tab param removed for default tab + uri = URI.parse(current_url) + expect(uri.query.to_s).not_to include("tab=") + + # Content restored + expect(page).to have_content("Some content here") + expect(page).not_to have_content("No references yet") + end + end + + describe "adding references via Turbo Stream" do + it "appends reference to the DOM without navigating away from the tab" do + visit plan_path(plan, tab: "references") + + # Open the
form + find("summary", text: "+ Add Reference").click + expect(page).to have_css("details[open]") + + fill_in "reference[url]", with: "https://github.com/org/repo" + fill_in "reference[title]", with: "My Repo" + fill_in "reference[key]", with: "my-repo" + click_button "Add" + + # Turbo Stream replaces the list — reference appears without navigation + expect(page).to have_link("My Repo", href: "https://github.com/org/repo") + expect(page).not_to have_content("No references yet") + + # Count span updated in-place via Turbo Stream (separate stream target) + expect(page).to have_css("#references-count", text: "1") + + # Still on references tab — Turbo Stream didn't cause a Turbo visit + # (content tab remains hidden, references tab content is visible) + expect(page).not_to have_content("Some content here") + expect(page).to have_content("My Repo") + end + + it "supports sequential adds with form re-expansion" do + visit plan_path(plan, tab: "references") + + find("summary", text: "+ Add Reference").click + fill_in "reference[url]", with: "https://github.com/org/repo" + fill_in "reference[title]", with: "Repo One" + click_button "Add" + expect(page).to have_content("Repo One") + expect(page).to have_css("#references-count", text: "1") + + # After Turbo Stream replaces the partial,
is collapsed; + # user must be able to re-expand and add another + find("summary", text: "+ Add Reference").click + fill_in "reference[url]", with: "https://github.com/org/other" + fill_in "reference[title]", with: "Repo Two" + click_button "Add" + + expect(page).to have_content("Repo One") + expect(page).to have_content("Repo Two") + expect(page).to have_css("#references-count", text: "2") + end + end + + describe "removing references via Turbo Stream" do + it "removes reference from DOM with confirm dialog" do + create(:reference, plan: plan, url: "https://example.com", title: "Doomed", source: "explicit") + + visit plan_path(plan, tab: "references") + expect(page).to have_content("Doomed") + expect(page).to have_css("#references-count", text: "1") + + # data-turbo-confirm triggers a browser confirm dialog + accept_confirm("Remove this reference?") do + click_button "✕" + end + + # Turbo Stream removes the reference and updates count + expect(page).not_to have_content("Doomed") + expect(page).to have_css("#references-count", text: "0") + expect(page).to have_content("No references yet") + end + end + + describe "tab count updates across tab switches" do + it "updates the references count badge visible in the tab nav" do + visit plan_path(plan) + + # Count starts at 0 + expect(page).to have_css("#references-count", text: "0") + + # Switch to references, add one + click_link "References" + find("summary", text: "+ Add Reference").click + fill_in "reference[url]", with: "https://github.com/org/repo" + fill_in "reference[title]", with: "My Repo" + click_button "Add" + + # Count updated via Turbo Stream — visible even in the tab nav + expect(page).to have_css("#references-count", text: "1") + + # Switch back to content — count persists (it's outside the panels) + click_link "Content" + expect(page).to have_css("#references-count", text: "1") + end + end +end