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 %>
@@ -13,7 +13,7 @@ <%= card.title %> - <%= link_to collection_card_path(card.collection, card), class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> + <%= link_to collection_card_path(card.collection, card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %> <%= card.title %> <% end %>
diff --git a/app/views/cards/index.html.erb b/app/views/cards/index.html.erb index eba0049da..8d25e5c7e 100644 --- a/app/views/cards/index.html.erb +++ b/app/views/cards/index.html.erb @@ -13,9 +13,20 @@ <%= render "filters/settings", filter: @filter %> -
- <%= render "cards/index/engagement/considering", **@considering.to_h %> - <%= render "cards/index/engagement/doing", **@doing.to_h %> -
+<%= tag.div data: { + controller: "drag-and-drop", + drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item", + drag_and_drop_hover_container_class: "drag-and-drop__hover-container", + drag_and_drop_url_value: cards_drops_path(**@filter.as_params), + action: " + dragstart->drag-and-drop#dragStart + dragover->drag-and-drop#dragOver + drop->drag-and-drop#drop + dragend->drag-and-drop#dragEnd" } do %> +
+ <%= render "cards/index/engagement/considering", **@considering.to_h %> + <%= render "cards/index/engagement/doing", **@doing.to_h %> +
-<%= render "cards/index/engagement/closed", **@closed.to_h %> + <%= render "cards/index/engagement/closed", **@closed.to_h %> +<% end %> diff --git a/app/views/cards/index/engagement/_considering.html.erb b/app/views/cards/index/engagement/_considering.html.erb index 623bf8f17..9760cd7f9 100644 --- a/app/views/cards/index/engagement/_considering.html.erb +++ b/app/views/cards/index/engagement/_considering.html.erb @@ -1,4 +1,4 @@ -
+

Considering

diff --git a/app/views/cards/index/engagement/_doing.html.erb b/app/views/cards/index/engagement/_doing.html.erb index 3b6db623b..1c12ddcbe 100644 --- a/app/views/cards/index/engagement/_doing.html.erb +++ b/app/views/cards/index/engagement/_doing.html.erb @@ -1,4 +1,4 @@ -
+
<% if workflow = filter.single_workflow %> <%= render "cards/index/workflow_filter", workflow: workflow, filter: filter %> <% else %> @@ -7,13 +7,13 @@ <% end %> - <% if page.used? %> - <%= render partial: "cards/display/preview", collection: page.records, as: :card, cached: ->(card) { cacheable_preview_parts_for(card) } %> + <% if page.used? %> + <%= render partial: "cards/display/preview", collection: page.records, as: :card, cached: ->(card) { cacheable_preview_parts_for(card) } %> - <% unless page.last? %> - <%= cards_next_page_link "doing-cards", page: page, filter: filter %> + <% unless page.last? %> + <%= cards_next_page_link "doing-cards", page: page, filter: filter %> + <% end %> + <% else %> +

Nothing here

<% end %> - <% else %> -

Nothing here

- <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 25e96b5ab..3e9761dd4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,7 @@ Rails.application.routes.draw do namespace :cards do resources :previews + resources :drops end resources :cards do