Merge pull request #1174 from basecamp/collapsing-columns+domain

Collapsing columns+domain
This commit is contained in:
Jorge Manrubia
2025-09-26 19:44:32 +02:00
committed by GitHub
103 changed files with 882 additions and 710 deletions
-10
View File
@@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git merge:*)",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}
+1 -1
View File
@@ -608,7 +608,7 @@ GEM
tiktoken_ruby (0.0.12-x86_64-linux)
timeout (0.4.3)
tsort (0.2.0)
turbo-rails (2.0.16)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -7,6 +7,6 @@ class Account::EntropyConfigurationsController < ApplicationController
private
def entropy_configuration_params
params.expect(entropy_configuration: [ :auto_close_period, :auto_reconsider_period ])
params.expect(entropy_configuration: [ :auto_postpone_period ])
end
end
-64
View File
@@ -1,64 +0,0 @@
class Cards::DropsController < ApplicationController
include FilterScoped
before_action :set_card, :set_drop_target
def create
perform_drop_action
render_column_replacement
end
private
VALID_DROP_TARGETS = %w[ considering on_deck doing closed ]
def set_card
@card = Current.user.accessible_cards.find(params[:dropped_item_id])
end
def set_drop_target
if params[:drop_target].in?(VALID_DROP_TARGETS)
@drop_target = params[:drop_target].to_sym
else
head :bad_request
end
end
def perform_drop_action
case @drop_target
when :considering
@card.reconsider
when :on_deck
@card.move_to_on_deck
when :doing
@card.engage
if params[:stage_id].present?
stage = Workflow::Stage.find(params[:stage_id])
@card.change_stage_to(stage)
end
when :closed
@card.close
end
end
def render_column_replacement
columns = Cards::Columns.new(user_filtering: @user_filtering, page_size: CardsController::PAGE_SIZE)
column = columns.public_send(@drop_target)
if @drop_target == :doing && params[:stage_id].present?
# For stage-specific doing columns, we need to render the specific stage
stage = Workflow::Stage.find(params[:stage_id])
render \
turbo_stream: turbo_stream.replace("doing-cards-#{stage.id}",
method: :morph,
partial: "cards/index/engagement/doing",
locals: { column: column, stage: stage })
else
# For other columns, use the standard approach
render \
turbo_stream: turbo_stream.replace("#{@drop_target.to_s.gsub('_', '-')}-cards",
method: :morph,
partial: "cards/index/engagement/#{@drop_target}",
locals: { column: column })
end
end
end
@@ -0,0 +1,8 @@
class Cards::NotNowsController < ApplicationController
include CardScoped
def create
@card.postpone
render_card_replacement
end
end
@@ -0,0 +1,15 @@
class Cards::TriagesController < ApplicationController
include CardScoped
def create
column = @card.collection.columns.find(params[:column_id])
@card.triage_into(column)
render_card_replacement
end
def destroy
@card.send_back_to_triage
render_card_replacement
end
end
+1 -2
View File
@@ -11,8 +11,7 @@ class CardsController < ApplicationController
PAGE_SIZE = 25
def index
@columns = Cards::Columns.new(user_filtering: @user_filtering, page_size: PAGE_SIZE)
set_page_and_extract_portion_from @filter.cards
fresh_when etag: @columns
end
@@ -0,0 +1,7 @@
class Collections::Columns::ClosedsController < ApplicationController
include CollectionScoped
def show
set_page_and_extract_portion_from @collection.cards.closed.recently_closed_first
end
end
@@ -0,0 +1,7 @@
class Collections::Columns::NotNowsController < ApplicationController
include CollectionScoped
def show
set_page_and_extract_portion_from @collection.cards.postponed.reverse_chronologically
end
end
@@ -0,0 +1,7 @@
class Collections::Columns::StreamsController < ApplicationController
include CollectionScoped
def show
set_page_and_extract_portion_from @collection.cards.awaiting_triage.reverse_chronologically
end
end
@@ -0,0 +1,14 @@
class Collections::ColumnsController < ApplicationController
include CollectionScoped
before_action :set_column
def show
set_page_and_extract_portion_from @column.cards.active.reverse_chronologically
end
private
def set_column
@column = @collection.columns.find(params[:id])
end
end
@@ -12,6 +12,6 @@ class Collections::EntropyConfigurationsController < ApplicationController
private
def entropy_configuration_params
params.expect(collection: [ :auto_close_period, :auto_reconsider_period ])
params.expect(collection: [ :auto_postpone_period ])
end
end
+5 -1
View File
@@ -7,6 +7,10 @@ class CollectionsController < ApplicationController
@collection = Collection.new
end
def show
set_page_and_extract_portion_from @collection.cards.awaiting_triage.reverse_chronologically
end
def create
@collection = Collection.create! collection_params.with_defaults(all_access: true)
redirect_to cards_path(collection_ids: [ @collection ])
@@ -38,7 +42,7 @@ class CollectionsController < ApplicationController
end
def collection_params
params.expect(collection: [ :name, :all_access, :auto_close_period, :auto_reconsider_period, :public_description ])
params.expect(collection: [ :name, :all_access, :auto_postpone_period, :public_description ])
end
def grantees
@@ -0,0 +1,9 @@
class Columns::Cards::Drops::ClosuresController < ApplicationController
include CardScoped
def create
@card.close
render turbo_stream: turbo_stream.replace("closed-cards", partial: "collections/show/closed", method: :morph, locals: { collection: @card.collection })
end
end
@@ -0,0 +1,10 @@
class Columns::Cards::Drops::ColumnsController < ApplicationController
include ActionView::RecordIdentifier, CardScoped
def create
column = @card.collection.columns.find(params[:column_id])
@card.triage_into(column)
render turbo_stream: turbo_stream.replace(dom_id(column), partial: "collections/show/column", method: :morph, locals: { column: column })
end
end
@@ -0,0 +1,9 @@
class Columns::Cards::Drops::NotNowsController < ApplicationController
include CardScoped
def create
@card.postpone
render turbo_stream: turbo_stream.replace("not-now", partial: "collections/show/not_now", method: :morph, locals: { collection: @card.collection })
end
end
@@ -0,0 +1,10 @@
class Columns::Cards::Drops::StreamsController < ApplicationController
include CardScoped
def create
@card.send_back_to_triage
set_page_and_extract_portion_from @collection.cards.awaiting_triage.reverse_chronologically
render turbo_stream: turbo_stream.replace("the-stream", partial: "collections/show/stream", method: :morph, locals: { collection: @card.collection, page: @page })
end
end
@@ -6,10 +6,10 @@ class Public::CollectionsController < ApplicationController
layout "public"
def show
@considering = current_page_from @collection.cards.considering.latest, per_page: CardsController::PAGE_SIZE
@on_deck = current_page_from @collection.cards.on_deck.latest, per_page: CardsController::PAGE_SIZE
@doing = current_page_from @collection.cards.doing.latest, per_page: CardsController::PAGE_SIZE
@closed = current_page_from @collection.cards.closed.recently_closed_first, per_page: CardsController::PAGE_SIZE
# @considering = current_page_from @collection.cards.considering.latest, per_page: CardsController::PAGE_SIZE
# @on_deck = current_page_from @collection.cards.on_deck.latest, per_page: CardsController::PAGE_SIZE
# @doing = current_page_from @collection.cards.doing.latest, per_page: CardsController::PAGE_SIZE
# @closed = current_page_from @collection.cards.closed.recently_closed_first, per_page: CardsController::PAGE_SIZE
# To enable caching at intermediate proxies during traffic spikes
expires_in 5.seconds, public: true
+4 -4
View File
@@ -35,8 +35,8 @@ module CardsHelper
classes = [
options.delete(:class),
("golden-effect" if card.golden?),
("card--considering" if card.considering?),
("card--doing" if card.doing?),
("card--postponed" if card.postponed?),
("card--active" if card.active?),
("card--drafted" if card.drafted?)
].compact.join(" ")
@@ -71,8 +71,8 @@ module CardsHelper
end
def cards_expander(title, count)
tag.header class: "cards__expander", data: { action: "click->collapsible-columns#toggle" }, style: "--card-count: #{[ count, 20 ].min}", aria: { role: "button" } do
concat(tag.span count > 99 ? "99+" : count, class: "cards__expander-count")
tag.header class: "cards__expander", data: { action: "click->collapsible-columns#toggle" }, style: "--card-count: #{[count, 20].min}", aria: { role: "button" } do
concat(tag.span count > 99 ? "99+" : count, class: "cards__expander-count", "data-drag-and-drop-counter": true)
concat(tag.h2 title, class: "cards__expander-title")
concat(tag.div class: "cards__expander-menu position-relative", data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" } do
concat(tag.button class: "btn btn--circle txt-x-small borderless", data: { action: "click->dialog#open:stop" } do
+20
View File
@@ -0,0 +1,20 @@
module ColumnsHelper
def button_to_set_column(card, column)
button_to \
tag.span(column.name, class: "overflow-ellipsis"),
card_triage_path(card, column_id: column),
method: :post,
class: [ "btn justify-start workflow-stage txt-uppercase", { "workflow-stage--current": column == card.column } ],
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" }
end
def column_frame_tag(id, src: nil, data: {}, **options, &block)
data = data.reverse_merge \
"drag-and-drop-refresh": true,
controller: "frame",
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
turbo_frame_tag(id, src: src, data: data, **options, &block)
end
end
+1 -9
View File
@@ -7,7 +7,7 @@ module EntropyHelper
{
daysBeforeReminder: card.entropy.days_before_reminder,
closesAt: card.entropy.auto_clean_at.iso8601,
action: card_entropy_action(card)
action: "Postpones"
}
end
@@ -20,12 +20,4 @@ module EntropyHelper
}
end
end
def card_entropy_action(card)
if card.stagnated?
"Falls Back"
elsif card.considering?
"Closes"
end
end
end
-1
View File
@@ -6,4 +6,3 @@ import "controllers"
import "lexxy"
import "@rails/actiontext"
window.fetch = Turbo.fetch // TODO: We need to make @rails/request.js use Turbo's fetch when it's present.
@@ -19,4 +19,10 @@ export default class extends Controller {
clickedColumn.classList.remove(this.collapsedClass)
}
}
preventToggle(event) {
if (event.detail.attributeName === "class") {
event.preventDefault()
}
}
}
@@ -4,7 +4,6 @@ import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "item", "container" ]
static values = { url: String }
static classes = [ "draggedItem", "hoverContainer" ]
// Actions
@@ -38,8 +37,10 @@ export default class extends Controller {
if (!container || container === this.sourceContainer) { return }
this.wasDropped = true
this.#decreaseCounter(this.sourceContainer)
const sourceContainer = this.sourceContainer
await this.#submitDropRequest(this.dragItem, container)
this.#reloadSourceFrame(sourceContainer);
}
dragEnd() {
@@ -67,19 +68,30 @@ export default class extends Controller {
this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass))
}
// Private
async #submitDropRequest(item, container) {
const body = new FormData()
const id = item.dataset.id
const containerTarget = container.dataset.dropTarget
const stageId = container.dataset.stageId
const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id)
body.append("dropped_item_id", id)
body.append("drop_target", containerTarget)
if (stageId) {
body.append("stage_id", stageId)
return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
}
#reloadSourceFrame(sourceContainer) {
const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]")
if (frame) frame.reload()
}
#decreaseCounter(sourceContainer) {
const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]")
if (counterElement) {
const currentValue = counterElement.textContent.trim()
if (!/^\d+$/.test(currentValue)) return
const count = parseInt(currentValue)
if (count > 0) {
counterElement.textContent = count - 1
}
}
return post(this.urlValue, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
}
}
@@ -5,7 +5,7 @@ import { post } from "@rails/request.js"
export default class extends Controller {
static classes = ["filtersSet"]
static targets = ["field", "form"]
static values = { refreshUrl: String }
static values = { refreshUrl: String, noFilteringUrl: String }
initialize() {
this.debouncedToggle = debounce(this.#toggle.bind(this), 50)
@@ -15,11 +15,26 @@ export default class extends Controller {
this.#toggle()
}
change() {
change(event) {
this.#toggle()
this.#refreshSaveToggleButton()
}
resetIfBlankAndNoFiltering(event) {
const { target } = event
if (!target.value.trim() && !this.#hasFiltersSet) {
this.#showNoFilteringUrl();
event.stopImmediatePropagation()
}
}
resetIfNoFiltering(event) {
if (!this.#hasFiltersSet) {
this.#showNoFilteringUrl()
event.stopImmediatePropagation()
}
}
async fieldTargetConnected(field) {
this.debouncedToggle()
}
@@ -65,4 +80,8 @@ export default class extends Controller {
return formData
}
#showNoFilteringUrl() {
Turbo.visit(this.noFilteringUrlValue, { frame: "cards_container", action: "advance" })
}
}
@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
morphRender({ detail }) {
detail.render = function (currentElement, newElement) {
Turbo.morphChildren(currentElement, newElement)
}
}
morphReload(event) {
const newElement = event.detail.newElement
if (newElement && newElement.tagName === "TURBO-FRAME") {
event.preventDefault()
this.element.reload()
}
}
}
-7
View File
@@ -1,7 +0,0 @@
class Card::AutoCloseAllDueJob < ApplicationJob
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_close_all_due
end
end
end
@@ -0,0 +1,7 @@
class Card::AutoPostponeAllDueJob < ApplicationJob
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_postpone_all_due
end
end
end
+1 -2
View File
@@ -12,7 +12,6 @@ module Account::Entropic
def set_default_entropy_configuration
self.default_entropy_configuration ||= build_default_entropy_configuration \
auto_close_period: DEFAULT_ENTROPY_PERIOD,
auto_reconsider_period: DEFAULT_ENTROPY_PERIOD
auto_postpone_period: DEFAULT_ENTROPY_PERIOD
end
end
+4 -4
View File
@@ -1,7 +1,7 @@
class Card < ApplicationRecord
include Assignable, Attachments, Cacheable, Closeable, Colored, Engageable, Entropic, Eventable,
Golden, Mentions, Multistep, Pinnable, Promptable, Readable, Searchable,
Staged, Stallable, Statuses, Taggable, Watchable
include Assignable, Attachments, Cacheable, Closeable, Colored, Entropic, Eventable,
Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable,
Staged, Stallable, Statuses, Taggable, Triageable, Watchable
belongs_to :collection, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
@@ -21,7 +21,7 @@ class Card < ApplicationRecord
scope :indexed_by, ->(index) do
case index
when "stalled" then stalled
when "closing_soon" then closing_soon
when "postponing_soon" then postponing_soon
when "falling_back_soon" then falling_back_soon
when "closed" then closed.recently_closed_first
when "golden" then golden
+3 -3
View File
@@ -14,11 +14,11 @@ module Card::Colored
DEFAULT_COLOR = "var(--color-card-default)"
def color
color_from_stage || DEFAULT_COLOR
color_from_column || DEFAULT_COLOR
end
private
def color_from_stage
stage&.color&.presence if doing?
def color_from_column
column&.color&.presence
end
end
-75
View File
@@ -1,75 +0,0 @@
module Card::Engageable
extend ActiveSupport::Concern
included do
has_one :engagement, dependent: :destroy, class_name: "Card::Engagement"
scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) }
scope :on_deck, -> { published.open.joins(:engagement).where(engagement: { status: "on_deck" }) }
scope :doing, -> { published.open.joins(:engagement).where(engagement: { status: "doing" }) }
scope :by_engagement_status, ->(status) do
case status.to_s
when "considering" then considering.with_golden_first
when "on_deck" then on_deck.with_golden_first
when "doing" then doing.with_golden_first
end
end
end
def doing?
open? && published? && engagement&.status == "doing"
end
def on_deck?
open? && published? && engagement&.status == "on_deck"
end
def considering?
open? && published? && engagement.blank?
end
def engagement_status
if doing?
"doing"
elsif on_deck?
"on_deck"
elsif considering?
"considering"
end
end
def engage
unless doing?
reengage(status: "doing")
end
end
def move_to_on_deck
unless on_deck?
reengage(status: "on_deck")
end
end
def reconsider
transaction do
reopen
engagement&.destroy
activity_spike&.destroy
update!(stage: nil)
touch_last_active_at
end
end
private
def reengage(status:)
transaction do
reopen
engagement&.destroy
create_engagement!(status:)
if status == "doing" && stage.blank?
update!(stage: collection.initial_workflow_stage)
end
end
end
end
+12 -30
View File
@@ -2,43 +2,29 @@ module Card::Entropic
extend ActiveSupport::Concern
included do
scope :entropic_by, ->(period_name) do
left_outer_joins(collection: :entropy_configuration)
.where("last_active_at <= DATETIME('now', '-' || COALESCE(entropy_configurations.#{period_name}, ?) || ' seconds')",
Entropy::Configuration.default.public_send(period_name))
end
scope :stagnated, -> { doing.or(on_deck).entropic_by(:auto_reconsider_period) }
scope :due_to_be_closed, -> { considering.entropic_by(:auto_close_period) }
scope :closing_soon, -> do
considering
scope :due_to_be_postponed, -> do
active
.left_outer_joins(collection: :entropy_configuration)
.where("last_active_at > DATETIME('now', '-' || COALESCE(entropy_configurations.auto_close_period, ?) || ' seconds')", Entropy::Configuration.default.auto_close_period)
.where("last_active_at <= DATETIME('now', '-' || CAST(COALESCE(entropy_configurations.auto_close_period, ?) * 0.75 AS INTEGER) || ' seconds')", Entropy::Configuration.default.auto_close_period)
.where("last_active_at <= DATETIME('now', '-' || COALESCE(entropy_configurations.auto_postpone_period, ?) || ' seconds')",
Entropy::Configuration.default.auto_postpone_period)
end
scope :falling_back_soon, -> do
doing.or(on_deck)
scope :postponing_soon, -> do
active
.left_outer_joins(collection: :entropy_configuration)
.where("last_active_at > DATETIME('now', '-' || COALESCE(entropy_configurations.auto_reconsider_period, ?) || ' seconds')", Entropy::Configuration.default.auto_reconsider_period)
.where("last_active_at <= DATETIME('now', '-' || CAST(COALESCE(entropy_configurations.auto_reconsider_period, ?) * 0.75 AS INTEGER) || ' seconds')", Entropy::Configuration.default.auto_reconsider_period)
.where("last_active_at > DATETIME('now', '-' || COALESCE(entropy_configurations.auto_postpone_period, ?) || ' seconds')", Entropy::Configuration.default.auto_postpone_period)
.where("last_active_at <= DATETIME('now', '-' || CAST(COALESCE(entropy_configurations.auto_postpone_period, ?) * 0.75 AS INTEGER) || ' seconds')", Entropy::Configuration.default.auto_postpone_period)
end
delegate :auto_close_period, :auto_reconsider_period, to: :collection
delegate :auto_postpone_period, to: :collection
end
class_methods do
def auto_close_all_due
due_to_be_closed.find_each do |card|
card.close(user: User.system, reason: "Closed")
def auto_postpone_all_due
due_to_be_postponed.find_each do |card|
card.postpone
end
end
def auto_reconsider_all_stagnated
stagnated.find_each(&:reconsider)
end
end
def entropy
@@ -48,8 +34,4 @@ module Card::Entropic
def entropic?
entropy.present?
end
def stagnated?
card.doing? || card.on_deck?
end
end
+1 -5
View File
@@ -5,11 +5,7 @@ class Card::Entropy
def for(card)
return unless card.last_active_at
if card.considering?
new(card, card.auto_close_period)
elsif card.stagnated?
new(card, card.auto_reconsider_period)
end
new(card, card.auto_postpone_period)
end
end
+3
View File
@@ -0,0 +1,3 @@
class Card::NotNow < ApplicationRecord
belongs_to :card, class_name: "::Card", touch: true
end
+35
View File
@@ -0,0 +1,35 @@
module Card::Postponable
extend ActiveSupport::Concern
included do
has_one :not_now, dependent: :destroy, class_name: "Card::NotNow"
scope :postponed, -> { open.published.joins(:not_now) }
scope :active, -> { open.published.where.missing(:not_now) }
end
def postponed?
open? && published? && not_now.present?
end
def active?
open? && published? && !postponed?
end
def postpone
transaction do
send_back_to_triage
reopen
activity_spike&.destroy
create_not_now! unless postponed?
end
end
def resume
transaction do
reopen
activity_spike&.destroy
not_now&.destroy
end
end
end
+1 -1
View File
@@ -7,7 +7,7 @@ module Card::Stallable
has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy
scope :with_activity_spikes, -> { joins(:activity_spike) }
scope :stalled, -> { open.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
before_update :remember_to_detect_activity_spikes
after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?
+35
View File
@@ -0,0 +1,35 @@
module Card::Triageable
extend ActiveSupport::Concern
included do
belongs_to :column, optional: true
scope :awaiting_triage, -> { active.where.missing(:column) }
scope :triaged, -> { active.joins(:column) }
end
def triaged?
active? && column.present?
end
def awaiting_triage?
active? && !triaged?
end
def triage_into(column)
raise "The column must belong to the card collection" unless collection == column.collection
transaction do
resume
activity_spike&.destroy
update! column: column
end
end
def send_back_to_triage
transaction do
resume
update!(column: nil)
end
end
end
-45
View File
@@ -1,45 +0,0 @@
class Cards::Columns
attr_reader :user_filtering, :page_size
delegate :filter, to: :user_filtering
def initialize(user_filtering:, page_size:)
@user_filtering = user_filtering
@page_size = page_size
end
def considering
@considering ||= build_column_for "considering"
end
def on_deck
@on_deck ||= build_column_for "on_deck"
end
def doing
@doing ||= build_column_for "doing"
end
def closed
@closed ||= if filter.indexed_by.stalled?
build_column(filter) { |cards| cards.recently_closed_first }
else
build_column(filter.with(indexed_by: "closed")) { |cards| cards.recently_closed_first }
end
end
def cache_key
ActiveSupport::Cache.expand_cache_key([ considering, on_deck, doing, closed, Workflow.all, user_filtering ])
end
private
def build_column_for(engagement_status)
build_column(filter.with(engagement_status: engagement_status))
end
def build_column(filter, &block)
cards = block ? yield(filter.cards) : filter.cards
Column.new(page: GearedPagination::Recordset.new(cards, per_page: page_size).page(1), filter: filter, user_filtering: user_filtering)
end
end
-17
View File
@@ -1,17 +0,0 @@
class Cards::Columns::Column
attr_reader :page, :filter, :user_filtering
def initialize(page:, filter:, user_filtering:)
@page = page
@filter = filter
@user_filtering = user_filtering
end
def cards
page.records
end
def cache_key
ActiveSupport::Cache.expand_cache_key([ cards ])
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Collection < ApplicationRecord
include AutoClosing, Accessible, Broadcastable, Entropic, Filterable, Publishable, Workflowing
include AutoClosing, Accessible, Broadcastable, Entropic, Filterable, Publishable, Triageable, Workflowing
belongs_to :creator, class_name: "User", default: -> { Current.user }
+4 -4
View File
@@ -2,13 +2,13 @@ module Collection::AutoClosing
extend ActiveSupport::Concern
included do
before_create :set_default_auto_close_period
before_create :set_default_auto_postpone_period
end
private
DEFAULT_AUTO_CLOSE_PERIOD = 30.days
DEFAULT_auto_postpone_period = 30.days
def set_default_auto_close_period
self.auto_close_period ||= DEFAULT_AUTO_CLOSE_PERIOD unless attribute_present?(:auto_close_period)
def set_default_auto_postpone_period
self.auto_postpone_period ||= DEFAULT_auto_postpone_period unless attribute_present?(:auto_postpone_period)
end
end
+3 -8
View File
@@ -2,20 +2,15 @@ module Collection::Entropic
extend ActiveSupport::Concern
included do
delegate :auto_close_period, :auto_reconsider_period, to: :entropy_configuration
delegate :auto_postpone_period, to: :entropy_configuration
end
def entropy_configuration
super || Entropy::Configuration.default
end
def auto_close_period=(new_value)
def auto_postpone_period=(new_value)
entropy_configuration ||= association(:entropy_configuration).reader || self.build_entropy_configuration
entropy_configuration.update auto_close_period: new_value
end
def auto_reconsider_period=(new_value)
entropy_configuration ||= association(:entropy_configuration).reader || self.build_entropy_configuration
entropy_configuration.update auto_reconsider_period: new_value
entropy_configuration.update auto_postpone_period: new_value
end
end
+7
View File
@@ -0,0 +1,7 @@
module Collection::Triageable
extend ActiveSupport::Concern
included do
has_many :columns, dependent: :destroy
end
end
+7
View File
@@ -0,0 +1,7 @@
class Column < ApplicationRecord
belongs_to :collection
has_many :cards, dependent: :nullify
validates :name, presence: true
validates :color, presence: true
end
+1 -2
View File
@@ -23,12 +23,11 @@ class Filter < ApplicationRecord
result = result.sorted_by(sorted_by)
result = result.where(id: card_ids) if card_ids.present?
result = result.open unless include_closed_cards?
result = result.by_engagement_status(engagement_status) if engagement_status.present?
result = result.unassigned if assignment_status.unassigned?
result = result.assigned_to(assignees.ids) if assignees.present?
result = result.where(creator_id: creators.ids) if creators.present?
result = result.where(collection: collections.ids) if collections.present?
result = result.in_stage(stages.ids) if stages.present? && engagement_status&.doing?
result = result.in_stage(stages.ids) if stages.present?
result = result.tagged_with(tags.ids) if tags.present?
result = result.where("cards.created_at": creation_window) if creation_window
result = result.closed_at_window(closure_window) if closure_window
+2 -6
View File
@@ -1,7 +1,7 @@
module Filter::Fields
extend ActiveSupport::Concern
INDEXES = %w[ all stalled closing_soon falling_back_soon golden draft ]
INDEXES = %w[ all stalled postponing_soon falling_back_soon golden draft ]
SORTED_BY = %w[ newest oldest latest ]
delegate :default_value?, to: :class
@@ -18,7 +18,7 @@ module Filter::Fields
included do
store_accessor :fields, :assignment_status, :indexed_by, :sorted_by, :terms,
:engagement_status, :card_ids, :creation, :closure
:card_ids, :creation, :closure
def assignment_status
super.to_s.inquiry
@@ -32,10 +32,6 @@ module Filter::Fields
(super || default_sorted_by).inquiry
end
def engagement_status
super&.inquiry
end
def creation_window
TimeWindowParser.parse(creation)
end
-2
View File
@@ -5,7 +5,6 @@ module Filter::Params
:assignment_status,
:indexed_by,
:sorted_by,
:engagement_status,
:creation,
:closure,
card_ids: [],
@@ -54,7 +53,6 @@ module Filter::Params
@as_params ||= {}.tap do |params|
params[:indexed_by] = indexed_by
params[:sorted_by] = sorted_by
params[:engagement_status] = engagement_status
params[:creation] = creation
params[:closure] = closure
params[:assignment_status] = assignment_status
+1 -1
View File
@@ -20,7 +20,7 @@
<%= render "cards/container/title", card: card %>
<%= render "cards/display/perma/steps", card: card %>
</div>
<%= render "cards/stagings/stages", card: card if card.open? %>
<%= render "cards/triage/columns", card: card if card.open? %>
</div>
<footer class="card__footer full-width flex align-start gap">
+3 -3
View File
@@ -8,9 +8,9 @@
<%= render "cards/display/preview/steps", card: card %>
<%= icon_tag "attachment", class: "card__attachments-indicator translucent" if card.has_attachments? %>
<% if card.staged? && card.doing? %>
<% if card.triaged? %>
<span class="btn justify-start workflow-stage workflow-stage--current txt-uppercase min-width max-width">
<span class="overflow-ellipsis "><%= card.stage.name %></span>
<span class="overflow-ellipsis "><%= card.column.name %></span>
</span>
<% end %>
</header>
@@ -22,7 +22,7 @@
</h3>
</div>
<%= render "cards/display/preview/stages", card: card if card.doing? %>
<%= render "cards/display/preview/stages", card: card %>
</div>
</div>
@@ -1,4 +1,4 @@
<%= tag.div hidden: true, class: "bubble bubble--#{card.engagement_status}",
<%= tag.div hidden: true, class: "bubble",
data: {
controller: "bubble",
action: "turbo:morph-element->bubble#update",
+6 -21
View File
@@ -12,12 +12,6 @@
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<div class="header__actions header__actions--start">
<% if collection = @filter.single_collection %>
<%= render "cards/webhooks", collection: collection if Current.user.admin? %>
<% end %>
</div>
<h1 class="header__title divider divider--fade full-width">
<span class="overflow-ellipsis"><%= @user_filtering.selected_collections_label %></span>
</h1>
@@ -29,19 +23,10 @@
</div>
<% end %>
<%= render "filters/settings", user_filtering: @user_filtering %>
<%= render "filters/settings", user_filtering: @user_filtering, no_filtering_url: cards_path %>
<%= tag.div data: {
controller: "drag-and-drop drag-and-strum",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
drag_and_drop_url_value: cards_drops_path(**@filter.as_params),
action: "
dragstart->drag-and-drop#dragStart
dragover->drag-and-drop#dragOver
dragenter->drag-and-strum#dragEnter
drop->drag-and-drop#drop
dragend->drag-and-drop#dragEnd" } do %>
<%= render "cards/index/columns", columns: @columns %>
<% end %>
<section class="cards">
<%= turbo_frame_tag :cards_container do %>
<%= render partial: "cards/display/preview", collection: @page.records, as: :card, locals: { draggable: true }, cached: ->(card) { cacheable_preview_parts_for(card) } %>
<% end %>
</section>
+1 -1
View File
@@ -1,5 +1,5 @@
<% cache columns do %>
<%= turbo_frame_tag :card_columns do %>
<%= turbo_frame_tag :cards_container do %>
<div class="card-columns" data-controller="collapsible-columns" data-collapsible-columns-collapsed-class="is-collapsed">
<div class="card-columns__left">
<%= render "cards/index/engagement/on_deck", column: columns.on_deck %>
+1 -1
View File
@@ -12,7 +12,7 @@
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<%= link_to cards_path(collection_ids: [ @card.collection ]), class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: card-collection-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<%= link_to collection_path(@card.collection), class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: card-collection-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong class="font-black"><%= @card.collection.name %></strong>
+14
View File
@@ -0,0 +1,14 @@
<div id="<%= dom_id(card, :stages) %>" class="card__stages">
<%= button_to "Not now", card_not_now_path(card),
class: [ "btn justify-start workflow-stage txt-uppercase", { "workflow-stage--current": card.postponed? } ],
form_class: "flex align-center gap-half" %>
<%= button_to "The Stream", card_triage_path(card), method: :delete,
class: [ "btn justify-start workflow-stage txt-uppercase", { "workflow-stage--current": card.awaiting_triage? } ],
form_class: "flex align-center gap-half" %>
<% card.collection.columns.each do |column| %>
<%= button_to_set_column card, column %>
<% end %>
<%= button_to "Closed", card_closure_path(card, reason: "Completed"),
class: [ "btn justify-start workflow-stage txt-uppercase", { "workflow-stage--current": card.closed? } ],
form_class: "flex align-center gap-half" %>
</div>
@@ -0,0 +1 @@
<%= render partial: "cards/display/preview", collection: cards, as: :card, locals: { draggable: draggable }, cached: ->(card) { card.cache_invalidation_parts.for_preview } %>
@@ -0,0 +1,14 @@
<%= turbo_frame_tag :closed_column do %>
<% if @page.used? %>
<%= render "collections/columns/list", cards: @page.records, draggable: true %>
<% unless @page.last? %>
<div class="full-width">
</div>
<% end %>
<% else %>
<div class="card blank-slate blank-slate--card">
<p class="txt-align-center txt-normal">Drag cards here</p>
</div>
<% end %>
<% end %>
@@ -0,0 +1,15 @@
<%= turbo_frame_tag :not_now_column do %>
<% if @page.used? %>
<%= render "collections/columns/list", cards: @page.records, draggable: true %>
<% unless @page.last? %>
<div class="full-width">
</div>
<% end %>
<% else %>
<div class="card blank-slate blank-slate--card">
<p class="txt-align-center txt-normal">Drag cards here</p>
</div>
<% end %>
<% end %>
@@ -0,0 +1,14 @@
<%= turbo_frame_tag dom_id(@column, :cards) do %>
<% if @page.used? %>
<%= render "collections/columns/list", cards: @page.records, draggable: true %>
<% unless @page.last? %>
<div class="full-width">
</div>
<% end %>
<% else %>
<div class="card blank-slate blank-slate--card">
<p class="txt-align-center txt-normal">Drag cards here</p>
</div>
<% end %>
<% end %>
@@ -0,0 +1,14 @@
<%= turbo_frame_tag :stream_column do %>
<% if @page.used? %>
<%= render "collections/columns/list", cards: @page.records, draggable: true %>
<% unless @page.last? %>
<div class="full-width">
</div>
<% end %>
<% else %>
<div class="card blank-slate blank-slate--card">
<p class="txt-align-center txt-normal">Drag cards here</p>
</div>
<% end %>
<% end %>
+39
View File
@@ -0,0 +1,39 @@
<% @page_title = @collection.name %>
<% turbo_exempts_page_from_cache %>
<% content_for :head do %>
<%= tag.meta property: "og:title", content: @page_title %>
<%= tag.meta property: "og:description", content: Account.sole.name %>
<%= tag.meta property: "og:url", content: collection_url(@collection) %>
<% end %>
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<div class="header__actions header__actions--start">
<%= render "cards/webhooks", collection: @collection if Current.user.admin? %>
</div>
<h1 class="header__title divider divider--fade full-width">
<span class="overflow-ellipsis"><%= @collection.name %></span>
</h1>
<div class="header__actions header__actions--end">
<%= render "cards/collection_settings", collection: @collection %>
</div>
<% end %>
<%= render "filters/settings", user_filtering: @user_filtering, no_filtering_url: collection_path(@collection) %>
<%= tag.div data: {
controller: "drag-and-drop",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
action: "
dragstart->drag-and-drop#dragStart
dragover->drag-and-drop#dragOver
drop->drag-and-drop#drop
dragend->drag-and-drop#dragEnd" } do %>
<%= render "collections/show/columns", page: @page, collection: @collection %>
<% end %>
@@ -0,0 +1,11 @@
<section id="closed-cards" class="cards cards--on-deck is-collapsed" style="--card-color: var(--color-card-complete);"
data-drag-and-drop-target="container"
data-collapsible-columns-target="column"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_closure_path("__id__") %>"
>
<%= cards_expander "Done", collection.cards.closed.count %>
<%= column_frame_tag :closed_column, src: collection_columns_closed_path(collection) %>
</section>
@@ -0,0 +1,11 @@
<section id="<%= dom_id(column) %>"
class="cards cards--doing is-collapsed" style="--card-color: <%= column.color %>;"
data-drag-and-drop-target="container"
data-collapsible-columns-target="column"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_column_path("__id__", column_id: column.id) %>"
>
<%= cards_expander column.name, column.cards.active.count %>
<%= column_frame_tag dom_id(column, :cards), src: collection_column_path(column.collection, column) %>
</section>
@@ -0,0 +1,20 @@
<%= turbo_frame_tag :cards_container do %>
<div class="card-columns" data-controller="collapsible-columns" data-collapsible-columns-collapsed-class="is-collapsed">
<div class="card-columns__left">
<%= render "collections/show/not_now", collection: collection %>
</div>
<%= render "collections/show/stream", collection: collection, page: page %>
<div class="card-columns__right">
<%= render partial: "collections/show/column", collection: collection.columns, cached: true %>
<%= render "collections/show/closed", collection: collection %>
<button class="btn btn--circle txt-x-small borderless">
<%= icon_tag "menu-dots-horizontal" %>
<span class="for-screen-reader">Add a new column, etc.</span>
</button>
</div>
</div>
<% end %>
@@ -0,0 +1,11 @@
<section id="not-now" class="cards cards--on-deck is-collapsed" style="--card-color: var(--color-card-complete);"
data-collapsible-columns-target="column"
data-drag-and-drop-target="container"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_not_now_path("__id__") %>"
>
<%= cards_expander "Not Now", collection.cards.postponed.count %>
<%= column_frame_tag :not_now_column, src: collection_columns_not_now_path(collection) %>
</section>
@@ -0,0 +1,19 @@
<section id="the-stream" class="cards cards--considering"
data-drag-and-drop-target="container"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_stream_path("__id__") %>">
<div class="cards__decoration"></div>
<%= render "columns/show/add_card_button", collection: collection %>
<% if page.used? %>
<%= render "collections/columns/list", cards: page.records, draggable: true %>
<% unless page.last? %>
<div class="full-width">
</div>
<% end %>
<% end %>
<div class="cards__decoration cards__decoration--end"></div>
</section>
@@ -0,0 +1,13 @@
<div class="card card--new flex flex-column gap-half">
<%= button_to collection_cards_path(collection), method: :post, class: "btn btn--link", form: { data: { turbo_frame: "_top" } } do %>
<%= icon_tag "add" %>
<span>Add a card</span>
<% end %>
<hr class="separator--horizontal full-width" aria-hidden="true">
<footer class="flex flex-column align-center gap-half">
<strong class="txt-uppercase">Watching for new cards</strong>
<%= access_involvement_advance_button(collection, Current.user, show_watchers: true) %>
</footer>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@
<%= form_with model: model, url: url, data: { controller: "form" } do |form| %>
<%= render "entropy/knob",
form: form,
name: :auto_close_period,
name: :auto_postpone_period,
knob_options: entropy_auto_close_options,
label: "Days until auto-close" %>
<% end %>
+9 -4
View File
@@ -1,14 +1,19 @@
<% cache user_filtering do %>
<%= tag.aside id: "filter-settings",
class: class_names("filters flex align-center gap-half justify-center center margin-block-end", { "filters--expanded": user_filtering.expanded? }),
data: {
controller: "toggle-class filter-settings",
toggle_class_toggle_class: "filters--expanded",
filter_settings_filters_set_class: "filters--has-filters-set",
filter_settings_no_filtering_url_value: no_filtering_url,
filter_settings_refresh_url_value: settings_refresh_path } do %>
<%= form_with url: cards_path, method: :get, class: "", data: { controller: "form", turbo_frame: "card_columns", filter_settings_target: "form", turbo_action: "advance" } do |form| %>
<%= hidden_field_tag :expand_all, true, disabled: !user_filtering.expanded?, data: { toggle_enable_target: "element" } %>
<%= form_with url: cards_path, method: :get, class: "", data: {
controller: "form",
turbo_frame: "cards_container",
filter_settings_target: "form",
action: "turbo:submit-end->filter-settings#resetIfNoFiltering",
turbo_action: "advance" } do |form| %>
<%= hidden_field_tag :expand_all, true, disabled: !user_filtering.expanded?, data: { toggle_enable_target: "element"} %>
<% user_filtering.filter.collections.each do |collection| %>
<%= filter_hidden_field_tag "collection_ids[]", collection.id %>
<% end %>
@@ -20,7 +25,7 @@
<%= render "filters/settings/toggle", user_filtering: user_filtering %>
</div>
<% end %>
<%= render "filters/settings/manage", user_filtering: user_filtering %>
<%= render "filters/settings/manage", user_filtering: user_filtering, no_filtering_url: no_filtering_url %>
<% end %>
<% end %>
@@ -12,7 +12,7 @@
<%= icon_tag "check", size: 18, class: "checked" %>
</label>
<%= link_to cards_path(collection_ids: [ collection ]), class: "popup__btn btn" do %>
<%= link_to collection_path(collection), class: "popup__btn btn" do %>
<span class="overflow-ellipsis"><%= collection.name %></span>
<% end %>
</li>
@@ -27,7 +27,7 @@
<%= tag.li class: "popup__item", data: {
filter_target: "item", navigable_list_target: "item", multi_selection_combobox_target: "item", multi_selection_combobox_value: "unassigned", multi_selection_combobox_label: "No one" },
role: "checkbox", aria: { checked: filter.assignment_status.unassigned? } do %>
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#clear form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#clear filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow">No one</span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
@@ -37,7 +37,7 @@
<%= tag.li class: "popup__item", data: {
filter_target: "item", navigable_list_target: "item", multi_selection_combobox_target: "item", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },
role: "checkbox", aria: { checked: filter.assignees.include?(user) } do %>
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow"><%= user.name %></span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
+1 -1
View File
@@ -29,7 +29,7 @@
<%= tag.li class: "popup__item", data: {
filter_target: "item", navigable_list_target: "item", multi_selection_combobox_target: "item", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },
role: "checkbox", aria: { checked: filter.closers.include?(user) } do %>
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow"><%= user.name %></span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
@@ -29,7 +29,7 @@
<%= tag.li class: "popup__item", data: {
filter_target: "item", navigable_list_target: "item", multi_selection_combobox_target: "item", multi_selection_combobox_value: user.id, multi_selection_combobox_label: user.familiar_name },
role: "checkbox", aria: { checked: filter.creators.include?(user) } do %>
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow"><%= user.name %></span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
@@ -4,7 +4,6 @@
data: { controller: "dialog combobox",
action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside",
filter_show: user_filtering.show_indexed_by?,
turbo_action: "advance",
combobox_default_value_value: "all",
combobox_default_label_value: "Status",
combobox_with_default_class: "quick-filter--with-default" } do %>
@@ -23,7 +22,7 @@
<ul class="popup__list" data-filter-target="list" role="listbox">
<% Filter::INDEXES.each do |index| %>
<%= tag.li class: "popup__item", data: { navigable_list_target: "item", combobox_target: "item", combobox_value: index, combobox_label: index.humanize }, role: "checkbox", aria: { checked: filter.indexed_by == index } do %>
<button type="button" class="btn popup__btn" data-action="dialog#close combobox#change form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close combobox#change filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow"><%= index == "all" ? "Status" : index.humanize %></span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="filters__manage">
<%= render "filters/filter_toggle", filter: filter %>
<%= link_to cards_path(filter.as_params.except(:assignee_ids, :assignment_status, :card_ids, :creator_ids, :closer_ids, :stage_ids, :tag_ids, :terms, :indexed_by, :sorted_by, :creation, :closure)), class: "btn btn--remove txt-x-small" do %>
<%= link_to no_filtering_url, class: "btn btn--remove txt-x-small" do %>
<%= icon_tag "close" %>
<span class="for-screen-reader">Clear all</span>
<% end %>
@@ -5,7 +5,6 @@
controller: "dialog combobox",
action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside",
filter_show: user_filtering.show_sorted_by?,
turbo_action: "advance",
combobox_default_value_value: "latest",
combobox_with_default_class: "quick-filter--with-default" } do %>
<button type="button" class="btn input input--select flex-inline txt-x-small" data-action="click->dialog#toggle:stop">
@@ -25,7 +24,7 @@
<%= tag.li class: "popup__item", data: {
navigable_list_target: "item", combobox_target: "item", combobox_value: sort, combobox_label: sorted_by_label(sort) },
role: "checkbox", aria: { checked: filter.sorted_by == sort } do %>
<button type="button" class="btn popup__btn" data-action="dialog#close combobox#change form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close combobox#change filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow"><%= sorted_by_label(sort) %></span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
+1 -1
View File
@@ -28,7 +28,7 @@
<%= content_tag(:li, class: "popup__item", data: {
filter_target: "item", navigable_list_target: "item", multi_selection_combobox_target: "item", multi_selection_combobox_value: tag.id, multi_selection_combobox_label: tag.hashtag },
role: "checkbox", aria: { checked: filter.tags.include?(tag) }) do %>
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change form#submit filter-settings#change">
<button type="button" class="btn popup__btn" data-action="dialog#close multi-selection-combobox#change filter-settings#change form#submit">
<span class="overflow-ellipsis flex-item-grow"><%= tag.hashtag %></span>
<%= icon_tag "check", class: "checked flex-item-justify-end", "aria-hidden": true %>
</button>
+1 -1
View File
@@ -2,7 +2,7 @@
autofocus: false, autocomplete: :off, autocorrect: "off", data: {
"1p-ignore": "true",
filter_settings_target: "field",
action: "input->form#debouncedSubmit keydown.enter->form#submitToTopTarget" } %>
action: "input->filter-settings#resetIfBlankAndNoFiltering input->form#debouncedSubmit keydown.enter->form#submitToTopTarget" } %>
<% if filter.terms.present? %>
<% filter.terms.each do |term| %>
+5 -5
View File
@@ -10,11 +10,11 @@
autocomplete: "off",
aria: { activedescendant: "" },
data: {
"1p-ignore": "true",
filter_target: "input",
nav_section_expander_target: "input",
navigable_list_target: "input",
action: "input->filter#filter" } %>
"1p-ignore": "true",
filter_target: "input",
nav_section_expander_target: "input",
navigable_list_target: "input",
action: "input->filter#filter" } %>
<button class="fizzy-menu__close btn borderless txt-small" data-action="dialog#close">
<%= icon_tag "close" %>
<span class="for-screen-reader">Close menu</span>
+1 -1
View File
@@ -32,7 +32,7 @@
<div class="card__body justify-space-between">
<%= render "public/cards/show/title", card: @card %>
<%= render "cards/display/public_preview/stages", card: @card if @card.doing? %>
<%= render "cards/display/public_preview/stages", card: @card %>
</div>
<%= render "public/cards/show/steps", card: @card %>
+2 -2
View File
@@ -1,6 +1,6 @@
production: &production
auto_close_all_due:
class: Card::AutoCloseAllDueJob
auto_postpone_all_due:
class: Card::AutoPostponeAllDueJob
schedule: every hour
auto_reconsider_all_inactive:
class: Card::AutoReconsiderAllStagnatedJob
+23 -2
View File
@@ -17,6 +17,14 @@ Rails.application.routes.draw do
resource :involvement
resource :publication
resource :entropy_configuration
namespace :columns do
resource :not_now
resource :stream
resource :closed
end
resources :columns
end
resources :cards, only: %i[ create ]
@@ -28,18 +36,31 @@ Rails.application.routes.draw do
end
end
namespace :columns do
resources :cards do
scope module: "cards" do
namespace :drops do
resource :not_now
resource :stream
resource :closure
resource :column
end
end
end
end
namespace :cards do
resources :previews
resources :drops
end
resources :cards, only: %i[ index show edit update destroy ] do
scope module: :cards do
resource :engagement
resource :goldness
resource :image
resource :pin
resource :closure
resource :not_now
resource :triage
resource :publish
resource :reading
resource :recover
@@ -0,0 +1,12 @@
class CreateColumnsAndAddColumnIdToCards < ActiveRecord::Migration[8.1]
def change
create_table :columns do |t|
t.string :name, null: false
t.string :color, null: false
t.references :collection, null: false, foreign_key: true
t.timestamps
end
add_reference :cards, :column, foreign_key: true, index: true
end
end
@@ -0,0 +1,8 @@
class CreateCardNotNows < ActiveRecord::Migration[8.1]
def change
create_table :card_not_nows do |t|
t.references :card, null: false, foreign_key: true, index: { unique: true }
t.timestamps
end
end
end
@@ -0,0 +1,17 @@
class AddAutoPostponePeriod < ActiveRecord::Migration[8.1]
def change
add_column :entropy_configurations, :auto_postpone_period, :bigint, default: 30.days.to_i, null: false
add_index :entropy_configurations, [:container_type, :container_id, :auto_postpone_period]
execute <<-SQL
UPDATE entropy_configurations
SET auto_postpone_period = auto_close_period
SQL
remove_index :entropy_configurations, name: "idx_on_container_type_container_id_auto_close_perio_74dc880875"
remove_index :entropy_configurations, name: "idx_on_container_type_container_id_auto_reconsider__583aaddbea"
remove_column :entropy_configurations, :auto_close_period, :bigint
remove_column :entropy_configurations, :auto_reconsider_period, :bigint
end
end
Generated
+24 -5
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_09_17_064006) do
ActiveRecord::Schema[8.1].define(version: 2025_09_24_124737) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -131,8 +131,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_17_064006) do
t.index ["card_id"], name: "index_card_goldnesses_on_card_id", unique: true
end
create_table "card_not_nows", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["card_id"], name: "index_card_not_nows_on_card_id", unique: true
end
create_table "cards", force: :cascade do |t|
t.integer "collection_id", null: false
t.integer "column_id"
t.datetime "created_at", null: false
t.integer "creator_id", null: false
t.date "due_on"
@@ -142,6 +150,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_17_064006) do
t.string "title"
t.datetime "updated_at", null: false
t.index ["collection_id"], name: "index_cards_on_collection_id"
t.index ["column_id"], name: "index_cards_on_column_id"
t.index ["last_active_at", "status"], name: "index_cards_on_last_active_at_and_status"
t.index ["stage_id"], name: "index_cards_on_stage_id"
end
@@ -197,6 +206,15 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_17_064006) do
t.index ["filter_id"], name: "index_collections_filters_on_filter_id"
end
create_table "columns", force: :cascade do |t|
t.integer "collection_id", null: false
t.string "color", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.datetime "updated_at", null: false
t.index ["collection_id"], name: "index_columns_on_collection_id"
end
create_table "comments", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
@@ -237,14 +255,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_17_064006) do
end
create_table "entropy_configurations", force: :cascade do |t|
t.bigint "auto_close_period", default: 2592000, null: false
t.bigint "auto_reconsider_period", default: 2592000, null: false
t.bigint "auto_postpone_period", default: 2592000, null: false
t.integer "container_id", null: false
t.string "container_type", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["container_type", "container_id", "auto_close_period"], name: "idx_on_container_type_container_id_auto_close_perio_74dc880875"
t.index ["container_type", "container_id", "auto_reconsider_period"], name: "idx_on_container_type_container_id_auto_reconsider__583aaddbea"
t.index ["container_type", "container_id", "auto_postpone_period"], name: "idx_on_container_type_container_id_auto_postpone_pe_47f82c5b73"
t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true
end
@@ -508,11 +524,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_17_064006) do
add_foreign_key "ai_quotas", "users"
add_foreign_key "card_activity_spikes", "cards"
add_foreign_key "card_goldnesses", "cards"
add_foreign_key "card_not_nows", "cards"
add_foreign_key "cards", "columns"
add_foreign_key "cards", "workflow_stages", column: "stage_id"
add_foreign_key "closures", "cards"
add_foreign_key "closures", "users"
add_foreign_key "collection_publications", "collections"
add_foreign_key "collections", "workflows"
add_foreign_key "columns", "collections"
add_foreign_key "comments", "cards"
add_foreign_key "conversation_messages", "conversations"
add_foreign_key "conversations", "users"
+89 -32
View File
@@ -433,8 +433,23 @@ columns:
- *5
- *6
- *9
card_not_nows:
- *23
- *5
- *6
- *9
cards:
- *24
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: column_id
cast_type: *3
sql_type_metadata: *4
'null': true
default:
default_function:
collation:
comment:
- *5
- &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -610,6 +625,22 @@ columns:
collections_filters:
- *24
- *20
columns:
- *24
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: color
cast_type: *7
sql_type_metadata: *8
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *10
- *9
comments:
- *23
- *5
@@ -741,17 +772,7 @@ columns:
entropy_configurations:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_close_period
cast_type: *11
sql_type_metadata: *12
'null': false
default: 2592000
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auto_reconsider_period
name: auto_postpone_period
cast_type: *11
sql_type_metadata: *12
'null': false
@@ -1488,6 +1509,7 @@ primary_keys:
card_activity_spikes: id
card_engagements: id
card_goldnesses: id
card_not_nows: id
cards: id
closers_filters:
closure_reasons: id
@@ -1495,6 +1517,7 @@ primary_keys:
collection_publications: id
collections: id
collections_filters:
columns: id
comments: id
conversation_messages: id
conversations: id
@@ -1542,6 +1565,7 @@ data_sources:
card_activity_spikes: true
card_engagements: true
card_goldnesses: true
card_not_nows: true
cards: true
closers_filters: true
closure_reasons: true
@@ -1549,6 +1573,7 @@ data_sources:
collection_publications: true
collections: true
collections_filters: true
columns: true
comments: true
conversation_messages: true
conversations: true
@@ -1972,6 +1997,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
card_not_nows:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: card_not_nows
name: index_card_not_nows_on_card_id
unique: true
columns:
- card_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
cards:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: cards
@@ -1989,6 +2031,22 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: cards
name: index_cards_on_column_id
unique: false
columns:
- column_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: cards
name: index_cards_on_last_active_at_and_status
@@ -2205,6 +2263,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: columns
name: index_columns_on_collection_id
unique: false
columns:
- collection_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
comments:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: comments
@@ -2292,30 +2367,12 @@ indexes:
entropy_configurations:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: entropy_configurations
name: idx_on_container_type_container_id_auto_close_perio_74dc880875
name: idx_on_container_type_container_id_auto_postpone_pe_47f82c5b73
unique: false
columns:
- container_type
- container_id
- auto_close_period
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: entropy_configurations
name: idx_on_container_type_container_id_auto_reconsider__583aaddbea
unique: false
columns:
- container_type
- container_id
- auto_reconsider_period
- auto_postpone_period
lengths: {}
orders: {}
opclasses: {}
@@ -3191,4 +3248,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250917064006
version: 20250924124737
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env ruby
require_relative "../../config/environment"
ApplicationRecord.with_each_tenant do |tenant|
puts "Processing tenant: #{tenant}"
Collection.find_each do |collection|
next unless collection.workflow.present?
# Map to track stage_id -> column
columns_by_stage = {}
# Create columns from workflow stages
collection.workflow.stages.find_each do |stage|
column = collection.columns.create!(
name: stage.name,
color: stage.color || Card::Colored::COLORS.first
)
columns_by_stage[stage] = column
puts "Created column '#{column.name}' for collection '#{collection.name}'"
end
# Associate cards with their corresponding columns based on stages
collection.cards.includes(:stage).find_each do |card|
next unless card.stage.present?
unless card.stage.workflow.collections.include?(collection)
puts "Corrupt data: the card with id #{card.id} has the stage #{card.stage.name} with id #{card.stage.id} that belongs to a workflow not asociated ot its collection"
next
end
stage = columns_by_stage[card.stage]
card.update!(column: stage)
puts "Associated card ##{card.id} with column '#{stage.name}'"
end
end
end
puts "Migration completed!"
@@ -6,10 +6,9 @@ class Account::EntropyConfigurationsControllerTest < ActionDispatch::Integration
end
test "update" do
put account_entropy_configuration_path, params: { entropy_configuration: { auto_close_period: 1.day, auto_reconsider_period: 2.days } }
put account_entropy_configuration_path, params: { entropy_configuration: { auto_postpone_period: 1.day } }
assert_equal 1.day, entropy_configurations("37s_account").auto_close_period
assert_equal 2.days, entropy_configurations("37s_account").auto_reconsider_period
assert_equal 1.day, entropy_configurations("37s_account").auto_postpone_period
assert_redirected_to account_settings_path
end
@@ -1,34 +0,0 @@
require "test_helper"
class Cards::DropsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
@card = cards(:logo)
end
test "drop to considering" do
assert_changes -> { @card.reload.considering? }, from: false, to: true do
post cards_drops_path, params: { dropped_item_id: @card.id, drop_target: "considering" }, as: :turbo_stream
assert_column_rerendered("considering")
end
end
test "drop to doing" do
@card = cards(:text)
assert_changes -> { @card.reload.doing? }, from: false, to: true do
post cards_drops_path, params: { dropped_item_id: @card.id, drop_target: "doing" }, as: :turbo_stream
assert_column_rerendered("doing")
end
end
test "invalid drop target" do
post cards_drops_path, params: { dropped_item_id: @card.id, drop_target: "invalid" }, as: :turbo_stream
assert_response :bad_request
end
private
def assert_column_rerendered(target)
assert_turbo_stream action: :replace, target: "#{target}-cards"
end
end
@@ -1,39 +0,0 @@
require "test_helper"
class Cards::EngagementsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
card = cards(:text)
assert_changes -> { card.reload.doing? }, from: false, to: true do
post card_engagement_path(card), params: { engagement: "doing" }
assert_card_container_rerendered(card)
end
end
test "create on_deck" do
card = cards(:text)
assert_changes -> { card.reload.on_deck? }, from: false, to: true do
post card_engagement_path(card), params: { engagement: "on_deck" }
assert_card_container_rerendered(card)
end
end
test "destroy" do
card = cards(:logo)
assert_changes -> { card.reload.doing? }, from: true, to: false do
delete card_engagement_path(card)
assert_card_container_rerendered(card)
end
end
private
def assert_card_container_rerendered(card)
assert_turbo_stream action: :replace, target: dom_id(card, :card_container)
end
end
@@ -7,10 +7,9 @@ class Collections::EntropyConfigurationsControllerTest < ActionDispatch::Integra
end
test "update" do
put collection_entropy_configuration_path(@collection), params: { collection: { auto_close_period: 1.day, auto_reconsider_period: 2.days } }
put collection_entropy_configuration_path(@collection), params: { collection: { auto_postpone_period: 1.day } }
assert_equal 1.day, @collection.entropy_configuration.reload.auto_close_period
assert_equal 2.days, @collection.entropy_configuration.reload.auto_reconsider_period
assert_equal 1.day, @collection.entropy_configuration.reload.auto_postpone_period
assert_turbo_stream action: :replace, target: dom_id(@collection, :entropy_configuration)
end
@@ -31,8 +31,7 @@ class CollectionsControllerTest < ActionDispatch::IntegrationTest
collection: {
name: "Writebook bugs",
all_access: false,
auto_close_period: 1.day,
auto_reconsider_period: 2.days
auto_postpone_period: 1.day
},
user_ids: users(:kevin, :jz).pluck(:id)
}
@@ -40,8 +39,7 @@ class CollectionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to edit_collection_path(collections(:writebook))
assert_equal "Writebook bugs", collections(:writebook).reload.name
assert_equal users(:kevin, :jz).sort, collections(:writebook).users.sort
assert_equal 1.day, entropy_configurations(:writebook_collection).auto_close_period
assert_equal 2.days, entropy_configurations(:writebook_collection).auto_reconsider_period
assert_equal 1.day, entropy_configurations(:writebook_collection).auto_postpone_period
assert_not collections(:writebook).all_access?
end
@@ -7,38 +7,4 @@ class Public::Collections::CardPreviewsControllerTest < ActionDispatch::Integrat
collections(:writebook).publish
end
test "render considering cards" do
assert cards(:text).considering?
assert_not cards(:logo).considering?
get public_collection_card_previews_path(collections(:writebook).publication.key, target: "considering-cards", format: :turbo_stream)
assert_select ".card", text: /#{cards(:text).title}/
assert_select ".card", text: cards(:logo).title, count: 0
end
test "render doing cards" do
assert cards(:logo).doing?
assert_not cards(:text).doing?
get public_collection_card_previews_path(collections(:writebook).publication.key, target: "doing-cards", format: :turbo_stream)
assert_select ".card", text: /#{cards(:logo).title}/
assert_select ".card", text: cards(:text).title, count: 0
end
test "render closed cards" do
assert cards(:shipping).closed?
assert_not cards(:text).doing?
get public_collection_card_previews_path(collections(:writebook).publication.key, target: "closed-cards", format: :turbo_stream)
assert_select ".card", text: /#{cards(:shipping).title}/
assert_select ".card", text: cards(:text).title, count: 0
end
test "bad response for unknown target" do
get public_collection_card_previews_path(collections(:writebook).publication.key, format: :turbo_stream)
assert_response :bad_request
end
end
+4 -4
View File
@@ -1,7 +1,7 @@
logo:
collection: writebook
creator: david
stage: qa_triage
column: writebook_triage
title: The logo isn't big enough
due_on: <%= 3.days.from_now %>
created_at: <%= 1.week.ago %>
@@ -11,7 +11,7 @@ logo:
layout:
collection: writebook
creator: david
stage: qa_triage
column: writebook_triage
title: Layout is broken
created_at: <%= 1.week.ago %>
status: published
@@ -20,16 +20,16 @@ layout:
text:
collection: writebook
creator: kevin
column: writebook_in_progress
title: The text is too small
created_at: <%= 1.week.ago %>
status: published
last_active_at: <%= 1.week.ago %>
stage: qa_in_progress
shipping:
collection: writebook
creator: kevin
stage: qa_triage
column: writebook_triage
title: We need to ship the app
created_at: <%= 1.week.ago %>
status: published
-1
View File
@@ -2,7 +2,6 @@ writebook:
name: Writebook
creator: david
all_access: true
workflow: qa
private:
name: Private collection
+20
View File
@@ -0,0 +1,20 @@
# Columns for writebook collection (which has qa workflow)
writebook_triage:
name: Triage
color: "#000000"
collection: writebook
writebook_in_progress:
name: In progress
color: "#3b3633"
collection: writebook
writebook_on_hold:
name: On Hold
color: "#67695e"
collection: writebook
writebook_review:
name: Review
color: "#eb7a32"
collection: writebook
+3 -6
View File
@@ -1,14 +1,11 @@
37s_account:
container: 37s (Account)
auto_reconsider_period: <%= 30.days.to_i %>
auto_close_period: <%= 30.days.to_i %>
auto_postpone_period: <%= 30.days.to_i %>
writebook_collection:
container: writebook (Collection)
auto_reconsider_period: <%= 90.days.to_i %>
auto_close_period: <%= 90.days.to_i %>
auto_postpone_period: <%= 90.days.to_i %>
private_collection:
container: private (Collection)
auto_reconsider_period: <%= 30.days.to_i %>
auto_close_period: <%= 30.days.to_i %>
auto_postpone_period: <%= 30.days.to_i %>
+4 -8
View File
@@ -1,16 +1,12 @@
require "test_helper"
class Card::ColoredTest < ActiveSupport::TestCase
test "use default color no stage" do
test "use default color if no column" do
cards(:logo).update! column: nil
assert_equal Card::DEFAULT_COLOR, cards(:logo).color
end
test "use default color not in doing stage" do
assert_equal Card::DEFAULT_COLOR, cards(:text).color
end
test "infer color from stage when in doing stage" do
cards(:text).engage
assert_equal cards(:text).stage.color, cards(:text).color
test "infer color from column" do
assert_equal cards(:layout).column.color, cards(:layout).color
end
end
-60
View File
@@ -1,60 +0,0 @@
require "test_helper"
class Card::EngageableTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "check the engagement status of a card" do
assert cards(:logo).doing?
assert_not cards(:text).doing?
assert_not cards(:logo).considering?
assert cards(:text).considering?
assert_not cards(:logo).on_deck?
assert_not cards(:text).on_deck?
assert_equal "doing", cards(:logo).engagement_status
assert_equal "considering", cards(:text).engagement_status
end
test "change the engagement" do
assert_changes -> { cards(:text).reload.doing? }, to: true do
cards(:text).engage
end
assert_changes -> { cards(:logo).reload.doing? }, to: false do
cards(:logo).reconsider
end
end
test "engaging with closed cards" do
cards(:text).close
assert_not cards(:text).considering?
assert_not cards(:text).doing?
assert_not cards(:text).on_deck?
cards(:text).engage
assert_not cards(:text).reload.closed?
assert cards(:text).doing?
cards(:text).close
cards(:text).reconsider
assert_not cards(:text).reload.closed?
assert cards(:text).considering?
end
test "scopes" do
assert_includes Card.doing, cards(:logo)
assert_not_includes Card.doing, cards(:text)
assert_includes Card.considering, cards(:text)
assert_not_includes Card.considering, cards(:logo)
cards(:text).move_to_on_deck
assert_includes Card.on_deck, cards(:text)
assert_not_includes Card.on_deck, cards(:logo)
end
end

Some files were not shown because too many files have changed in this diff Show More