Skip to content

Commit

Permalink
feature: shift select multiple records (#3492)
Browse files Browse the repository at this point in the history
* feature: shift select multiple records

* wip

* add empty spec

* tweak select all checkbox

* update colors

* remove collaboration code

* remove code

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix `spec/features/avo/record_selector_spec.rb`

* fix action data indentation & tests

* add test

* lint

* pro check

* rm pro test

---------

Co-authored-by: Paul Bob <[email protected]>
Co-authored-by: Paul Bob <[email protected]>
  • Loading branch information
3 people authored Jan 9, 2025
1 parent ca1d2c6 commit f70b5d2
Show file tree
Hide file tree
Showing 16 changed files with 262 additions and 8 deletions.
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ GEM
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
flay (2.13.3)
erubi (~> 1.10)
Expand Down Expand Up @@ -412,6 +413,8 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.1-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
Expand Down Expand Up @@ -676,6 +679,7 @@ GEM
zeitwerk (2.7.1)

PLATFORMS
arm64-darwin-23
x86_64-linux

DEPENDENCIES
Expand Down
10 changes: 10 additions & 0 deletions app/assets/stylesheets/avo.base.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,13 @@ trix-editor {
dialog#turbo-confirm {
@apply bg-transparent;
}

.shift-pressed {
& .highlighted-row {
@apply !bg-neutral-200;
}
}

.selected-row {
@apply !bg-neutral-100;
}
5 changes: 3 additions & 2 deletions app/components/avo/index/resource_table_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def generate_table_row_components
table_row_components = []

# Loop through each resource in @resources
@resources.each do |resource|
@resources.each_with_index do |resource, index|
# Get fields for the current resource and concat them to the @header_fields
row_fields = resource.get_fields(reflection: @reflection, only_root: true)
header_fields.concat row_fields
Expand All @@ -79,7 +79,8 @@ def generate_table_row_components
reflection: @reflection,
parent_record: @parent_record,
parent_resource: @parent_resource,
actions: @actions
actions: @actions,
index:
)
end

Expand Down
5 changes: 3 additions & 2 deletions app/components/avo/index/table_row_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
id: "#{self.class.to_s.underscore}_#{@resource.record.to_param}",
class: class_names("bg-white hover:bg-gray-50 hover:shadow-row z-21 border-b", {"cursor-pointer": click_row_to_view_record}),
data: {
index: @index,
component_name: self.class.to_s.underscore,
resource_name: @resource.class.to_s,
record_id: @resource.record.id,
Expand All @@ -15,9 +16,9 @@
**(try(:drag_reorder_item_data_attributes) || {}),
} do %>
<% if @resource.record_selector %>
<td class="w-10">
<td class="item-selector-cell w-10">
<div class="flex justify-center h-full">
<%= render Avo::RowSelectorComponent.new floating: false %>
<%= render Avo::RowSelectorComponent.new floating: false, index: @index %>
</div>
</td>
<% end %>
Expand Down
1 change: 1 addition & 0 deletions app/components/avo/index/table_row_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Avo::Index::TableRowComponent < Avo::BaseComponent
prop :actions
prop :fields
prop :header_fields
prop :index

def resource_controls_component
Avo::Index::ResourceControlsComponent.new(
Expand Down
3 changes: 2 additions & 1 deletion app/components/avo/row_selector_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"w-4 h-4": @size.to_sym != :lg,
}),
data: {
action: "input->item-selector#toggle input->item-select-all#selectRow",
index: @index,
action: data_action,
item_select_all_target: "itemCheckbox",
tippy: "tooltip"
}
Expand Down
11 changes: 11 additions & 0 deletions app/components/avo/row_selector_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,15 @@
class Avo::RowSelectorComponent < Avo::BaseComponent
prop :floating, default: false
prop :size, default: :md
prop :index

def data_action
data = "input->item-selector#toggle input->item-select-all#selectRow"

if Avo.plugin_manager.installed?(:avo_pro)
data += " click->record-selector#toggleMultiple"
end

data
end
end
21 changes: 21 additions & 0 deletions app/javascript/avo.base.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ function isMac() {
document.body.classList.remove('os-mac')
}
}

// Add the shift-pressed class to the body when the shift key is pressed
document.addEventListener('keydown', (event) => {
if (event.shiftKey) {
document.body.classList.add('shift-pressed')
}
})
// Remove the shift-pressed class from the body when the shift key is released
document.addEventListener('keyup', (event) => {
if (!event.shiftKey) {
document.body.classList.remove('shift-pressed')
}
})

function initTippy() {
tippy('[data-tippy="tooltip"]', {
theme: 'light',
Expand Down Expand Up @@ -69,6 +83,13 @@ window.initTippy = initTippy

ActiveStorage.start()

document.addEventListener('turbo:before-stream-render', () => {
// We're using the timeout feature so we can fake the `turbo:after-stream-render` event
setTimeout(() => {
initTippy()
}, 1)
})

document.addEventListener('turbo:load', () => {
initTippy()
isMac()
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/js/controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import PanelRefreshController from './controllers/fields/panel_refresh_controlle
import PerPageController from './controllers/per_page_controller'
import PreviewController from './controllers/preview_controller'
import ProgressBarFieldController from './controllers/fields/progress_bar_field_controller'
import RecordSelectorController from './controllers/record_selector_controller'
import ReloadBelongsToFieldController from './controllers/fields/reload_belongs_to_field_controller'
import ResourceEditController from './controllers/resource_edit_controller'
import ResourceIndexController from './controllers/resource_index_controller'
Expand Down Expand Up @@ -72,6 +73,7 @@ application.register('modal', ModalController)
application.register('multiple-select-filter', MultipleSelectFilterController)
application.register('per-page', PerPageController)
application.register('preview', PreviewController)
application.register('record-selector', RecordSelectorController)
application.register('resource-edit', ResourceEditController)
application.register('resource-index', ResourceIndexController)
application.register('resource-show', ResourceShowController)
Expand Down
28 changes: 27 additions & 1 deletion app/javascript/js/controllers/item_select_all_controller.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller } from '@hotwired/stimulus'
import { AttributeObserver, Controller } from '@hotwired/stimulus'

export default class extends Controller {
static targets = [
Expand All @@ -17,6 +17,32 @@ export default class extends Controller {

connect() {
this.resourceName = this.element.dataset.resourceName
this.selectedResourcesObserver = new AttributeObserver(this.element, 'data-selected-resources', this)
this.selectedResourcesObserver.start()
}

elementAttributeValueChanged(element) {
// Check if anything is selected.
const selectedResources = JSON.parse(element.dataset.selectedResources)
// If all are selected, mark the checkbox as checked.
const rowCount = this.element.querySelectorAll('tbody tr').length
// Reset the checkbox
this.checkboxTarget.indeterminate = false
this.checkboxTarget.checked = false

if (selectedResources.length === rowCount) {
this.checkboxTarget.checked = true
} else if (selectedResources.length === 0) {
// If nothing is selected, mark the checkbox as unchecked.
this.checkboxTarget.checked = false
} else if (selectedResources.length > 0 && selectedResources.length < rowCount) {
// If some are selected, mark the checkbox as indeterminate.
this.checkboxTarget.indeterminate = true
}
}

disconnect() {
this.selectedResourcesObserver.stop()
}

toggle(event) {
Expand Down
6 changes: 6 additions & 0 deletions app/javascript/js/controllers/item_selector_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,16 @@ export default class extends Controller {

ids.push(this.resourceId)

// Mark the row as selected
this.element.closest('tr').classList.add('selected-row')

this.currentIds = ids
}

removeFromSelected() {
// Un-mark the row as selected
this.element.closest('tr').classList.remove('selected-row')

this.currentIds = this.currentIds.filter(
(item) => item.toString() !== this.resourceId,
)
Expand Down
162 changes: 162 additions & 0 deletions app/javascript/js/controllers/record_selector_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* eslint-disable radix */
import { Controller } from '@hotwired/stimulus'
import difference from 'lodash/difference'
import range from 'lodash/range'

// Hopefully we'll never need to touch this code again
export default class extends Controller {
lastCheckedIndex = null

autoClicking = false

get itemSelectorCells() {
return document.querySelectorAll('.item-selector-cell')
}

get hasLastCheckedIndex() {
return this.lastCheckedIndex !== null
}

connect() {
this.#addEventListeners()
}

disconnect() {
this.#removeEventListeners()
}

// Toggle multiple items
toggleMultiple(event) {
// this check is to prevent the method from running twice when the script clicks the checkboxes
if (this.autoClicking) {
return
}

// If there's no last checked index and the shift key isn't pressed, set the starting index
if (!this.hasLastCheckedIndex) {
this.#setStartingIndex(event)

return
}

// Ignore action if shift key is not pressed
if (!event.shiftKey) {
this.#resetLastCheckedIndex()

return
}

const currentIndex = parseInt(event.target.dataset.index)
const theRange = difference(range(this.lastCheckedIndex, currentIndex), [this.lastCheckedIndex, currentIndex])

// Set the autoClicking flag to true to prevent the method from running twice
this.autoClicking = true

// Get the state of the target checkbox
const state = event.target.checked

// Loop through the range of rows and toggle the checkboxes
theRange.forEach((index) => {
const checkbox = document.querySelector(`input[type="checkbox"][data-index="${index}"]`)

// Toggle the checkbox if it's not in the same state as the target checkbox
if (checkbox.checked !== state) {
checkbox.click()
}
})

this.#setEndingIndex(event)

// Reset the autoClicking flag
this.autoClicking = false

// Reset the last checked index
this.#resetLastCheckedIndex()

this.#resetEventListeners()
}

#resetEventListeners() {
this.#removeEventListeners()
this.#addEventListeners()
}

#addEventListeners() {
// Attach event listeners to item selector cells
Array.from(this.itemSelectorCells).forEach((itemSelectorCell) => {
itemSelectorCell.addEventListener('mouseenter', this.#selectorMouseenterHandler.bind(this))
itemSelectorCell.addEventListener('mouseleave', this.#selectorMouseleaveHandler.bind(this))
})

// Attach event listeners to keyboard events
document.addEventListener('keydown', this.#keydownHandler)
document.addEventListener('keyup', this.#keyupHandler)
}

#removeEventListeners() {
// Remove event listeners
Array.from(this.itemSelectorCells).forEach((itemSelectorCell) => {
itemSelectorCell.removeEventListener('mouseenter', this.#selectorMouseenterHandler.bind(this))
itemSelectorCell.removeEventListener('mouseleave', this.#selectorMouseleaveHandler.bind(this))
})
document.removeEventListener('keydown', this.#keydownHandler.bind(this))
document.removeEventListener('keyup', this.#keyupHandler.bind(this))
}

#selectorMouseenterHandler(event) {
// Add the highlighted-row class to the row that the mouse is over
event.target.closest('tr').classList.add('highlighted-row')
if (this.lastCheckedIndex) {
// Highlight the range of rows between the last checked index and the current index
this.#highlightRange(this.lastCheckedIndex, parseInt(event.target.closest('tr').dataset.index))
}
}

#selectorMouseleaveHandler(event) {
// Remove the highlighted-row class from the row that the mouse is over
event.target.closest('tr').classList.remove('highlighted-row')
// Remove the highlighted-row class from all rows
document.querySelectorAll('tr[data-index]').forEach((tr) => {
tr.classList.remove('highlighted-row')
})
}

// Highlight the range of rows between the start index and the end index
#highlightRange(startIndex, endIndex) {
const theRange = difference(range(startIndex, endIndex))
theRange.forEach((index) => {
const tr = document.querySelector(`tr[data-index="${index}"]`)
tr.classList.add('highlighted-row')
})
}

// Add the shift-pressed class to the body when the shift key is pressed
#keydownHandler(event) {
if (event.shiftKey) {
document.body.classList.add('shift-pressed')
}
}

// Remove the shift-pressed class from the body when the shift key is released
#keyupHandler(event) {
if (!event.shiftKey) {
document.body.classList.remove('shift-pressed')
}
}

#resetLastCheckedIndex() {
this.lastCheckedIndex = null
}

// Set the starting index
#setStartingIndex(event) {
this.lastCheckedIndex = parseInt(event.target.dataset.index)
event.target.closest('tr').classList.add('highlighted-row')
}

// Set the ending index
#setEndingIndex(event) {
this.lastCheckedIndex = parseInt(event.target.dataset.index)
event.target.closest('tr').classList.add('highlighted-row')
}
}
8 changes: 7 additions & 1 deletion app/javascript/js/controllers/table_row_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ export default class extends Controller {
return
}

// We won't navigate if shift is pressed. That is usually used to select multiple rows.
const isShiftPressed = event.shiftKey
// We won't navigate if the user clicks on the item selector cell
const isItemSelector = event.target.closest('.item-selector-cell')
// We won't navigate if the user clicks on a link or button
const isLinkOrButton = event.target.closest('a, button')
// We won't navigate if the user clicks on a checkbox
const isCheckbox = event.target.closest('input[type="checkbox"]')

if (isLinkOrButton || isCheckbox) {
if (isShiftPressed || isLinkOrButton || isCheckbox || isItemSelector) {
return // Don't navigate if a link or button is clicked
}

Expand Down
Loading

0 comments on commit f70b5d2

Please sign in to comment.