Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/images/arrow-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/assets/images/arrows-horizontal.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/assets/images/link.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/assets/stylesheets/icons.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 "); }
Expand Down Expand Up @@ -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 "); }
Expand Down
94 changes: 94 additions & 0 deletions app/assets/stylesheets/links.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
48 changes: 48 additions & 0 deletions app/controllers/cards/links_controller.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion app/models/card.rb
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions app/models/card/linkable.rb
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions app/models/card_link.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/views/cards/_container.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<div class="card__content">
<%= render "cards/container/content", card: card %>
<%= render "cards/display/perma/steps", card: card %>
<%= render "cards/display/perma/links", card: card %>
</div>

<% if card.published? %>
Expand Down
40 changes: 40 additions & 0 deletions app/views/cards/display/perma/_links.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<div id="<%= dom_id(card, :links) %>" class="card__links">
<div class="position-relative" data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
<button class="link-picker__button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
data-controller="tooltip hotkey" data-action="click->dialog#open:stop keydown.l@document->hotkey#click">
<%= icon_tag "link" %>
<span class="for-screen-reader">Link to another card</span>
</button>

<dialog class="link-picker popup panel flex-column align-start justify-start fill-white shadow txt-small"
data-dialog-target="dialog"
data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing">
<%= turbo_frame_tag card, :linking, src: new_card_link_path(card), loading: :lazy, refresh: :morph %>
</dialog>
</div>

<% if card.parents.any? %>
<div class="links-group">
<% card.parents.each do |parent| %>
<%= render "cards/links/link_row", linked_card: parent, type: :parent, card: card %>
<% end %>
</div>
<% end %>

<% if card.children.any? %>
<div class="links-group">
<% card.children.each do |child| %>
<%= render "cards/links/link_row", linked_card: child, type: :child, card: card %>
<% end %>
</div>
<% end %>

<% if card.related_cards.any? %>
<div class="links-group">
<% card.related_cards.each do |rel| %>
<%= render "cards/links/link_row", linked_card: rel, type: :related, card: card %>
<% end %>
</div>
<% end %>
</div>
24 changes: 24 additions & 0 deletions app/views/cards/links/_link_row.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<div class="link-row">
<span class="link-type-icon txt-gray">
<%= icon_tag(type == :parent ? "arrow-up" : type == :child ? "arrow-down" : "arrows-horizontal") %>
</span>

<%= link_to card_path(linked_card), class: "link-card min-width flex align-center gap-quarter" do %>
<span class="link-card-badge" style="background: <%= linked_card.column&.color || 'var(--fill-gray)' %>">
#<%= linked_card.number %>
</span>
<span class="min-width overflow-ellipsis"><%= truncate(linked_card.title, length: 40) %></span>
<% end %>

<%# Find link in outgoing_links first, then check incoming_links with inverse type %>
<% link = card.outgoing_links.find_by(target_card: linked_card, link_type: type) %>
<% unless link %>
<% inverse_type = type == :parent ? :child : type == :child ? :parent : :related %>
<% link = card.incoming_links.find_by(source_card: linked_card, link_type: inverse_type) %>
<% end %>
<% if link %>
<%= button_to card_link_path(link.source_card, link), method: :delete, class: "btn btn--icon link-remove" do %>
<%= icon_tag "close" %>
<% end %>
<% end %>
</div>
20 changes: 20 additions & 0 deletions app/views/cards/links/_search_results.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<ul class="popup__list margin-block-start-half">
<% cards.each do |target| %>
<li class="popup__item" data-navigable-list-target="item">
<%= button_to card_links_path(card),
method: :post,
params: { target_card_id: target.id, link_type: link_type },
class: "btn popup__btn full-width flex align-center gap-half",
data: { turbo_frame: "_top" } do %>
<span class="link-card-badge" style="background: <%= target.column&.color || 'var(--fill-gray)' %>">
#<%= target.number %>
</span>
<span class="min-width overflow-ellipsis"><%= target.title %></span>
<% end %>
</li>
<% end %>

<% if cards.empty? %>
<li class="popup__item txt-gray txt-small">No cards found</li>
<% end %>
</ul>
7 changes: 7 additions & 0 deletions app/views/cards/links/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -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 } %>
Loading