Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: shift select multiple records #3492

Merged
merged 23 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-slate-200;
}
}

.selected-row {
@apply !bg-slate-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
5 changes: 5 additions & 0 deletions app/components/avo/items/panel_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<%= render Avo::PanelComponent.new(**args) do |c| %>
<% if @is_main_panel %>
<% c.with_after_description_slot do %>
<% if Avo.plugin_manager.installed?("avo_collaborate") %>
<%#= render Avo::Collaborate::ReactionPickerComponent.new(target: @resource.record, resource: @resource) %>
<% end %>
<% end %>
<% c.with_tools do %>
<% controls.each do |control| %>
<%= render_control control %>
Expand Down
7 changes: 6 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,12 @@
"w-4 h-4": @size.to_sym != :lg,
}),
data: {
action: "input->item-selector#toggle input->item-select-all#selectRow",
index: @index,
action: "
input->item-selector#toggle
input->item-select-all#selectRow
click->record-selector#toggleMultiple
",
item_select_all_target: "itemCheckbox",
tippy: "tooltip"
}
Expand Down
1 change: 1 addition & 0 deletions app/components/avo/row_selector_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
class Avo::RowSelectorComponent < Avo::BaseComponent
prop :floating, default: false
prop :size, default: :md
prop :index
end
3 changes: 3 additions & 0 deletions app/components/avo/views/resource_show_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
)%>
<% end %>
<% end %>
<div class="mt-4">
<%#= render Avo::Collaborate::TimelineComponent.new(resource: @resource) %>
</div>
<% if should_display_invalid_fields_errors? %>
<turbo-stream action="append" target="alerts">
<template>
Expand Down
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 @@ -28,6 +28,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 @@ -70,6 +71,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
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
174 changes: 174 additions & 0 deletions app/javascript/js/controllers/record_selector_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* 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()
}

#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)
}

disconnect() {
this.#removeEventListeners()
}

#removeEventListeners() {
console.log('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')
}
}

// Toggle multiple items
toggleMultiple(event) {
console.log('toggleMultiple', this.autoClicking, this.lastCheckedIndex, event.shiftKey, !this.lastCheckedIndex && !event.shiftKey)
// 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 && !event.shiftKey) {
if (!this.hasLastCheckedIndex) {
this.#setStartingIndex(event)

return
}

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

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

return
}
console.log('starting')

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
// console.log('autoClicking', this.autoClicking)

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

this.#resetEventListeners()

console.log('reached the end', this.lastCheckedIndex)
}

#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
1 change: 1 addition & 0 deletions lib/avo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def mount_engines
mount Avo::Dashboards::Engine, at: "/dashboards" if defined?(Avo::Dashboards::Engine)
mount Avo::Pro::Engine, at: "/avo-pro" if defined?(Avo::Pro::Engine)
mount Avo::Kanban::Engine, at: "/boards" if defined?(Avo::Kanban::Engine)
mount Avo::Collaborate::Engine, at: "/collaborate" if defined?(Avo::Collaborate::Engine)
}
end

Expand Down
2 changes: 1 addition & 1 deletion lib/avo/concerns/has_resource_stimulus_controllers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def get_stimulus_controllers
when :new, :edit
controllers << "resource-edit"
when :index
controllers << "resource-index"
controllers << "resource-index record-selector"
end

controllers << self.class.stimulus_controllers
Expand Down
2 changes: 2 additions & 0 deletions spec/dummy/app/avo/resources/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class Avo::Resources::Project < Avo::BaseResource
query.unscoped
}


def fields
field :id, as: :id, link_to_record: true
field :status,
Expand Down Expand Up @@ -74,6 +75,7 @@ def fields
end

field :users, as: :has_and_belongs_to_many

field :comments, as: :has_many, searchable: true
field :even_reviews, as: :has_many, for_attribute: :reviews, scope: -> { query.where("reviews.id % 2 = ?", "0") }
field :reviews, as: :has_many
Expand Down
Loading
Loading