diff --git a/app/assets/stylesheets/drag_and_drop.css b/app/assets/stylesheets/drag_and_drop.css new file mode 100644 index 000000000..3d5c93e87 --- /dev/null +++ b/app/assets/stylesheets/drag_and_drop.css @@ -0,0 +1,13 @@ +@layer components { + .drag-and-drop__dragged-item { + border: 2px solid #e0e7ff; + opacity: 0.7; + box-shadow: 0 4px 6px rgba(99, 102, 241, 0.15); + } + + .drag-and-drop__hover-container { + border: 2px dashed #818cf8; + background-color: #f5f3ff; + transition: all 0.2s ease; + } +} diff --git a/app/controllers/cards/drops_controller.rb b/app/controllers/cards/drops_controller.rb new file mode 100644 index 000000000..f5d78b9c0 --- /dev/null +++ b/app/controllers/cards/drops_controller.rb @@ -0,0 +1,46 @@ +class Cards::DropsController < ApplicationController + before_action :set_filter, :set_card, :set_drop_target + + def create + case @drop_target + when :considering + @card.reconsider + when :doing + @card.engage + end + + render_column_replacement + end + + private + VALID_DROP_TARGETS = %w[ considering doing ] + + def set_filter + @filter = Current.user.filters.from_params params.reverse_merge(**FilterScoped::DEFAULT_PARAMS).permit(*Filter::PERMITTED_PARAMS) + end + + def set_card + @card = Current.user.accessible_cards.find(params[:dropped_item_id]) + end + + def set_drop_target + if params[:drop_target].in?(VALID_DROP_TARGETS) + @drop_target = params[:drop_target].to_sym + else + head :bad_request + end + end + + def render_column_replacement + page_and_filter = page_and_filter_for @filter.with(engagement_status: @drop_target.to_s), per_page: CardsController::PAGE_SIZE + render turbo_stream: turbo_stream.replace("#{@drop_target}-cards", partial: "cards/index/engagement/#{@drop_target}", locals: page_and_filter.to_h) + end + + def page_and_filter_for(filter, per_page: nil) + cards = block_given? ? yield(filter.cards) : filter.cards + + OpenStruct.new \ + page: GearedPagination::Recordset.new(cards, per_page:).page(1), + filter: filter + end +end diff --git a/app/controllers/public/collections/card_previews_controller.rb b/app/controllers/public/collections/card_previews_controller.rb index 936ba0825..f7572616a 100644 --- a/app/controllers/public/collections/card_previews_controller.rb +++ b/app/controllers/public/collections/card_previews_controller.rb @@ -17,8 +17,7 @@ class Public::Collections::CardPreviewsController < ApplicationController when "closed-cards" @collection.cards.closed.recently_closed_first else - head :bad_request - Card.none + raise ActionController::BadRequest end end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 36d861cc7..725acdc82 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -5,3 +5,5 @@ import "controllers" import "actiontext-lexical" import "@rails/actiontext" + +window.fetch = Turbo.fetch // TODO: We need to make @rails/request.js use Turbo's fetch when it's present. diff --git a/app/javascript/controllers/drag_and_drop_controller.js b/app/javascript/controllers/drag_and_drop_controller.js new file mode 100644 index 000000000..90e253014 --- /dev/null +++ b/app/javascript/controllers/drag_and_drop_controller.js @@ -0,0 +1,81 @@ +import { Controller } from "@hotwired/stimulus" +import { post } from "@rails/request.js" +import { nextFrame } from "helpers/timing_helpers" + +export default class extends Controller { + static targets = [ "item", "container" ] + static values = { url: String } + static classes = [ "draggedItem", "hoverContainer" ] + + // Actions + + async dragStart(event) { + event.dataTransfer.effectAllowed = "move" + event.dataTransfer.dropEffect = "move" + event.dataTransfer.setData("37ui/move", event.target) + + await nextFrame() + this.dragItem = this.#itemContaining(event.target) + this.sourceContainer = this.#containerContaining(this.dragItem) + this.dragItem.classList.add(this.draggedItemClass) + } + + dragOver(event) { + event.preventDefault() + const container = this.#containerContaining(event.target) + this.#clearContainerHoverClasses() + + if (!container) { return } + + if (container !== this.sourceContainer) { + container.classList.add(this.hoverContainerClass) + } + } + + async drop(event) { + const container = this.#containerContaining(event.target) + + if (!container) { return } + + await this.#submitDropRequest(this.dragItem, container) + + this.wasDropped = true + } + + dragEnd() { + this.dragItem.classList.remove(this.draggedItemClass) + this.#clearContainerHoverClasses() + + if (this.wasDropped) { + this.dragItem.remove() + } + + this.sourceContainer = null + this.dragItem = null + this.wasDropped = false + } + + #itemContaining(element) { + return this.itemTargets.find(item => item.contains(element) || item === element) + } + + #containerContaining(element) { + return this.containerTargets.find(container => container.contains(element) || container === element) + } + + #clearContainerHoverClasses() { + this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass)) + } + + // Private + + async #submitDropRequest(item, container) { + const body = new FormData() + const id = item.dataset.id + const containerTarget = container.dataset.dropTarget + + body.append("dropped_item_id", id) + body.append("drop_target", containerTarget) + return post(this.urlValue, { body, headers: { Accept: "text/vnd.turbo-stream.html" } }) + } +} diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index 4590b5e99..2220e5a88 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -1,5 +1,5 @@ <% cache cacheable_preview_parts_for(card) do %> - <%= card_article_tag card, class: "card" do %> + <%= card_article_tag card, class: "card", draggable: true, data: { id: card.id, drag_and_drop_target: "item" } do %>
Nothing here
<% end %> - <% else %> -Nothing here
- <% end %>