From 3ada6f271a39c6538ae2b6862122e57a4d4ba3b9 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 15:41:17 +0100 Subject: [PATCH 01/11] Models for column positioning --- app/models/column.rb | 2 + app/models/concerns/column/positioned.rb | 35 +++++++++++ app/views/collections/show/_columns.html.erb | 2 +- .../20251029142418_add_position_to_columns.rb | 9 +++ db/schema.rb | 3 +- db/schema_cache.yml | 12 +++- test/models/column/positioned_test.rb | 62 +++++++++++++++++++ 7 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 app/models/concerns/column/positioned.rb create mode 100644 db/migrate/20251029142418_add_position_to_columns.rb create mode 100644 test/models/column/positioned_test.rb diff --git a/app/models/column.rb b/app/models/column.rb index 27bfa8a06..7585c39be 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -1,4 +1,6 @@ class Column < ApplicationRecord + include Positioned + belongs_to :collection, touch: true has_many :cards, dependent: :nullify diff --git a/app/models/concerns/column/positioned.rb b/app/models/concerns/column/positioned.rb new file mode 100644 index 000000000..a3864dab0 --- /dev/null +++ b/app/models/concerns/column/positioned.rb @@ -0,0 +1,35 @@ +module Column::Positioned + extend ActiveSupport::Concern + + included do + scope :sorted, -> { order(position: :asc) } + + before_create :set_position + end + + def move_left + if column = collection.columns.where("position < ?", position).sorted.last + swap_position_with(column) + end + end + + def move_right + if column = collection.columns.where("position > ?", position).sorted.first + swap_position_with(column) + end + end + + private + def set_position + max_position = collection.columns.maximum(:position) + self.position = max_position + 1 + end + + def swap_position_with(other_column) + transaction do + other_column.position, self.position = self.position, other_column.position + other_column.save! + save! + end + end +end diff --git a/app/views/collections/show/_columns.html.erb b/app/views/collections/show/_columns.html.erb index 585093dc6..8c12b164a 100644 --- a/app/views/collections/show/_columns.html.erb +++ b/app/views/collections/show/_columns.html.erb @@ -16,7 +16,7 @@ <%= render "collections/show/stream", collection: collection, page: page %>
- <%= render partial: "collections/show/column", collection: collection.columns, cached: true %> + <%= render partial: "collections/show/column", collection: collection.columns.sorted, cached: true %> <%= render "collections/show/closed", collection: collection %> <%= render "collections/show/menu/columns", collection: collection %> diff --git a/db/migrate/20251029142418_add_position_to_columns.rb b/db/migrate/20251029142418_add_position_to_columns.rb new file mode 100644 index 000000000..ba16d7831 --- /dev/null +++ b/db/migrate/20251029142418_add_position_to_columns.rb @@ -0,0 +1,9 @@ +class AddPositionToColumns < ActiveRecord::Migration[8.2] + def change + add_column :columns, :position, :integer, default: 0 + + execute "UPDATE columns SET position = 0" + + change_column_null :columns, :position, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 71c233fcb..cef4d628e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_10_29_104115) do +ActiveRecord::Schema[8.2].define(version: 2025_10_29_142418) do create_table "accesses", force: :cascade do |t| t.datetime "accessed_at" t.integer "collection_id", null: false @@ -191,6 +191,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_104115) do t.string "color", null: false t.datetime "created_at", null: false t.string "name", null: false + t.integer "position", default: 0, null: false t.datetime "updated_at", null: false t.index ["collection_id"], name: "index_columns_on_collection_id" end diff --git a/db/schema_cache.yml b/db/schema_cache.yml index f7da6da99..ad614480d 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -562,6 +562,16 @@ columns: - *5 - *6 - *10 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: position + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: 0 + default_function: + collation: + comment: - *9 comments: - *22 @@ -2818,4 +2828,4 @@ indexes: nulls_not_distinct: comment: valid: true -version: 20251029104115 +version: 20251029142418 diff --git a/test/models/column/positioned_test.rb b/test/models/column/positioned_test.rb new file mode 100644 index 000000000..4d4355cfc --- /dev/null +++ b/test/models/column/positioned_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Column::PositionedTest < ActiveSupport::TestCase + test "auto position new columns" do + collection = collections(:writebook) + max_position = collection.columns.maximum(:position) + + new_column = collection.columns.create!(name: "New Column", color: "#000000") + + assert_equal max_position + 1, new_column.position + end + + test "move column to the left" do + collection = collections(:writebook) + columns = collection.columns.sorted.to_a + + column_a = columns[0] + column_b = columns[1] + original_position_a = column_a.position + original_position_b = column_b.position + + column_b.move_left + + assert_equal original_position_b, column_a.reload.position + assert_equal original_position_a, column_b.reload.position + end + + test "move left when already at leftmost position" do + collection = collections(:writebook) + leftmost_column = collection.columns.sorted.first + original_position = leftmost_column.position + + leftmost_column.move_left + + assert_equal original_position, leftmost_column.reload.position + end + + test "move column to the right" do + collection = collections(:writebook) + columns = collection.columns.sorted.to_a + + column_a = columns[0] + column_b = columns[1] + original_position_a = column_a.position + original_position_b = column_b.position + + column_a.move_right + + assert_equal original_position_b, column_a.reload.position + assert_equal original_position_a, column_b.reload.position + end + + test "move right when already at rightmost position" do + collection = collections(:writebook) + rightmost_column = collection.columns.sorted.last + original_position = rightmost_column.position + + rightmost_column.move_right + + assert_equal original_position, rightmost_column.reload.position + end +end From 5eb5e4b0075b05bf28701309c5e0012923c64197 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:15:25 +0100 Subject: [PATCH 02/11] Add buttons to move columns left and right --- .../columns/left_positions_controller.rb | 8 +++ .../columns/right_positions_controller.rb | 8 +++ app/controllers/concerns/column_scoped.rb | 12 +++++ app/models/column/positioned.rb | 49 +++++++++++++++++++ app/models/concerns/column/positioned.rb | 35 ------------- .../collections/show/menu/_column.html.erb | 20 ++++++++ .../left_positions/create.turbo_stream.erb | 4 ++ .../right_positions/create.turbo_stream.erb | 4 ++ config/routes.rb | 5 ++ .../20251029-populate-column-positions.rb | 20 ++++++++ 10 files changed, 130 insertions(+), 35 deletions(-) create mode 100644 app/controllers/columns/left_positions_controller.rb create mode 100644 app/controllers/columns/right_positions_controller.rb create mode 100644 app/controllers/concerns/column_scoped.rb create mode 100644 app/models/column/positioned.rb delete mode 100644 app/models/concerns/column/positioned.rb create mode 100644 app/views/columns/left_positions/create.turbo_stream.erb create mode 100644 app/views/columns/right_positions/create.turbo_stream.erb create mode 100755 script/migrations/20251029-populate-column-positions.rb diff --git a/app/controllers/columns/left_positions_controller.rb b/app/controllers/columns/left_positions_controller.rb new file mode 100644 index 000000000..ce87befb9 --- /dev/null +++ b/app/controllers/columns/left_positions_controller.rb @@ -0,0 +1,8 @@ +class Columns::LeftPositionsController < ApplicationController + include ActionView::RecordIdentifier, ColumnScoped + + def create + @left_column = @column.left_column + @column.move_left + end +end diff --git a/app/controllers/columns/right_positions_controller.rb b/app/controllers/columns/right_positions_controller.rb new file mode 100644 index 000000000..14b7b849a --- /dev/null +++ b/app/controllers/columns/right_positions_controller.rb @@ -0,0 +1,8 @@ +class Columns::RightPositionsController < ApplicationController + include ActionView::RecordIdentifier, ColumnScoped + + def create + @right_column = @column.right_column + @column.move_right + end +end diff --git a/app/controllers/concerns/column_scoped.rb b/app/controllers/concerns/column_scoped.rb new file mode 100644 index 000000000..ea002b95a --- /dev/null +++ b/app/controllers/concerns/column_scoped.rb @@ -0,0 +1,12 @@ +module ColumnScoped + extend ActiveSupport::Concern + + included do + before_action :set_column + end + + private + def set_column + @column = Column.find(params[:column_id]) + end +end diff --git a/app/models/column/positioned.rb b/app/models/column/positioned.rb new file mode 100644 index 000000000..3ce92b290 --- /dev/null +++ b/app/models/column/positioned.rb @@ -0,0 +1,49 @@ +module Column::Positioned + extend ActiveSupport::Concern + + included do + scope :sorted, -> { order(position: :asc) } + + before_create :set_position + end + + def move_left + swap_position_with left_column + end + + def move_right + swap_position_with right_column + end + + def left_column + collection.columns.where("position < ?", position).sorted.last + end + + def right_column + collection.columns.where("position > ?", position).sorted.first + end + + def leftmost? + left_column.nil? + end + + def rightmost? + right_column.nil? + end + + private + def set_position + max_position = collection.columns.maximum(:position) + self.position = max_position + 1 + end + + def swap_position_with(other_column) + return if other_column.nil? + + transaction do + old_position = self.position + self.update_column(:position, other_column.position) + other_column.update_column(:position, old_position) + end + end +end diff --git a/app/models/concerns/column/positioned.rb b/app/models/concerns/column/positioned.rb deleted file mode 100644 index a3864dab0..000000000 --- a/app/models/concerns/column/positioned.rb +++ /dev/null @@ -1,35 +0,0 @@ -module Column::Positioned - extend ActiveSupport::Concern - - included do - scope :sorted, -> { order(position: :asc) } - - before_create :set_position - end - - def move_left - if column = collection.columns.where("position < ?", position).sorted.last - swap_position_with(column) - end - end - - def move_right - if column = collection.columns.where("position > ?", position).sorted.first - swap_position_with(column) - end - end - - private - def set_position - max_position = collection.columns.maximum(:position) - self.position = max_position + 1 - end - - def swap_position_with(other_column) - transaction do - other_column.position, self.position = self.position, other_column.position - other_column.save! - save! - end - end -end diff --git a/app/views/collections/show/menu/_column.html.erb b/app/views/collections/show/menu/_column.html.erb index 6870e34e7..3977f98a5 100644 --- a/app/views/collections/show/menu/_column.html.erb +++ b/app/views/collections/show/menu/_column.html.erb @@ -11,6 +11,24 @@ Edit column + + + + diff --git a/app/views/columns/left_positions/create.turbo_stream.erb b/app/views/columns/left_positions/create.turbo_stream.erb new file mode 100644 index 000000000..66eadf968 --- /dev/null +++ b/app/views/columns/left_positions/create.turbo_stream.erb @@ -0,0 +1,4 @@ +<% if @left_column %> + <%= turbo_stream.remove(dom_id(@column)) %> + <%= turbo_stream.before(@left_column, partial: "collections/show/column", locals: { column: @column }) %> +<% end %> diff --git a/app/views/columns/right_positions/create.turbo_stream.erb b/app/views/columns/right_positions/create.turbo_stream.erb new file mode 100644 index 000000000..ed04429fd --- /dev/null +++ b/app/views/columns/right_positions/create.turbo_stream.erb @@ -0,0 +1,4 @@ +<% if @right_column %> + <%= turbo_stream.remove(dom_id(@column)) %> + <%= turbo_stream.after(@right_column, partial: "collections/show/column", locals: { column: @column }) %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 76a6204cc..bdf95a29a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,11 @@ Rails.application.routes.draw do end end + resources :columns, only: [] do + resource :left_position, module: :columns + resource :right_position, module: :columns + end + namespace :columns do resources :cards do scope module: "cards" do diff --git a/script/migrations/20251029-populate-column-positions.rb b/script/migrations/20251029-populate-column-positions.rb new file mode 100755 index 000000000..b47f5eccf --- /dev/null +++ b/script/migrations/20251029-populate-column-positions.rb @@ -0,0 +1,20 @@ +#!/usr/bin/env ruby + +require_relative "../../config/environment" + +ApplicationRecord.with_each_tenant do |tenant| + puts "Processing tenant: #{tenant}" + + Collection.find_each do |collection| + puts " Processing collection: #{collection.name} (ID: #{collection.id})" + + columns = collection.columns.order(:id) + + columns.each_with_index do |column, index| + column.update_column(:position, index) + puts " Set position #{index} for column '#{column.name}' (ID: #{column.id})" + end + end +end + +puts "Migration completed!" From 24d24683dd0a1969d813698caf5ac89f601f1306 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:38:44 +0100 Subject: [PATCH 03/11] Keep the expanded state when moving columns --- .../collapsible_columns_controller.js | 37 ++++++++++++++----- app/views/collections/show/_column.html.erb | 2 +- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js index 01289baf0..788a817ab 100644 --- a/app/javascript/controllers/collapsible_columns_controller.js +++ b/app/javascript/controllers/collapsible_columns_controller.js @@ -1,5 +1,5 @@ import { Controller } from "@hotwired/stimulus" -import { nextFrame } from "helpers/timing_helpers"; +import { nextFrame, delay, debounce } from "helpers/timing_helpers"; export default class extends Controller { static classes = [ "collapsed", "noTransitions" ] @@ -8,12 +8,12 @@ export default class extends Controller { collection: String } - async connect() { - this.#disableTransitions() - this.#restoreColumns() + initialize() { + this.restoreState = debounce(this.restoreState.bind(this), 10) + } - await nextFrame() - this.#enableTransitions() + async connect() { + await this.#restoreColumnsDisablingTransitions() } toggle({ target }) { @@ -27,6 +27,19 @@ export default class extends Controller { } } + async restoreState(event) { + await nextFrame() + await this.#restoreColumnsDisablingTransitions() + } + + async #restoreColumnsDisablingTransitions() { + this.#disableTransitions() + this.#restoreColumns() + + await nextFrame() + this.#enableTransitions() + } + #disableTransitions() { this.element.classList.add(this.noTransitionsClass) } @@ -79,13 +92,17 @@ export default class extends Controller { #restoreColumns() { this.columnTargets.forEach(column => { - const key = this.#localStorageKeyFor(column) - if (localStorage.getItem(key)) { - this.#expand(column) - } + this.#restoreColumn(column) }) } + #restoreColumn(column) { + const key = this.#localStorageKeyFor(column) + if (localStorage.getItem(key)) { + this.#expand(column) + } + } + #localStorageKeyFor(column) { return `expand-${this.collectionValue}-${column.getAttribute("id")}` } diff --git a/app/views/collections/show/_column.html.erb b/app/views/collections/show/_column.html.erb index ed667047d..3a6084b0c 100644 --- a/app/views/collections/show/_column.html.erb +++ b/app/views/collections/show/_column.html.erb @@ -4,7 +4,7 @@ data-drag-and-strum-target="container" data-collapsible-columns-target="column" data-controller="clicker" - data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle" + data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle turbo:before-stream-render@document->collapsible-columns#restoreState" data-drag-and-drop-url="<%= columns_card_drops_column_path("__id__", column_id: column.id) %>" > <%= render "collections/show/expander", title: column.name, count: column.cards.active.count, column_id: dom_id(column) do %> From 7a1dde1a81f7fb7c009bdae252692f9b13b2d1f4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:41:04 +0100 Subject: [PATCH 04/11] Add tests --- .../columns/left_positions_controller_test.rb | 23 +++++++++++++++++++ .../right_positions_controller_test.rb | 23 +++++++++++++++++++ test/fixtures/columns.yml | 6 ++++- 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 test/controllers/columns/left_positions_controller_test.rb create mode 100644 test/controllers/columns/right_positions_controller_test.rb diff --git a/test/controllers/columns/left_positions_controller_test.rb b/test/controllers/columns/left_positions_controller_test.rb new file mode 100644 index 000000000..1f1570b18 --- /dev/null +++ b/test/controllers/columns/left_positions_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Columns::LeftPositionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "move column left" do + collection = collections(:writebook) + columns = collection.columns.sorted.to_a + + column_a = columns[0] + column_b = columns[1] + original_position_a = column_a.position + original_position_b = column_b.position + + post column_left_position_path(column_b), as: :turbo_stream + assert_response :success + + assert_equal original_position_b, column_a.reload.position + assert_equal original_position_a, column_b.reload.position + end +end diff --git a/test/controllers/columns/right_positions_controller_test.rb b/test/controllers/columns/right_positions_controller_test.rb new file mode 100644 index 000000000..572a56383 --- /dev/null +++ b/test/controllers/columns/right_positions_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Columns::RightPositionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "move column right" do + collection = collections(:writebook) + columns = collection.columns.sorted.to_a + + column_a = columns[0] + column_b = columns[1] + original_position_a = column_a.position + original_position_b = column_b.position + + post column_right_position_path(column_a), as: :turbo_stream + assert_response :success + + assert_equal original_position_b, column_a.reload.position + assert_equal original_position_a, column_b.reload.position + end +end diff --git a/test/fixtures/columns.yml b/test/fixtures/columns.yml index a17a18cbd..9c3269637 100644 --- a/test/fixtures/columns.yml +++ b/test/fixtures/columns.yml @@ -3,18 +3,22 @@ writebook_triage: name: Triage color: "#000000" collection: writebook + position: 0 writebook_in_progress: name: In progress color: "#3b3633" collection: writebook + position: 1 writebook_on_hold: name: On Hold color: "#67695e" collection: writebook + position: 2 writebook_review: name: Review color: "#eb7a32" - collection: writebook \ No newline at end of file + collection: writebook + position: 3 \ No newline at end of file From cda768aa08f315687b0d28c10c4fef3d420233ea Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:42:33 +0100 Subject: [PATCH 05/11] Fix nil error when no position --- app/models/column/positioned.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/column/positioned.rb b/app/models/column/positioned.rb index 3ce92b290..333882e36 100644 --- a/app/models/column/positioned.rb +++ b/app/models/column/positioned.rb @@ -33,7 +33,7 @@ module Column::Positioned private def set_position - max_position = collection.columns.maximum(:position) + max_position = collection.columns.maximum(:position) || 0 self.position = max_position + 1 end From 68ef01114c47366a1ab4f6cc2f7258961daca1be Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:43:39 +0100 Subject: [PATCH 06/11] Not needed anymore since we moved the streams to the templates --- app/controllers/columns/left_positions_controller.rb | 2 +- app/controllers/columns/right_positions_controller.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/columns/left_positions_controller.rb b/app/controllers/columns/left_positions_controller.rb index ce87befb9..7161c093f 100644 --- a/app/controllers/columns/left_positions_controller.rb +++ b/app/controllers/columns/left_positions_controller.rb @@ -1,5 +1,5 @@ class Columns::LeftPositionsController < ApplicationController - include ActionView::RecordIdentifier, ColumnScoped + include ColumnScoped def create @left_column = @column.left_column diff --git a/app/controllers/columns/right_positions_controller.rb b/app/controllers/columns/right_positions_controller.rb index 14b7b849a..d43beb662 100644 --- a/app/controllers/columns/right_positions_controller.rb +++ b/app/controllers/columns/right_positions_controller.rb @@ -1,5 +1,5 @@ class Columns::RightPositionsController < ApplicationController - include ActionView::RecordIdentifier, ColumnScoped + include ColumnScoped def create @right_column = @column.right_column From cc8279181bd6bc13a0e938b9f163717534ac6a31 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:48:22 +0100 Subject: [PATCH 07/11] Format --- app/views/collections/show/menu/_column.html.erb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/collections/show/menu/_column.html.erb b/app/views/collections/show/menu/_column.html.erb index 3977f98a5..b3d9a26c5 100644 --- a/app/views/collections/show/menu/_column.html.erb +++ b/app/views/collections/show/menu/_column.html.erb @@ -39,8 +39,6 @@ Delete column <% end %> - - From 70333d2d4e7e0097dfb0d608c837380268b16094 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 29 Oct 2025 16:51:45 +0100 Subject: [PATCH 08/11] Format --- app/views/collections/show/menu/_column.html.erb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/collections/show/menu/_column.html.erb b/app/views/collections/show/menu/_column.html.erb index b3d9a26c5..c89d8077e 100644 --- a/app/views/collections/show/menu/_column.html.erb +++ b/app/views/collections/show/menu/_column.html.erb @@ -11,6 +11,7 @@ Edit column + + + @@ -27,8 +27,8 @@ class: "popup__btn btn", form_class: "display-contents", disabled: column.rightmost? do %> - <%= icon_tag "arrow-right" %> - Move right + <%= icon_tag "column-right" %> + Move to the right <% end %>