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 @@ def set_board 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/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index f48b5d557..b14e6adc6 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -12,6 +12,7 @@ def button_to_set_column(card, column) def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block) classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed) + hotkeys_disabled = data[:card_hotkeys_disabled] data = { drag_and_drop_target: "container", @@ -37,6 +38,7 @@ def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color navigable_list_auto_select_value: "false", navigable_list_actionable_items_value: "true", navigable_list_only_act_on_focused_items_value: "true", + card_hotkeys_disabled: hotkeys_disabled, action: "keydown->navigable-list#navigate" }, &block) 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..b2f92ee9b --- /dev/null +++ b/app/javascript/controllers/card_hotkeys_controller.js @@ -0,0 +1,130 @@ +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") && !this.#hotkeysDisabled(focusedList)) { + 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]) + } + } + } + + #hotkeysDisabled(navigableList) { + return navigableList?.element.dataset.cardHotkeysDisabled === "true" + } + + #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/_closed.html.erb b/app/views/boards/show/_closed.html.erb index 5d2820399..d4712b832 100644 --- a/app/views/boards/show/_closed.html.erb +++ b/app/views/boards/show/_closed.html.erb @@ -1,5 +1,6 @@ <%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);", data: { + card_hotkeys_disabled: true, drag_and_strum_target: "container", collapsible_columns_target: "column", action: "focus->collapsible-columns#focusOnColumn" 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/boards/show/_not_now.html.erb b/app/views/boards/show/_not_now.html.erb index 66cd99519..ab84d04b8 100644 --- a/app/views/boards/show/_not_now.html.erb +++ b/app/views/boards/show/_not_now.html.erb @@ -1,5 +1,6 @@ <%= column_tag id: "not-now", name: "Not Now", drop_url: columns_card_drops_not_now_path("__id__"), class: "cards--on-deck", style: "--card-color: var(--color-card-complete);", data: { + card_hotkeys_disabled: true, collapsible_columns_target: "column", drag_and_strum_target: "container", action: "focus->collapsible-columns#focusOnColumn" 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 %>