diff --git a/app/assets/images/arrow-down.svg b/app/assets/images/arrow-down.svg new file mode 100644 index 0000000000..43cafc43ea --- /dev/null +++ b/app/assets/images/arrow-down.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/arrows-horizontal.svg b/app/assets/images/arrows-horizontal.svg new file mode 100644 index 0000000000..0be72c4880 --- /dev/null +++ b/app/assets/images/arrows-horizontal.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/link.svg b/app/assets/images/link.svg new file mode 100644 index 0000000000..148f905db1 --- /dev/null +++ b/app/assets/images/link.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 42a3a21ece..6f2491a839 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -24,6 +24,8 @@ .icon--arrow-left { --svg: url("arrow-left.svg "); } .icon--arrow-right { --svg: url("arrow-right.svg "); } .icon--arrow-up { --svg: url("arrow-up.svg "); } + .icon--arrow-down { --svg: url("arrow-down.svg"); } + .icon--arrows-horizontal { --svg: url("arrows-horizontal.svg"); } .icon--art { --svg: url("art.svg "); } .icon--assigned { --svg: url("assigned.svg "); } .icon--attachment { --svg: url("attachment.svg "); } @@ -63,6 +65,7 @@ .icon--home { --svg: url("home.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } + .icon--link { --svg: url("link.svg"); } .icon--lock { --svg: url("lock.svg "); } .icon--logout { --svg: url("logout.svg "); } .icon--marker { --svg: url("marker.svg "); } diff --git a/app/assets/stylesheets/links.css b/app/assets/stylesheets/links.css new file mode 100644 index 0000000000..f5aad4018f --- /dev/null +++ b/app/assets/stylesheets/links.css @@ -0,0 +1,94 @@ +@layer components { + .card__links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: calc(var(--inline-space) / 2); + margin-block-start: var(--block-space); + } + + .link-picker { + --panel-border-radius: 1em; + --panel-padding: 0.75em; + --panel-size: max-content; + + inline-size: auto !important; + inset: 0 auto auto 0; + min-inline-size: 28ch; + position: absolute; + z-index: 2; + + &[open] { + display: flex; + } + } + + .link-picker__button { + font-size: 0.6em; + margin-block-start: 0.1em; + } + + .links-group { + display: flex; + flex-wrap: wrap; + gap: calc(var(--inline-space) / 2); + } + + .link-row { + display: flex; + align-items: center; + gap: calc(var(--inline-space) / 3); + font-size: 0.875em; + + &:hover .link-remove { + opacity: 1; + } + } + + .link-type-icon { + font-size: 0.75em; + } + + .link-card { + display: flex; + align-items: center; + gap: calc(var(--inline-space) / 3); + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .link-card-badge { + --badge-size: 1.5em; + + background-color: var(--fill-gray); + border-radius: 0.25em; + color: var(--color-ink); + font-size: 0.75em; + font-weight: 600; + padding: 0.1em 0.3em; + white-space: nowrap; + } + + .link-remove { + --btn-padding: 0.2em; + --icon-size: 0.75em; + + opacity: 0; + transition: opacity 0.15s ease; + } + + .link-type-tabs { + display: flex; + gap: calc(var(--inline-space) / 2); + + .btn--active { + background-color: var(--color-ink); + border-color: var(--color-ink); + color: var(--color-ink-inverted); + } + } +} diff --git a/app/controllers/cards/links_controller.rb b/app/controllers/cards/links_controller.rb new file mode 100644 index 0000000000..74d96c9603 --- /dev/null +++ b/app/controllers/cards/links_controller.rb @@ -0,0 +1,48 @@ +class Cards::LinksController < ApplicationController + include CardScoped + + def new + existing_ids = @card.linked_cards.pluck(:id) + @card.linking_cards.pluck(:id) + @available_cards = Current.account.cards + .where.not(id: [ @card.id ] + existing_ids) + .order(number: :desc) + .limit(50) + @link_type = params[:link_type] || "related" + fresh_when etag: [ @available_cards, @card.outgoing_links ] + end + + def create + target = Current.account.cards.find(params[:target_card_id]) + link_type = params[:link_type]&.to_sym || :related + + @card.toggle_link(target, link_type: link_type) + @target = target + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end + end + + def destroy + @link = @card.outgoing_links.find(params[:id]) + @target = @link.target_card + @link.destroy + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end + end + + def search + query = params[:q].to_s.strip + @link_type = params[:link_type] || "related" + existing_ids = @card.linked_cards.pluck(:id) + @card.linking_cards.pluck(:id) + @cards = Current.account.cards + .where.not(id: [ @card.id ] + existing_ids) + .search_by_title_or_number(query) + .order(number: :desc) + .limit(20) + end +end diff --git a/app/models/card.rb b/app/models/card.rb index dbb45f184e..c63ff04557 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,6 @@ class Card < ApplicationRecord include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + Exportable, Golden, Linkable, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } @@ -25,6 +25,11 @@ class Card < ApplicationRecord scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } + scope :search_by_title_or_number, ->(query) { + sanitized = sanitize_sql_like(query) + where("LOWER(title) LIKE LOWER(?) OR CAST(number AS TEXT) LIKE ?", "%#{sanitized}%", "#{query}%") + } + scope :indexed_by, ->(index) do case index when "stalled" then stalled diff --git a/app/models/card/linkable.rb b/app/models/card/linkable.rb new file mode 100644 index 0000000000..15f9bade09 --- /dev/null +++ b/app/models/card/linkable.rb @@ -0,0 +1,53 @@ +module Card::Linkable + extend ActiveSupport::Concern + + included do + has_many :outgoing_links, class_name: "CardLink", + foreign_key: :source_card_id, dependent: :destroy + has_many :incoming_links, class_name: "CardLink", + foreign_key: :target_card_id, dependent: :destroy + end + + # Return ActiveRecord::Relations for chainability (not arrays) + # Parents: cards I marked as parent + cards that marked me as child + def parents + Card.where(id: outgoing_links.parent.select(:target_card_id)) + .or(Card.where(id: incoming_links.child.select(:source_card_id))) + end + + # Children: cards I marked as child + cards that marked me as parent + def children + Card.where(id: outgoing_links.child.select(:target_card_id)) + .or(Card.where(id: incoming_links.parent.select(:source_card_id))) + end + + # Related: bidirectional - both outgoing and incoming related links + def related_cards + Card.where(id: outgoing_links.related.select(:target_card_id)) + .or(Card.where(id: incoming_links.related.select(:source_card_id))) + end + + def linked_cards + Card.where(id: outgoing_links.select(:target_card_id)) + end + + def linking_cards + Card.where(id: incoming_links.select(:source_card_id)) + end + + def has_links? + outgoing_links.exists? || incoming_links.exists? + end + + # Toggle-style API (matches Card::Taggable pattern) + def toggle_link(target_card, link_type: :related) + existing = outgoing_links.find_by(target_card: target_card, link_type: link_type) + if existing + existing.destroy + :removed + else + outgoing_links.create!(target_card: target_card, link_type: link_type) + :added + end + end +end diff --git a/app/models/card_link.rb b/app/models/card_link.rb new file mode 100644 index 0000000000..e289ec2817 --- /dev/null +++ b/app/models/card_link.rb @@ -0,0 +1,46 @@ +class CardLink < ApplicationRecord + enum :link_type, { related: 0, parent: 1, child: 2 } + + belongs_to :source_card, class_name: "Card", touch: true + belongs_to :target_card, class_name: "Card", touch: true + + validates :source_card_id, uniqueness: { scope: [ :target_card_id, :link_type ] } + validate :no_self_links + validate :same_account + validate :no_circular_parent_chain, if: :parent? + + # Derive account from source_card (not stored) + def account + source_card&.account + end + + private + def no_self_links + errors.add(:target_card, "cannot link to itself") if source_card_id == target_card_id + end + + def same_account + return if source_card&.account_id == target_card&.account_id + errors.add(:target_card, "must be in the same account") + end + + def no_circular_parent_chain + # BFS to detect cycles through ALL parent paths + return unless parent? + + visited = Set.new + queue = [ target_card_id ] + + while (current_id = queue.shift) + return errors.add(:target_card, "would create circular parent chain") if current_id == source_card_id + next if visited.include?(current_id) + + visited << current_id + + # Get all parents of current card + CardLink.where(source_card_id: current_id, link_type: :parent) + .pluck(:target_card_id) + .each { |parent_id| queue << parent_id } + end + end +end diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index c852366835..4a4c7d20ea 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -16,6 +16,7 @@
<%= render "cards/container/content", card: card %> <%= render "cards/display/perma/steps", card: card %> + <%= render "cards/display/perma/links", card: card %>
<% if card.published? %> diff --git a/app/views/cards/display/perma/_links.html.erb b/app/views/cards/display/perma/_links.html.erb new file mode 100644 index 0000000000..df1b6f3468 --- /dev/null +++ b/app/views/cards/display/perma/_links.html.erb @@ -0,0 +1,40 @@ + diff --git a/app/views/cards/links/_link_row.html.erb b/app/views/cards/links/_link_row.html.erb new file mode 100644 index 0000000000..61266370aa --- /dev/null +++ b/app/views/cards/links/_link_row.html.erb @@ -0,0 +1,24 @@ + diff --git a/app/views/cards/links/_search_results.html.erb b/app/views/cards/links/_search_results.html.erb new file mode 100644 index 0000000000..88baf58f95 --- /dev/null +++ b/app/views/cards/links/_search_results.html.erb @@ -0,0 +1,20 @@ + diff --git a/app/views/cards/links/create.turbo_stream.erb b/app/views/cards/links/create.turbo_stream.erb new file mode 100644 index 0000000000..b0a6705cae --- /dev/null +++ b/app/views/cards/links/create.turbo_stream.erb @@ -0,0 +1,7 @@ +<%= turbo_stream.replace [@card, :links], + partial: "cards/display/perma/links", + locals: { card: @card.reload } %> + +<%= turbo_stream.replace [@target, :links], + partial: "cards/display/perma/links", + locals: { card: @target.reload } %> diff --git a/app/views/cards/links/destroy.turbo_stream.erb b/app/views/cards/links/destroy.turbo_stream.erb new file mode 100644 index 0000000000..8273ae7d77 --- /dev/null +++ b/app/views/cards/links/destroy.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.replace [@card, :links], + partial: "cards/display/perma/links", + method: :morph, + locals: { card: @card.reload } %> + +<%= turbo_stream.replace [@target, :links], + partial: "cards/display/perma/links", + method: :morph, + locals: { card: @target.reload } %> diff --git a/app/views/cards/links/new.html.erb b/app/views/cards/links/new.html.erb new file mode 100644 index 0000000000..e5e230a545 --- /dev/null +++ b/app/views/cards/links/new.html.erb @@ -0,0 +1,37 @@ +<%= turbo_frame_tag @card, :linking do %> +
+
+ Link to… + l +
+ + + + <%= form_with url: search_card_links_path(@card), method: :get, + data: { controller: "form", turbo_frame: dom_id(@card, :link_results) }, + class: "full-width margin-block-half" do |form| %> + <%= form.search_field :q, placeholder: "Search by title or card #…", + class: "input input--transparent txt-small full-width", + autofocus: true, autocomplete: "off", + data: { "1p-ignore": "true", + action: "input->form#debouncedSubmit" } %> + <%= form.hidden_field :link_type, value: @link_type %> + <% end %> + + <%= turbo_frame_tag dom_id(@card, :link_results) do %> + <%= render "search_results", cards: @available_cards, card: @card, link_type: @link_type %> + <% end %> +
+<% end %> diff --git a/app/views/cards/links/search.html.erb b/app/views/cards/links/search.html.erb new file mode 100644 index 0000000000..40a1bd1d25 --- /dev/null +++ b/app/views/cards/links/search.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag dom_id(@card, :link_results) do %> + <%= render "search_results", cards: @cards, card: @card, link_type: @link_type %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index d84fa06e38..bca37e3f11 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,6 +88,12 @@ resources :steps resources :taggings + resources :links, only: [:new, :create, :destroy] do + collection do + get :search + end + end + resources :comments do resources :reactions, module: :comments end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 58afcedf50..e1815ed0c3 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -11,11 +11,11 @@ # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema[8.2].define(version: 1) do - create_table "solid_cable_messages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", limit: 1024, null: false t.bigint "channel_hash", null: false t.datetime "created_at", null: false - t.binary "payload", size: :long, null: false + t.binary "payload", limit: 4294967295, null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/migrate/20251225200314_create_card_links.rb b/db/migrate/20251225200314_create_card_links.rb new file mode 100644 index 0000000000..1c1911e3d2 --- /dev/null +++ b/db/migrate/20251225200314_create_card_links.rb @@ -0,0 +1,21 @@ +class CreateCardLinks < ActiveRecord::Migration[8.2] + def change + create_table :card_links, id: :uuid do |t| + t.references :source_card, null: false, type: :uuid, index: true + t.references :target_card, null: false, type: :uuid, index: true + t.integer :link_type, null: false, default: 0 + t.timestamps + end + + # Foreign keys with cascade delete + add_foreign_key :card_links, :cards, column: :source_card_id, on_delete: :cascade + add_foreign_key :card_links, :cards, column: :target_card_id, on_delete: :cascade + + # Unique constraint including link_type + add_index :card_links, [:source_card_id, :target_card_id, :link_type], + unique: true, name: "index_card_links_unique" + + # Prevent self-linking at database level + add_check_constraint :card_links, "source_card_id != target_card_id", name: "no_self_links" + end +end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 84fd827bba..a0ff02a8bc 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do +ActiveRecord::Schema[8.2].define(version: 2025_12_25_200314) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -188,6 +188,18 @@ t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true end + create_table "card_links", id: :uuid, force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "link_type", default: 0, null: false + t.uuid "source_card_id", null: false + t.uuid "target_card_id", null: false + t.datetime "updated_at", null: false + t.index ["source_card_id", "target_card_id", "link_type"], name: "index_card_links_unique", unique: true + t.index ["source_card_id"], name: "index_card_links_on_source_card_id" + t.index ["target_card_id"], name: "index_card_links_on_target_card_id" + t.check_constraint "source_card_id != target_card_id", name: "no_self_links" + end + create_table "card_not_nows", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false @@ -479,7 +491,7 @@ t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 - t.string "request_id" + t.string "request_id", limit: 255 t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" @@ -595,6 +607,9 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions" end + + add_foreign_key "card_links", "cards", column: "source_card_id", on_delete: :cascade + add_foreign_key "card_links", "cards", column: "target_card_id", on_delete: :cascade execute "CREATE VIRTUAL TABLE search_records_fts USING fts5(\n title,\n content,\n tokenize='porter'\n )" end diff --git a/test/controllers/cards/links_controller_test.rb b/test/controllers/cards/links_controller_test.rb new file mode 100644 index 0000000000..f35ba4081d --- /dev/null +++ b/test/controllers/cards/links_controller_test.rb @@ -0,0 +1,161 @@ +require "test_helper" + +class Cards::LinksControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + @card = cards(:logo) + @target = cards(:layout) + end + + # GET new + + test "GET new renders link picker modal" do + get new_card_link_path(@card) + assert_response :success + end + + test "GET new excludes self from available cards" do + get new_card_link_path(@card) + assert_response :success + assert_no_match /##{@card.number}/, response.body + end + + test "GET new excludes already linked cards" do + CardLink.create!(source_card: @card, target_card: @target, link_type: :related) + get new_card_link_path(@card) + assert_response :success + end + + test "GET new respects link_type param" do + get new_card_link_path(@card, link_type: "parent") + assert_response :success + assert_match /btn--active.*Parent/m, response.body + end + + # POST create + + test "POST create adds link via turbo stream" do + assert_difference -> { CardLink.count } do + post card_links_path(@card), + params: { target_card_id: @target.id, link_type: "related" }, + as: :turbo_stream + end + assert_response :success + assert_turbo_stream action: :replace, target: dom_id(@card, :links) + end + + test "POST create updates both cards' link sections" do + post card_links_path(@card), + params: { target_card_id: @target.id, link_type: "related" }, + as: :turbo_stream + + # Should have two turbo_stream.replace calls + assert_turbo_stream action: :replace, target: dom_id(@card, :links) + assert_turbo_stream action: :replace, target: dom_id(@target, :links) + end + + test "POST create with parent link type" do + post card_links_path(@card), + params: { target_card_id: @target.id, link_type: "parent" }, + as: :turbo_stream + + assert_response :success + link = CardLink.last + assert_equal "parent", link.link_type + end + + test "POST create with child link type" do + post card_links_path(@card), + params: { target_card_id: @target.id, link_type: "child" }, + as: :turbo_stream + + assert_response :success + link = CardLink.last + assert_equal "child", link.link_type + end + + test "POST create toggles existing link off" do + CardLink.create!(source_card: @card, target_card: @target, link_type: :related) + + assert_difference -> { CardLink.count }, -1 do + post card_links_path(@card), + params: { target_card_id: @target.id, link_type: "related" }, + as: :turbo_stream + end + assert_response :success + end + + test "POST create responds to json" do + post card_links_path(@card), + params: { target_card_id: @target.id, link_type: "related" }, + as: :json + + assert_response :no_content + end + + test "POST create rejects cross-account target" do + other_card = cards(:radio) + + post card_links_path(@card), + params: { target_card_id: other_card.id, link_type: "related" }, + as: :turbo_stream + + assert_response :not_found + end + + # DELETE destroy + + test "DELETE destroy removes link via turbo stream" do + link = CardLink.create!(source_card: @card, target_card: @target, link_type: :related) + + assert_difference -> { CardLink.count }, -1 do + delete card_link_path(@card, link), as: :turbo_stream + end + assert_response :success + end + + test "DELETE destroy updates both cards' link sections" do + link = CardLink.create!(source_card: @card, target_card: @target, link_type: :related) + + delete card_link_path(@card, link), as: :turbo_stream + + assert_turbo_stream action: :replace, target: dom_id(@card, :links) + assert_turbo_stream action: :replace, target: dom_id(@target, :links) + end + + test "DELETE destroy responds to json" do + link = CardLink.create!(source_card: @card, target_card: @target, link_type: :related) + + delete card_link_path(@card, link), as: :json + + assert_response :no_content + end + + # GET search + + test "GET search finds cards by title" do + get search_card_links_path(@card), params: { q: @target.title[0..5], link_type: "related" } + assert_response :success + assert_match @target.title, response.body + end + + test "GET search finds cards by number" do + get search_card_links_path(@card), params: { q: @target.number.to_s, link_type: "related" } + assert_response :success + assert_match "##{@target.number}", response.body + end + + test "GET search excludes self" do + get search_card_links_path(@card), params: { q: @card.title[0..5], link_type: "related" } + assert_response :success + assert_no_match /##{@card.number}/, response.body + end + + test "GET search only returns cards from same account" do + other_card = cards(:radio) + + get search_card_links_path(@card), params: { q: other_card.title, link_type: "related" } + assert_response :success + assert_no_match other_card.title, response.body + end +end diff --git a/test/fixtures/card_links.yml b/test/fixtures/card_links.yml new file mode 100644 index 0000000000..d5fa33eef6 --- /dev/null +++ b/test/fixtures/card_links.yml @@ -0,0 +1 @@ +# Empty fixture file - links are created in tests as needed diff --git a/test/models/card_link_test.rb b/test/models/card_link_test.rb new file mode 100644 index 0000000000..0e5b644b30 --- /dev/null +++ b/test/models/card_link_test.rb @@ -0,0 +1,182 @@ +require "test_helper" + +class CardLinkTest < ActiveSupport::TestCase + setup do + @card_a = cards(:logo) + @card_b = cards(:layout) + @card_c = cards(:text) + @card_other_account = cards(:radio) + end + + test "creates link between cards" do + link = CardLink.create!( + source_card: @card_a, + target_card: @card_b, + link_type: :parent + ) + assert link.persisted? + assert_equal @card_a.account, link.account + end + + test "prevents self-linking" do + link = CardLink.new(source_card: @card_a, target_card: @card_a, link_type: :related) + assert_not link.valid? + assert_includes link.errors[:target_card], "cannot link to itself" + end + + test "prevents cross-account linking" do + link = CardLink.new(source_card: @card_a, target_card: @card_other_account, link_type: :related) + assert_not link.valid? + assert_includes link.errors[:target_card], "must be in the same account" + end + + test "prevents duplicate links of same type" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + duplicate = CardLink.new(source_card: @card_a, target_card: @card_b, link_type: :related) + assert_not duplicate.valid? + end + + test "allows same cards with different link types" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + parent_link = CardLink.new(source_card: @card_a, target_card: @card_b, link_type: :parent) + assert parent_link.valid? + end + + test "prevents simple circular parent chain" do + # A is parent of B + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :parent) + # B trying to be parent of A should fail + link = CardLink.new(source_card: @card_b, target_card: @card_a, link_type: :parent) + assert_not link.valid? + assert_includes link.errors[:target_card], "would create circular parent chain" + end + + test "prevents transitive circular parent chain" do + # A → parent → B + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :parent) + # B → parent → C + CardLink.create!(source_card: @card_b, target_card: @card_c, link_type: :parent) + # C → parent → A should fail (creates A→B→C→A cycle) + link = CardLink.new(source_card: @card_c, target_card: @card_a, link_type: :parent) + assert_not link.valid? + assert_includes link.errors[:target_card], "would create circular parent chain" + end + + test "allows circular related links" do + # Related links can be circular - no hierarchy + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + reverse = CardLink.new(source_card: @card_b, target_card: @card_a, link_type: :related) + assert reverse.valid? + end + + test "cascade deletes when source card is destroyed" do + link = CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + assert_difference -> { CardLink.count }, -1 do + @card_a.destroy + end + end + + test "cascade deletes when target card is destroyed" do + link = CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + assert_difference -> { CardLink.count }, -1 do + @card_b.destroy + end + end +end + +class CardLinkableTest < ActiveSupport::TestCase + setup do + @card_a = cards(:logo) + @card_b = cards(:layout) + @card_c = cards(:text) + end + + test "parents returns parent cards as relation" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :parent) + CardLink.create!(source_card: @card_a, target_card: @card_c, link_type: :parent) + + parents = @card_a.parents + assert_kind_of ActiveRecord::Relation, parents + assert_includes parents, @card_b + assert_includes parents, @card_c + end + + test "children returns child cards as relation" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :child) + + children = @card_a.children + assert_kind_of ActiveRecord::Relation, children + assert_includes children, @card_b + end + + test "related_cards returns related cards as relation" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + + related = @card_a.related_cards + assert_kind_of ActiveRecord::Relation, related + assert_includes related, @card_b + end + + test "linked_cards returns all outgoing linked cards" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :parent) + CardLink.create!(source_card: @card_a, target_card: @card_c, link_type: :related) + + linked = @card_a.linked_cards + assert_includes linked, @card_b + assert_includes linked, @card_c + end + + test "linking_cards returns cards that link to this card" do + CardLink.create!(source_card: @card_b, target_card: @card_a, link_type: :parent) + + linking = @card_a.linking_cards + assert_includes linking, @card_b + end + + test "has_links? returns true when card has outgoing links" do + assert_not @card_a.has_links? + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :related) + assert @card_a.has_links? + end + + test "has_links? returns true when card has incoming links" do + assert_not @card_a.has_links? + CardLink.create!(source_card: @card_b, target_card: @card_a, link_type: :related) + assert @card_a.has_links? + end + + test "toggle_link adds link when none exists" do + assert_difference -> { CardLink.count } do + result = @card_a.toggle_link(@card_b, link_type: :related) + assert_equal :added, result + end + assert @card_a.linked_cards.include?(@card_b) + end + + test "toggle_link removes link when it exists" do + @card_a.toggle_link(@card_b, link_type: :related) + + assert_difference -> { CardLink.count }, -1 do + result = @card_a.toggle_link(@card_b, link_type: :related) + assert_equal :removed, result + end + assert_not @card_a.linked_cards.include?(@card_b) + end + + test "toggle_link with different types creates separate links" do + @card_a.toggle_link(@card_b, link_type: :related) + @card_a.toggle_link(@card_b, link_type: :parent) + + assert_equal 2, @card_a.outgoing_links.count + end + + test "relations are chainable with other scopes" do + CardLink.create!(source_card: @card_a, target_card: @card_b, link_type: :parent) + CardLink.create!(source_card: @card_a, target_card: @card_c, link_type: :parent) + + # Can chain with where, order, etc. + result = @card_a.parents.where(number: @card_b.number) + assert_includes result, @card_b + assert_not_includes result, @card_c + end +end