Merge pull request #2211 from basecamp/card-nav-hotkeys
Add hotkeys for triaging cards in columns
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ module ColumnsHelper
|
||||
|
||||
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 @@ module ColumnsHelper
|
||||
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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }) %>
|
||||
@@ -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 }) %>
|
||||
@@ -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 %>
|
||||
<div class="flex flex-column flex-item-grow max-inline-size">
|
||||
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
|
||||
<span class="for-screen-reader"><%= card.title %></span>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<% if Current.user %>
|
||||
<% if Current.user && !local_assigns[:preview] %>
|
||||
<%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -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 }) %>
|
||||
Reference in New Issue
Block a user