From 6e90a6dbfb47867920a3d70390b25ad4ab52178b Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Fri, 19 Dec 2025 10:15:20 -0600 Subject: [PATCH] Add hotkeys for triaging cards in columns Hover to select items --- app/controllers/cards/closures_controller.rb | 14 +- app/controllers/cards/not_nows_controller.rb | 4 +- app/controllers/concerns/card_scoped.rb | 11 ++ .../controllers/card_hotkeys_controller.js | 126 ++++++++++++++++++ .../controllers/navigable_list_controller.js | 20 ++- app/views/boards/show/_columns.html.erb | 5 +- .../cards/closures/create.turbo_stream.erb | 9 ++ .../cards/closures/destroy.turbo_stream.erb | 9 ++ app/views/cards/display/_preview.html.erb | 18 ++- .../cards/not_nows/create.turbo_stream.erb | 9 ++ 10 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 app/javascript/controllers/card_hotkeys_controller.js create mode 100644 app/views/cards/closures/create.turbo_stream.erb create mode 100644 app/views/cards/closures/destroy.turbo_stream.erb create mode 100644 app/views/cards/not_nows/create.turbo_stream.erb diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index c4aac26d1..a78b30441 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -2,20 +2,30 @@ class Cards::ClosuresController < ApplicationController include CardScoped def create + capture_card_location @card.close + refresh_stream_if_needed respond_to do |format| - format.turbo_stream { render_card_replacement } + format.turbo_stream format.json { head :no_content } end end def destroy @card.reopen + refresh_stream_after_reopen respond_to do |format| - format.turbo_stream { render_card_replacement } + format.turbo_stream format.json { head :no_content } end end + + private + def refresh_stream_after_reopen + if @card.awaiting_triage? + set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded + end + end end diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb index 8eeb0e663..ea91436b0 100644 --- a/app/controllers/cards/not_nows_controller.rb +++ b/app/controllers/cards/not_nows_controller.rb @@ -2,10 +2,12 @@ class Cards::NotNowsController < ApplicationController include CardScoped def create + capture_card_location @card.postpone + refresh_stream_if_needed respond_to do |format| - format.turbo_stream { render_card_replacement } + format.turbo_stream format.json { head :no_content } end end diff --git a/app/controllers/concerns/card_scoped.rb b/app/controllers/concerns/card_scoped.rb index 52311809e..72e42d080 100644 --- a/app/controllers/concerns/card_scoped.rb +++ b/app/controllers/concerns/card_scoped.rb @@ -17,4 +17,15 @@ module CardScoped def render_card_replacement render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) end + + def capture_card_location + @source_column = @card.column + @was_in_stream = @card.awaiting_triage? + end + + def refresh_stream_if_needed + if @was_in_stream + set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded + end + end end diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js new file mode 100644 index 000000000..6aef323b5 --- /dev/null +++ b/app/javascript/controllers/card_hotkeys_controller.js @@ -0,0 +1,126 @@ +import { Controller } from "@hotwired/stimulus" +import { post } from "@rails/request.js" + +export default class extends Controller { + static outlets = [ "navigable-list" ] + + connect() { + this.morphCompletePromise = null + this.morphCompleteResolver = null + } + + handleKeydown(event) { + if (this.#shouldIgnore(event)) return + + const handler = this.#keyHandlers[event.key.toLowerCase()] + if (handler) { + handler.call(this, event) + } + } + + // Called when turbo:morph completes - resolves our waiting promise + handleMorphComplete() { + if (this.morphCompleteResolver) { + this.morphCompleteResolver() + this.morphCompleteResolver = null + this.morphCompletePromise = null + } + } + + // Private + + #shouldIgnore(event) { + const target = event.target + return target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest("input, textarea, [contenteditable], lexxy-editor") + } + + get #selectedCard() { + // Find the navigable-list that currently has focus + const focusedList = this.navigableListOutlets.find(list => list.hasFocus) + if (!focusedList) return null + + const currentItem = focusedList.currentItem + if (currentItem?.classList.contains("card")) { + return { card: currentItem, controller: focusedList } + } + return null + } + + async #postponeCard(event) { + const selection = this.#selectedCard + if (!selection) return + + const url = selection.card.dataset.cardNotNowUrl + if (url) { + event.preventDefault() + await this.#performCardAction(url, selection) + } + } + + async #closeCard(event) { + const selection = this.#selectedCard + if (!selection) return + + const url = selection.card.dataset.cardClosureUrl + if (url) { + event.preventDefault() + await this.#performCardAction(url, selection) + } + } + + async #assignToMe(event) { + const selection = this.#selectedCard + if (!selection) return + + const url = selection.card.dataset.cardAssignToMeUrl + if (url) { + event.preventDefault() + await post(url, { responseKind: "turbo-stream" }) + } + } + + async #performCardAction(url, selection) { + const { controller } = selection + const visibleItems = controller.visibleItems + const currentIndex = visibleItems.indexOf(selection.card) + const wasLastItem = currentIndex === visibleItems.length - 1 + + // Set up promise to wait for morph completion + this.morphCompletePromise = new Promise(resolve => { + this.morphCompleteResolver = resolve + }) + + await post(url, { responseKind: "turbo-stream" }) + + // Wait for Turbo Stream morph to complete + await Promise.race([ + this.morphCompletePromise, + new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout + ]) + + // Select the next card (or previous if it was the last) + const newVisibleItems = controller.visibleItems + if (newVisibleItems.length === 0) { + controller.clearSelection() + return + } + + if (wasLastItem) { + controller.selectLast() + } else { + const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1) + if (newVisibleItems[nextIndex]) { + await controller.selectItem(newVisibleItems[nextIndex]) + } + } + } + + #keyHandlers = { + "["(event) { this.#postponeCard(event) }, + "]"(event) { this.#closeCard(event) }, + m(event) { this.#assignToMe(event) } + } +} diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index bd5e87665..b9eba66ee 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -45,6 +45,10 @@ export default class extends Controller { this.selectItem(target, true) } + hoverSelect({ currentTarget }) { + this.selectItem(currentTarget) + } + selectCurrentOrReset(event) { if (this.currentItem) { this.#setCurrentFrom(this.currentItem) @@ -221,6 +225,20 @@ export default class extends Controller { }) } + // Public accessors for card_hotkeys_controller outlet + get visibleItems() { + return this.#visibleItems + } + + clearSelection() { + this.#clearSelection() + this.currentItem = null + } + + get hasFocus() { + return this.element.contains(document.activeElement) + } + #keyHandlers = { ArrowDown(event) { if (this.supportsVerticalNavigationValue) { @@ -253,6 +271,6 @@ export default class extends Controller { } else { this.#clickCurrentItem(event) } - }, + } } } diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index a6001da79..bf7a76fcc 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,5 +1,5 @@ <%= tag.div class: "card-columns hide-scrollbar", data: { - controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list", + controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys", drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", drag_and_drop_hover_container_class: "drag-and-drop__hover-container", collapsible_columns_board_value: board.id, @@ -11,8 +11,11 @@ navigable_list_prevent_handled_keys_value: true, navigable_list_auto_select_value: false, navigable_list_auto_scroll_value: false, + card_hotkeys_navigable_list_outlet: ".cards__transition-container", action: " keydown->navigable-list#navigate + keydown->card-hotkeys#handleKeydown + turbo:morph@document->card-hotkeys#handleMorphComplete dragstart->drag-and-drop#dragStart dragover->drag-and-drop#dragOver dragenter->drag-and-strum#dragEnter diff --git a/app/views/cards/closures/create.turbo_stream.erb b/app/views/cards/closures/create.turbo_stream.erb new file mode 100644 index 000000000..c4898e471 --- /dev/null +++ b/app/views/cards/closures/create.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %> + +<% if @source_column %> + <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> +<% elsif @was_in_stream %> + <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> +<% end %> + +<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/closures/destroy.turbo_stream.erb b/app/views/cards/closures/destroy.turbo_stream.erb new file mode 100644 index 000000000..8df408979 --- /dev/null +++ b/app/views/cards/closures/destroy.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %> + +<% if @card.column %> + <%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %> +<% elsif @card.awaiting_triage? %> + <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> +<% end %> + +<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index 655652ffe..c1fc67914 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -1,6 +1,22 @@ <% draggable = local_assigns.fetch(:draggable, false) && card.published? %> -<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" }, tabindex: 0 do %> +<% card_data = { + id: card.number, + drag_and_drop_target: "item", + navigable_list_target: "item", + css_variable_counter_target: "item" +} %> + +<% if card.open? %> + <% card_data[:card_not_now_url] = card_not_now_path(card) %> + <% card_data[:card_closure_url] = card_closure_path(card) %> + <% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %> + <% if Current.user %> + <% card_data[:card_assign_to_me_url] = card_assignments_path(card, params: { assignee_id: Current.user.id }) %> + <% end %> +<% end %> + +<%= card_article_tag card, class: "card", draggable: draggable, data: card_data, tabindex: 0 do %>
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> <%= card.title %> diff --git a/app/views/cards/not_nows/create.turbo_stream.erb b/app/views/cards/not_nows/create.turbo_stream.erb new file mode 100644 index 000000000..9128580e2 --- /dev/null +++ b/app/views/cards/not_nows/create.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.replace("not-now", partial: "boards/show/not_now", method: :morph, locals: { board: @card.board }) %> + +<% if @source_column %> + <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> +<% elsif @was_in_stream %> + <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> +<% end %> + +<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>