diff --git a/.dockerignore b/.dockerignore index df5bbdacc5..e004b7d6ed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,45 +1,15 @@ -# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. - -# Ignore git directory. -/.git/ - -# Ignore bundler config. -/.bundle - -# Ignore documentation -/docs/ -/README.md -/CLAUDE.md -/AGENTS.md -/STYLE.md -/CONTRIBUTING.md - -# Ignore all environment files (except templates). -/.env* -!/.env*.erb - -# Ignore all default key files. -/config/master.key -/config/credentials/*.key - -# Ignore all logfiles and tempfiles. -/log/* -/tmp/* -!/log/.keep -!/tmp/.keep - -# Ignore pidfiles, but keep the directory. -/tmp/pids/* -!/tmp/pids/.keep - -# Ignore storage (uploaded files in development and any SQLite databases). -/storage/* -!/storage/.keep -/tmp/storage/* -!/tmp/storage/.keep - -# Ignore assets. -/node_modules/ -/app/assets/builds/* -!/app/assets/builds/.keep -/public/assets +.git +.bundle +log/* +tmp/* +!tmp/keep +public/assets +storage/* +!storage/keep +node_modules +.DS_Store +Dockerfile +Dockerfile.dev +docker-compose.yml +.dockerignore +vendor/bundle diff --git a/Dockerfile.dev b/Dockerfile.dev index 9c20fcda5d..268d834a33 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -19,7 +19,6 @@ RUN apt-get update -qq && \ # Install application gems COPY Gemfile Gemfile.lock .ruby-version ./ COPY lib/fizzy.rb ./lib/fizzy.rb -COPY gems ./gems/ RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-devbundle-${RUBY_VERSION},sharing=locked,target=/devbundle \ gem install bundler foreman && \ BUNDLE_PATH=/devbundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \ diff --git a/app/controllers/boards/columns/closeds_controller.rb b/app/controllers/boards/columns/closeds_controller.rb index 95e0c7126f..2692f5c34f 100644 --- a/app/controllers/boards/columns/closeds_controller.rb +++ b/app/controllers/boards/columns/closeds_controller.rb @@ -2,7 +2,12 @@ class Boards::Columns::ClosedsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.closed.recently_closed_first.preloaded + cards = if @board.manual_sorting_enabled? + @board.cards.closed.ordered_by_position(Arel.sql("closures.created_at DESC, cards.id DESC")).preloaded + else + @board.cards.closed.recently_closed_first.preloaded + end + set_page_and_extract_portion_from cards fresh_when etag: @page.records end end diff --git a/app/controllers/boards/columns/not_nows_controller.rb b/app/controllers/boards/columns/not_nows_controller.rb index 0ff41e9ae3..cc3f55735c 100644 --- a/app/controllers/boards/columns/not_nows_controller.rb +++ b/app/controllers/boards/columns/not_nows_controller.rb @@ -2,7 +2,12 @@ class Boards::Columns::NotNowsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded + cards = if @board.manual_sorting_enabled? + @board.cards.postponed.ordered_by_position(last_active_at: :desc, id: :desc).preloaded + else + @board.cards.postponed.latest.preloaded + end + set_page_and_extract_portion_from cards fresh_when etag: @page.records end end diff --git a/app/controllers/boards/columns/streams_controller.rb b/app/controllers/boards/columns/streams_controller.rb index d2c7d0795e..4d3df04630 100644 --- a/app/controllers/boards/columns/streams_controller.rb +++ b/app/controllers/boards/columns/streams_controller.rb @@ -2,7 +2,12 @@ class Boards::Columns::StreamsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded + cards = if @board.manual_sorting_enabled? + @board.cards.awaiting_triage.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc).preloaded + else + @board.cards.awaiting_triage.latest.with_golden_first.preloaded + end + set_page_and_extract_portion_from cards fresh_when etag: @page.records end end diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 4b71e7f3e0..ebe79d49d2 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -9,7 +9,12 @@ def index end def show - set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first.preloaded + cards = if @board.manual_sorting_enabled? + @column.cards.active.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc).preloaded + else + @column.cards.active.latest.with_golden_first.preloaded + end + set_page_and_extract_portion_from cards fresh_when etag: @page.records end diff --git a/app/controllers/boards/manual_sortings_controller.rb b/app/controllers/boards/manual_sortings_controller.rb new file mode 100644 index 0000000000..a87d362b92 --- /dev/null +++ b/app/controllers/boards/manual_sortings_controller.rb @@ -0,0 +1,14 @@ +class Boards::ManualSortingsController < ApplicationController + include BoardScoped + + before_action :ensure_permission_to_admin_board + + def create + @board.update!(manual_sorting_enabled: true) + end + + def destroy + @board.update!(manual_sorting_enabled: false) + end +end + diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 721e69b0d5..c2a5d4cd32 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -81,7 +81,11 @@ def show_filtered_cards end def show_columns - cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded + cards = if @board.manual_sorting_enabled? + @board.cards.awaiting_triage.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc).preloaded + else + @board.cards.awaiting_triage.latest.with_golden_first.preloaded + end set_page_and_extract_portion_from cards fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] end diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index c4aac26d19..37732bfdee 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -2,7 +2,13 @@ class Cards::ClosuresController < ApplicationController include CardScoped def create - @card.close + ActiveRecord::Base.transaction do + @card.close + + Card::Positioner + .new(relation: @board.cards.closed, fallback_order: Arel.sql("closures.created_at DESC, cards.id DESC")) + .reposition!(card: @card, before_number: nil, after_number: nil) + end respond_to do |format| format.turbo_stream { render_card_replacement } @@ -11,7 +17,29 @@ def create end def destroy - @card.reopen + ActiveRecord::Base.transaction do + @card.reopen + + relation = if @card.postponed? + @board.cards.postponed + elsif @card.triaged? + @card.column.cards.active + else + @board.cards.awaiting_triage + end + + if @card.active? + relation = if @card.golden? + relation.joins(:goldness) + else + relation.where.missing(:goldness) + end + end + + Card::Positioner + .new(relation: relation, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: nil, after_number: nil) + end respond_to do |format| format.turbo_stream { render_card_replacement } diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb index 8eeb0e663b..9faa4c3497 100644 --- a/app/controllers/cards/not_nows_controller.rb +++ b/app/controllers/cards/not_nows_controller.rb @@ -2,7 +2,13 @@ class Cards::NotNowsController < ApplicationController include CardScoped def create - @card.postpone + ActiveRecord::Base.transaction do + @card.postpone + + Card::Positioner + .new(relation: @board.cards.postponed, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: nil, after_number: nil) + end respond_to do |format| format.turbo_stream { render_card_replacement } diff --git a/app/controllers/cards/publishes_controller.rb b/app/controllers/cards/publishes_controller.rb index 641f728cdd..06dd79a01e 100644 --- a/app/controllers/cards/publishes_controller.rb +++ b/app/controllers/cards/publishes_controller.rb @@ -2,7 +2,25 @@ class Cards::PublishesController < ApplicationController include CardScoped def create - @card.publish + ActiveRecord::Base.transaction do + @card.publish + + relation = if @card.triaged? + @card.column.cards.active + else + @board.cards.awaiting_triage + end + + relation = if @card.golden? + relation.joins(:goldness) + else + relation.where.missing(:goldness) + end + + Card::Positioner + .new(relation: relation, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: nil, after_number: nil) + end if add_another_param? redirect_to @board.cards.create!, notice: "Card added" diff --git a/app/controllers/cards/triages_controller.rb b/app/controllers/cards/triages_controller.rb index d8fc548f15..0b68e17699 100644 --- a/app/controllers/cards/triages_controller.rb +++ b/app/controllers/cards/triages_controller.rb @@ -3,7 +3,20 @@ class Cards::TriagesController < ApplicationController def create column = @card.board.columns.find(params[:column_id]) - @card.triage_into(column) + ActiveRecord::Base.transaction do + @card.triage_into(column) + + relation = column.cards.active + relation = if @card.golden? + relation.joins(:goldness) + else + relation.where.missing(:goldness) + end + + Card::Positioner + .new(relation: relation, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: nil, after_number: nil) + end respond_to do |format| format.html { redirect_to @card } @@ -12,7 +25,20 @@ def create end def destroy - @card.send_back_to_triage + ActiveRecord::Base.transaction do + @card.send_back_to_triage + + relation = @board.cards.awaiting_triage + relation = if @card.golden? + relation.joins(:goldness) + else + relation.where.missing(:goldness) + end + + Card::Positioner + .new(relation: relation, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: nil, after_number: nil) + end respond_to do |format| format.html { redirect_to @card } diff --git a/app/controllers/columns/cards/drops/closures_controller.rb b/app/controllers/columns/cards/drops/closures_controller.rb index a58e86775c..04f0233a9c 100644 --- a/app/controllers/columns/cards/drops/closures_controller.rb +++ b/app/controllers/columns/cards/drops/closures_controller.rb @@ -2,6 +2,17 @@ class Columns::Cards::Drops::ClosuresController < ApplicationController include CardScoped def create - @card.close + if @card.closed? + return unless @board.manual_sorting_enabled? + else + @card.close + end + + before_id = @board.manual_sorting_enabled? ? params[:before_id] : nil + after_id = @board.manual_sorting_enabled? ? params[:after_id] : nil + + Card::Positioner + .new(relation: @board.cards.closed, fallback_order: Arel.sql("closures.created_at DESC, cards.id DESC")) + .reposition!(card: @card, before_number: before_id, after_number: after_id) end end diff --git a/app/controllers/columns/cards/drops/columns_controller.rb b/app/controllers/columns/cards/drops/columns_controller.rb index 1d2bd8ce54..cf3e80f2b0 100644 --- a/app/controllers/columns/cards/drops/columns_controller.rb +++ b/app/controllers/columns/cards/drops/columns_controller.rb @@ -3,6 +3,29 @@ class Columns::Cards::Drops::ColumnsController < ApplicationController def create @column = @card.board.columns.find(params[:column_id]) - @card.triage_into(@column) + + ActiveRecord::Base.transaction do + already_in_target = @card.active? && @card.column == @column + + if already_in_target + return unless @board.manual_sorting_enabled? + else + @card.triage_into(@column) + end + + relation = @column.cards.active + relation = if @card.golden? + relation.joins(:goldness) + else + relation.where.missing(:goldness) + end + + before_id = @board.manual_sorting_enabled? ? params[:before_id] : nil + after_id = @board.manual_sorting_enabled? ? params[:after_id] : nil + + Card::Positioner + .new(relation: relation, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: before_id, after_number: after_id) + end end end diff --git a/app/controllers/columns/cards/drops/not_nows_controller.rb b/app/controllers/columns/cards/drops/not_nows_controller.rb index c1b6a5fac7..1878d6b1c8 100644 --- a/app/controllers/columns/cards/drops/not_nows_controller.rb +++ b/app/controllers/columns/cards/drops/not_nows_controller.rb @@ -2,6 +2,19 @@ class Columns::Cards::Drops::NotNowsController < ApplicationController include CardScoped def create - @card.postpone + ActiveRecord::Base.transaction do + if @card.postponed? + return unless @board.manual_sorting_enabled? + else + @card.postpone + end + + before_id = @board.manual_sorting_enabled? ? params[:before_id] : nil + after_id = @board.manual_sorting_enabled? ? params[:after_id] : nil + + Card::Positioner + .new(relation: @board.cards.postponed, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: before_id, after_number: after_id) + end end end diff --git a/app/controllers/columns/cards/drops/streams_controller.rb b/app/controllers/columns/cards/drops/streams_controller.rb index fefdbb23f8..ea3c9beeb0 100644 --- a/app/controllers/columns/cards/drops/streams_controller.rb +++ b/app/controllers/columns/cards/drops/streams_controller.rb @@ -2,7 +2,30 @@ class Columns::Cards::Drops::StreamsController < ApplicationController include CardScoped def create - @card.send_back_to_triage - set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first + ActiveRecord::Base.transaction do + unless @card.awaiting_triage? + @card.send_back_to_triage + end + + relation = @board.cards.awaiting_triage + relation = if @card.golden? + relation.joins(:goldness) + else + relation.where.missing(:goldness) + end + + if @board.manual_sorting_enabled? + Card::Positioner + .new(relation: relation, fallback_order: { last_active_at: :desc, id: :desc }) + .reposition!(card: @card, before_number: params[:before_id], after_number: params[:after_id]) + end + end + + cards = if @board.manual_sorting_enabled? + @board.cards.awaiting_triage.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc) + else + @board.cards.awaiting_triage.latest.with_golden_first + end + set_page_and_extract_portion_from cards end end diff --git a/app/controllers/public/boards/columns/closeds_controller.rb b/app/controllers/public/boards/columns/closeds_controller.rb index 63ca9de7b2..53d6e913a2 100644 --- a/app/controllers/public/boards/columns/closeds_controller.rb +++ b/app/controllers/public/boards/columns/closeds_controller.rb @@ -1,5 +1,10 @@ class Public::Boards::Columns::ClosedsController < Public::BaseController def show - set_page_and_extract_portion_from @board.cards.closed.recently_closed_first + cards = if @board.manual_sorting_enabled? + @board.cards.closed.ordered_by_position(Arel.sql("closures.created_at DESC, cards.id DESC")) + else + @board.cards.closed.recently_closed_first + end + set_page_and_extract_portion_from cards end end diff --git a/app/controllers/public/boards/columns/not_nows_controller.rb b/app/controllers/public/boards/columns/not_nows_controller.rb index 5a54df1716..55158a9362 100644 --- a/app/controllers/public/boards/columns/not_nows_controller.rb +++ b/app/controllers/public/boards/columns/not_nows_controller.rb @@ -1,5 +1,10 @@ class Public::Boards::Columns::NotNowsController < Public::BaseController def show - set_page_and_extract_portion_from @board.cards.postponed.latest + cards = if @board.manual_sorting_enabled? + @board.cards.postponed.ordered_by_position(last_active_at: :desc, id: :desc) + else + @board.cards.postponed.latest + end + set_page_and_extract_portion_from cards end end diff --git a/app/controllers/public/boards/columns/streams_controller.rb b/app/controllers/public/boards/columns/streams_controller.rb index 778817b3e1..fe8f8badbf 100644 --- a/app/controllers/public/boards/columns/streams_controller.rb +++ b/app/controllers/public/boards/columns/streams_controller.rb @@ -1,5 +1,10 @@ class Public::Boards::Columns::StreamsController < Public::BaseController def show - set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first + cards = if @board.manual_sorting_enabled? + @board.cards.awaiting_triage.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc) + else + @board.cards.awaiting_triage.latest.with_golden_first + end + set_page_and_extract_portion_from cards end end diff --git a/app/controllers/public/boards/columns_controller.rb b/app/controllers/public/boards/columns_controller.rb index 5a5c23d5eb..91b2f2b991 100644 --- a/app/controllers/public/boards/columns_controller.rb +++ b/app/controllers/public/boards/columns_controller.rb @@ -2,7 +2,12 @@ class Public::Boards::ColumnsController < Public::BaseController before_action :set_column def show - set_page_and_extract_portion_from @column.cards.active.latest.with_golden_first + cards = if @board.manual_sorting_enabled? + @column.cards.active.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc) + else + @column.cards.active.latest.with_golden_first + end + set_page_and_extract_portion_from cards end private diff --git a/app/controllers/public/boards_controller.rb b/app/controllers/public/boards_controller.rb index d56a1f684f..68b8355877 100644 --- a/app/controllers/public/boards_controller.rb +++ b/app/controllers/public/boards_controller.rb @@ -1,5 +1,10 @@ class Public::BoardsController < Public::BaseController def show - set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first + cards = if @board.manual_sorting_enabled? + @board.cards.awaiting_triage.with_golden_first.ordered_by_position(last_active_at: :desc, id: :desc) + else + @board.cards.awaiting_triage.latest.with_golden_first + end + set_page_and_extract_portion_from cards end end diff --git a/app/javascript/controllers/drag_and_drop_controller.js b/app/javascript/controllers/drag_and_drop_controller.js index 5fc532ca16..88285f2187 100644 --- a/app/javascript/controllers/drag_and_drop_controller.js +++ b/app/javascript/controllers/drag_and_drop_controller.js @@ -1,150 +1,246 @@ -import { Controller } from "@hotwired/stimulus" -import { post } from "@rails/request.js" -import { nextFrame } from "helpers/timing_helpers" +import { Controller } from "@hotwired/stimulus"; +import { post } from "@rails/request.js"; +import { nextFrame } from "helpers/timing_helpers"; export default class extends Controller { - static targets = [ "item", "container" ] - static classes = [ "draggedItem", "hoverContainer" ] + static targets = ["item", "container"]; + static classes = ["draggedItem", "hoverContainer"]; // Actions async dragStart(event) { - event.dataTransfer.effectAllowed = "move" - event.dataTransfer.dropEffect = "move" - event.dataTransfer.setData("37ui/move", event.target) + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setData("37ui/move", event.target); - await nextFrame() - this.dragItem = this.#itemContaining(event.target) - this.sourceContainer = this.#containerContaining(this.dragItem) - this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer) - this.dragItem.classList.add(this.draggedItemClass) + await nextFrame(); + this.dragItem = this.#itemContaining(event.target); + this.sourceContainer = this.#containerContaining(this.dragItem); + this.originalDraggedItemCssVariable = this.#containerCssVariableFor( + this.sourceContainer, + ); + this.dragItem.classList.add(this.draggedItemClass); } dragOver(event) { - event.preventDefault() - if (!this.dragItem) { return } + event.preventDefault(); + if (!this.dragItem) { + return; + } + + const container = this.#containerContaining(event.target); + this.#clearContainerHoverClasses(); - const container = this.#containerContaining(event.target) - this.#clearContainerHoverClasses() + if (!container) { + return; + } - if (!container) { return } + if (container !== this.sourceContainer || this.#reorderEnabled(container)) { + this.#repositionDraggedItem(container, event.clientY); + } if (container !== this.sourceContainer) { - container.classList.add(this.hoverContainerClass) - this.#applyContainerCssVariableToDraggedItem(container) + container.classList.add(this.hoverContainerClass); + this.#applyContainerCssVariableToDraggedItem(container); } else { - this.#restoreOriginalDraggedItemCssVariable() + this.#restoreOriginalDraggedItemCssVariable(); } } async drop(event) { - const targetContainer = this.#containerContaining(event.target) + const targetContainer = this.#containerContaining(event.target); - if (!targetContainer || targetContainer === this.sourceContainer) { return } + if (!targetContainer) { + return; + } - this.wasDropped = true - this.#increaseCounter(targetContainer) - this.#decreaseCounter(this.sourceContainer) + if ( + targetContainer === this.sourceContainer && + !this.#reorderEnabled(targetContainer) + ) { + return; + } - const sourceContainer = this.sourceContainer - this.#insertDraggedItem(targetContainer, this.dragItem) - await this.#submitDropRequest(this.dragItem, targetContainer) - this.#reloadSourceFrame(sourceContainer) + this.wasDropped = true; + const sourceContainer = this.sourceContainer; + const movedAcrossContainers = targetContainer !== sourceContainer; + + if (movedAcrossContainers) { + this.#increaseCounter(targetContainer); + this.#decreaseCounter(sourceContainer); + } + + this.#repositionDraggedItem(targetContainer, event.clientY); + await this.#submitDropRequest(this.dragItem, targetContainer); + + if (movedAcrossContainers) this.#reloadSourceFrame(sourceContainer); } dragEnd() { - this.dragItem.classList.remove(this.draggedItemClass) - this.#clearContainerHoverClasses() + this.dragItem.classList.remove(this.draggedItemClass); + this.#clearContainerHoverClasses(); if (!this.wasDropped) { - this.#restoreOriginalDraggedItemCssVariable() + this.#restoreOriginalDraggedItemCssVariable(); } - this.sourceContainer = null - this.dragItem = null - this.wasDropped = false - this.originalDraggedItemCssVariable = null + this.sourceContainer = null; + this.dragItem = null; + this.wasDropped = false; + this.originalDraggedItemCssVariable = null; } #itemContaining(element) { - return this.itemTargets.find(item => item.contains(element) || item === element) + return this.itemTargets.find( + (item) => item.contains(element) || item === element, + ); } #containerContaining(element) { - return this.containerTargets.find(container => container.contains(element) || container === element) + return this.containerTargets.find( + (container) => container.contains(element) || container === element, + ); } #clearContainerHoverClasses() { - this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass)) + this.containerTargets.forEach((container) => + container.classList.remove(this.hoverContainerClass), + ); } #applyContainerCssVariableToDraggedItem(container) { - const cssVariable = this.#containerCssVariableFor(container) + const cssVariable = this.#containerCssVariableFor(container); if (cssVariable) { - this.dragItem.style.setProperty(cssVariable.name, cssVariable.value) + this.dragItem.style.setProperty(cssVariable.name, cssVariable.value); } } #restoreOriginalDraggedItemCssVariable() { if (this.originalDraggedItemCssVariable) { - const { name, value } = this.originalDraggedItemCssVariable - this.dragItem.style.setProperty(name, value) + const { name, value } = this.originalDraggedItemCssVariable; + this.dragItem.style.setProperty(name, value); } } #containerCssVariableFor(container) { - const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset + const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = + container.dataset; if (dragAndDropCssVariableName && dragAndDropCssVariableValue) { - return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue } + return { + name: dragAndDropCssVariableName, + value: dragAndDropCssVariableValue, + }; } - return null + return null; } #increaseCounter(container) { - this.#modifyCounter(container, count => count + 1) + this.#modifyCounter(container, (count) => count + 1); } #decreaseCounter(container) { - this.#modifyCounter(container, count => Math.max(0, count - 1)) + this.#modifyCounter(container, (count) => Math.max(0, count - 1)); } #modifyCounter(container, fn) { - const counterElement = container.querySelector("[data-drag-and-drop-counter]") + const counterElement = container.querySelector( + "[data-drag-and-drop-counter]", + ); if (counterElement) { - const currentValue = counterElement.textContent.trim() + const currentValue = counterElement.textContent.trim(); - if (!/^\d+$/.test(currentValue)) return + if (!/^\d+$/.test(currentValue)) return; - counterElement.textContent = fn(parseInt(currentValue)) + counterElement.textContent = fn(parseInt(currentValue)); } } - #insertDraggedItem(container, item) { - const itemContainer = container.querySelector("[data-drag-drop-item-container]") - const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]") - const firstTopItem = topItems[0] - const lastTopItem = topItems[topItems.length - 1] + #repositionDraggedItem(container, clientY) { + const itemContainer = container.querySelector( + "[data-drag-drop-item-container]", + ); + if (!itemContainer) return; + + const item = this.dragItem; + const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]"); + const firstTopItem = topItems[0]; + const lastTopItem = topItems[topItems.length - 1]; + + const isTopItem = item.hasAttribute("data-drag-and-drop-top"); + const candidates = Array.from( + itemContainer.querySelectorAll('[data-drag-and-drop-target="item"]'), + ) + .filter((candidate) => candidate !== item) + .filter( + (candidate) => + candidate.hasAttribute("data-drag-and-drop-top") === isTopItem, + ); + + const referenceItem = candidates.find((candidate) => { + const { top, height } = candidate.getBoundingClientRect(); + return clientY < top + height / 2; + }); + + if (referenceItem) return referenceItem.before(item); + + if (candidates.length > 0) + return candidates[candidates.length - 1].after(item); + + if (isTopItem) { + const firstNonTopItem = itemContainer.querySelector( + '[data-drag-and-drop-target="item"]:not([data-drag-and-drop-top])', + ); + return firstNonTopItem + ? firstNonTopItem.before(item) + : itemContainer.prepend(item); + } - const isTopItem = item.hasAttribute("data-drag-and-drop-top") - const referenceItem = isTopItem ? firstTopItem : lastTopItem + return lastTopItem ? lastTopItem.after(item) : itemContainer.prepend(item); + } - if (referenceItem) { - referenceItem[isTopItem ? "before" : "after"](item) - } else { - itemContainer.prepend(item) + async #submitDropRequest(item, container) { + const body = new FormData(); + const id = item.dataset.id; + const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id); + + if (this.#reorderEnabled(container)) { + const { beforeId, afterId } = this.#neighborIdsFor(item); + if (beforeId) body.append("before_id", beforeId); + if (afterId) body.append("after_id", afterId); } + + return post(url, { + body, + headers: { Accept: "text/vnd.turbo-stream.html" }, + }); } - async #submitDropRequest(item, container) { - const body = new FormData() - const id = item.dataset.id - const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id) + #reorderEnabled(container) { + return container.dataset.dragAndDropReorderEnabled === "true"; + } + + #neighborIdsFor(item) { + const isTopItem = item.hasAttribute("data-drag-and-drop-top"); + + const matchesGroup = (candidate) => { + if (!candidate) return false; + if (candidate.getAttribute("data-drag-and-drop-target") !== "item") + return false; + return candidate.hasAttribute("data-drag-and-drop-top") === isTopItem; + }; + + let previous = item.previousElementSibling; + while (previous && !matchesGroup(previous)) + previous = previous.previousElementSibling; + + let next = item.nextElementSibling; + while (next && !matchesGroup(next)) next = next.nextElementSibling; - return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } }) + return { beforeId: next?.dataset?.id, afterId: previous?.dataset?.id }; } #reloadSourceFrame(sourceContainer) { - const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]") - if (frame) frame.reload() + const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]"); + if (frame) frame.reload(); } } diff --git a/app/models/card.rb b/app/models/card.rb index dbb45f184e..6be8eb83c3 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,6 @@ class Card < ApplicationRecord include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + Exportable, Golden, Mentions, Multistep, Pinnable, Positioned, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } diff --git a/app/models/card/positioned.rb b/app/models/card/positioned.rb new file mode 100644 index 0000000000..de70ff9615 --- /dev/null +++ b/app/models/card/positioned.rb @@ -0,0 +1,11 @@ +module Card::Positioned + extend ActiveSupport::Concern + + included do + scope :ordered_by_position, ->(fallback_order = nil) do + relation = order(Arel.sql("cards.position IS NULL"), position: :asc) + fallback_order.present? ? relation.order(fallback_order) : relation + end + end +end + diff --git a/app/models/card/positioner.rb b/app/models/card/positioner.rb new file mode 100644 index 0000000000..7a3f68926b --- /dev/null +++ b/app/models/card/positioner.rb @@ -0,0 +1,107 @@ +class Card::Positioner + STEP = 1024 + + def initialize(relation:, fallback_order:) + @relation = relation + @fallback_order = fallback_order + end + + def reposition!(card:, before_number:, after_number:) + ensure_positions! + + before_card = resolve_neighbor(before_number) + after_card = resolve_neighbor(after_number) + + new_position = compute_position(before_card:, after_card:) + + # If we can't fit between neighbors, repack and try once more. + if new_position.nil? + renumber_all! + before_card = resolve_neighbor(before_number) + after_card = resolve_neighbor(after_number) + new_position = compute_position(before_card:, after_card:) || top_position + end + + card.update!(position: new_position) + end + + private + attr_reader :relation, :fallback_order + + def ensure_positions! + return unless relation.where(position: nil).exists? + + renumber_all!(use_fallback_order: true) + end + + def resolve_neighbor(number) + return nil unless number.present? + + card_number = Integer(number) + neighbor = relation.find_by(number: card_number) + return nil unless neighbor + + relation.where(id: neighbor.id).exists? ? neighbor : nil + rescue ArgumentError + nil + end + + def compute_position(before_card:, after_card:) + return top_position if before_card.nil? && after_card.nil? + + if before_card && after_card + before_pos = before_card.position + after_pos = after_card.position + return nil if before_pos.nil? || after_pos.nil? + + gap = before_pos - after_pos + return nil if gap <= 1 + + return after_pos + (gap / 2) + end + + return (before_card.position - STEP) if before_card + return (after_card.position + STEP) if after_card + + nil + end + + def top_position + min = relation.where.not(position: nil).minimum(:position) + min.present? ? (min - STEP) : 0 + end + + def renumber_all!(use_fallback_order: false) + order_sql = if use_fallback_order + order_sql_for(fallback_order) + else + "cards.position ASC, cards.id ASC" + end + + ranked = relation + .reorder(nil) + .reselect(Arel.sql("cards.id AS id, (ROW_NUMBER() OVER (ORDER BY #{order_sql})) * #{STEP} AS new_position")) + + sql = <<~SQL.squish + UPDATE cards + JOIN (#{ranked.to_sql}) ranked ON ranked.id = cards.id + SET cards.position = ranked.new_position + SQL + + ActiveRecord::Base.connection.execute(sql) + end + + def order_sql_for(order) + case order + when Hash + order.map do |column, direction| + direction_sql = direction.to_s.upcase + column_sql = column.to_s.include?(".") ? column.to_s : "cards.#{column}" + "#{column_sql} #{direction_sql}" + end.join(", ") + else + order.to_s + end + end +end + diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb index 36beee3c86..269a017606 100644 --- a/app/views/boards/edit.html.erb +++ b/app/views/boards/edit.html.erb @@ -32,6 +32,7 @@