Project board
The kanban flagship — cards move across lanes with enter/exit effects, live in every open tab, with inline rename, confirm-gated archive, and a per-visitor effect-style picker.
Move a card and watch it animate — in every open tab.
To do
1Doing
0Done
1# One shared board, three lanes. A move is one reply: the row's remove
# (its exit animates before the element leaves), the append into the new
# lane, and a reactive:js repaint of every count badge — broadcast to
# peers with the actor's echo excluded:
render ProjectBoardComponent.new
# frozen_string_literal: true
# The Project board (issue #216) — the kanban flagship. Three fixed lanes on
# ONE shared public board; each card is its own nested reactive root (see
# BoardCardComponent) owning move/rename/archive, while the board owns the
# per-lane composers and the effect-style picker (signed state, threaded into
# every row so per-call effects use the visitor's pick).
#
# Count badges are kept live EVERYWHERE via reactive:js text ops — the actor's
# reply chains them (reply.js) and every mutation broadcasts them — closing
# the gap where a peer tab's badges go stale after a row broadcast. Empty
# lanes are pure CSS (the lane list's :empty pseudo shows the placeholder), so
# the 0↔1 boundary needs no bookkeeping on either path.
class ProjectBoardComponent < Phlex::HTML
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
include Phlex::Rails::Helpers::TurboStreamFrom
STYLES = %w[default fade slide scale highlight shake random].freeze
LANE_TITLES = { 'todo' => 'To do', 'doing' => 'Doing', 'done' => 'Done' }.freeze
# The lane list's :empty placeholder — rendered by CSS the moment the last
# card leaves, restored the moment one arrives; no server bookkeeping.
EMPTY_LANE_CSS = "empty:before:content-['No_cards_yet'] empty:before:block " \
'empty:before:rounded-field empty:before:border empty:before:border-dashed ' \
'empty:before:border-base-300 empty:before:py-4 empty:before:text-center ' \
'empty:before:text-xs empty:before:opacity-50'
reactive_state :style
# Composer inputs are named PER LANE (title_todo/…): the client's field
# sweep is root-wide, so three same-named inputs would collide (last wins);
# the lane param picks the right one out of the declared trio.
action :add_card, params: { title_todo: :string, title_doing: :string, title_done: :string, lane: :string }
action :set_style, params: { style: :string }
def initialize(style: 'default')
@style = STYLES.include?(style) ? style : 'default'
end
def id = 'project-board'
class << self
def lane_target(lane) = "lane-#{lane}-cards"
# One js chain repainting all three count badges from server truth —
# chained onto the actor's reply AND broadcast to peers after every
# mutation, so no tab's badges ever go stale.
def count_ops
Card::LANES.reduce(Phlex::Reactive::JS.new) do |chain, lane|
chain.text("#lane-#{lane}-count", Card.by_lane(lane).count)
end
end
def broadcast_counts(exclude:)
broadcast_to(*Card.stream_key, js: count_ops, exclude:)
end
end
# Add into the named lane. The full-board re-render (the todo-list
# precedent) clears the composer and repaints counts in one go; peers get
# the row append + the count sync.
def add_card(lane:, title_todo: '', title_doing: '', title_done: '')
return reply.replace unless Card::LANES.include?(lane)
title = { 'todo' => title_todo, 'doing' => title_doing, 'done' => title_done }.fetch(lane).to_s.strip
return reply.replace.flash(:error, 'A card needs a title', dismiss_after: 2500) if title.blank?
card = Card.create!(title:, lane:, position: Card.next_position(lane))
BoardCardComponent.broadcast_to(*Card.stream_key, append: card,
target: self.class.lane_target(lane),
exclude: reactive_connection_id)
self.class.broadcast_counts(exclude: reactive_connection_id)
reply.replace
end
# The effect-style picker: whitelisted, signed into state, and threaded into
# every row on the re-render so subsequent moves animate with the pick.
def set_style(style:)
@style = style if STYLES.include?(style)
reply.replace
end
def view_template
div(**reactive_root(class: 'flex flex-col gap-4', data: { testid: 'project-board' })) do
turbo_stream_from(*Card.stream_key) # cross-tab sync
# The flash landing zone (Phlex::Reactive.flash_target default) — the
# archive toast and the blank-title error append here.
div(id: 'flash', class: 'flex flex-col gap-1', data: { testid: 'flash' })
header_row
div(class: 'grid gap-4 sm:grid-cols-3') do
Card::LANES.each { |lane| lane_column(lane) }
end
end
end
private
def header_row
div(class: 'flex flex-wrap items-center justify-between gap-2') do
span(class: 'text-sm opacity-70') { 'Move a card and watch it animate — in every open tab.' }
style_picker
end
end
def style_picker
div(class: 'join', data: { testid: 'style-picker' }) do
STYLES.each do |name|
button(**mix(on(:set_style, style: name),
class: "join-item btn btn-xs #{'btn-active' if name == @style}",
data: { testid: "style-#{name}" })) { name }
end
end
end
def lane_column(lane)
div(class: 'flex flex-col gap-3 rounded-box border border-base-300 bg-base-200/50 p-3',
data: { testid: "lane-#{lane}" }) do
div(class: 'flex items-center justify-between') do
h3(class: 'text-sm font-semibold') { LANE_TITLES.fetch(lane) }
span(id: "lane-#{lane}-count", class: 'badge badge-sm', data: { testid: "count-#{lane}" }) do
Card.by_lane(lane).count.to_s
end
end
ul(id: self.class.lane_target(lane), class: "flex flex-col gap-2 #{EMPTY_LANE_CSS}") do
Card.by_lane(lane).each { |card| render BoardCardComponent.new(card:, style: @style) }
end
composer(lane)
end
end
def composer(lane)
div(class: 'flex gap-2') do
input(**mix(on(:add_card, lane:, event: 'keydown.enter'),
name: "title_#{lane}", placeholder: 'Add a card…', autocomplete: 'off',
class: 'input input-sm input-bordered min-w-0 flex-1',
data: { testid: "new-card-#{lane}" }))
button(**mix(on(:add_card, lane:, busy: '…'),
class: 'btn btn-sm btn-primary', data: { testid: "add-#{lane}" })) { 'Add' }
end
end
end