From d3a5e1ff619e665311ed710fe906e3abb7c7c0d1 Mon Sep 17 00:00:00 2001
From: Felix Eckhofer <felix@eckhofer.com>
Date: Fri, 20 Dec 2024 02:17:48 +0100
Subject: [PATCH] Allow users to register themselves as volunteers
 ("candidates")

---
 app/controllers/candidates_controller.rb      | 59 +++++++++++++++++++
 .../controllers/bothhands_controller.js       | 19 ++++++
 app/models/candidate.rb                       |  6 ++
 app/models/session.rb                         |  1 +
 app/models/user.rb                            |  1 +
 app/views/candidates/_user_avatar.html.erb    | 17 ++++++
 app/views/sessions/_session.html.erb          | 29 ++++++++-
 config/routes.rb                              |  1 +
 .../20241118161733_create_candidates.rb       | 11 ++++
 db/schema.rb                                  | 14 ++++-
 test/fixtures/candidates.yml                  |  7 +++
 test/models/candidate_test.rb                 |  7 +++
 12 files changed, 170 insertions(+), 2 deletions(-)
 create mode 100644 app/controllers/candidates_controller.rb
 create mode 100644 app/javascript/controllers/bothhands_controller.js
 create mode 100644 app/models/candidate.rb
 create mode 100644 app/views/candidates/_user_avatar.html.erb
 create mode 100644 db/migrate/20241118161733_create_candidates.rb
 create mode 100644 test/fixtures/candidates.yml
 create mode 100644 test/models/candidate_test.rb

diff --git a/app/controllers/candidates_controller.rb b/app/controllers/candidates_controller.rb
new file mode 100644
index 0000000..61f1c3c
--- /dev/null
+++ b/app/controllers/candidates_controller.rb
@@ -0,0 +1,59 @@
+require 'icalendar/tzinfo'
+
+class CandidatesController < ApplicationController
+  def create
+    @session = Session.find_by(ref_id: params[:session_ref_id])
+    @conference = Conference.find_by(slug: params[:conference_slug])
+    @user = User.find(session[:user_id])
+    @candidate = Candidate.find_or_initialize_by(user: @user, session: @session).tap do |candidate_|
+      candidate_.note = params[:note]
+      candidate_.save!
+    end
+
+    if @candidate
+      Rails.logger.debug("Saved candidate #{@candidate.inspect}")
+      #@session = Session.find_by(ref_id: params[:session_ref_id])
+      Turbo::StreamsChannel.broadcast_replace_to(
+        @session.conference,
+        target: helpers.dom_id(@session),
+        partial: "sessions/session",
+        locals: { session: @session }
+      )
+      flash.now[:success] = 'User assigned successfully.'
+      respond_to do |format|
+        format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }) }
+        format.html { redirect_to conference_session_path(@session.conference, @session), success: 'User assigned successfully.' }
+      end
+    else
+      flash.now[:alert] = 'Failed to record candidate.'
+      respond_to do |format|
+        format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }), status: :unprocessable_entity }
+        format.html { render :show, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def destroy
+    @candidate = Candidate.find(params[:id])
+    @session = @candidate.session
+
+    if @candidate&.destroy
+      Rails.logger.debug("destroyed candidate entry")
+      Turbo::StreamsChannel.broadcast_replace_later_to(
+        @session.conference,
+        target: helpers.dom_id(@session),
+        partial: "sessions/session",
+        locals: { session: @session }
+      )
+      respond_to do |format|
+        format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }) }
+        format.html { redirect_to conference_session_path(@session.conference, @session), notice: 'Candidate removed successfully.' }
+      end
+    else
+      respond_to do |format|
+        format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.dom_id(@session), partial: "sessions/session", locals: { session: @session }), status: :unprocessable_entity }
+        format.html { redirect_to conference_session_path(@session.conference, @session), alert: 'Failed to remove candidate.' }
+      end
+    end
+  end
+end
diff --git a/app/javascript/controllers/bothhands_controller.js b/app/javascript/controllers/bothhands_controller.js
new file mode 100644
index 0000000..e818f57
--- /dev/null
+++ b/app/javascript/controllers/bothhands_controller.js
@@ -0,0 +1,19 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+  static targets = ["form", "link"];
+
+  submitWithPrompt(event) {
+    event.preventDefault();
+
+    const reason = prompt("Why are you especially qualified? (Please keep it short, thanks)");
+    if (reason !== null && reason.trim() !== "") {
+      const input = document.createElement("input");
+      input.type = "hidden";
+      input.name = "note";
+      input.value = reason;
+      this.formTarget.appendChild(input);
+      this.formTarget.requestSubmit();
+    }
+  }
+}
diff --git a/app/models/candidate.rb b/app/models/candidate.rb
new file mode 100644
index 0000000..4d402d1
--- /dev/null
+++ b/app/models/candidate.rb
@@ -0,0 +1,6 @@
+class Candidate < ApplicationRecord
+  belongs_to :user
+  belongs_to :session
+
+  validates :user_id, uniqueness: { scope: :session_id, message: "has already recorded interest for this session" }
+end
diff --git a/app/models/session.rb b/app/models/session.rb
index b45ecce..28e5bbf 100644
--- a/app/models/session.rb
+++ b/app/models/session.rb
@@ -3,6 +3,7 @@ class Session < ApplicationRecord
   belongs_to :stage
   has_many :assignments
   has_many :users, through: :assignments
+  has_many :candidates
   has_many :session_speakers, dependent: :destroy
   has_many :speakers, through: :session_speakers
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 5918786..c6a2a7a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,5 +1,6 @@
 class User < ApplicationRecord
   has_many :assignments
+  has_many :candidates
 
   has_secure_password
 
diff --git a/app/views/candidates/_user_avatar.html.erb b/app/views/candidates/_user_avatar.html.erb
new file mode 100644
index 0000000..04f1a3b
--- /dev/null
+++ b/app/views/candidates/_user_avatar.html.erb
@@ -0,0 +1,17 @@
+<% user = candidate.user %>
+<% session = candidate.session %>
+<span class="gap-x-0.5 rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10" style="background-color: <%= user.avatar_color %>" title="<%= user.name %>">
+  <span style="color: <%= user.text_color %>"><%= user.name %></span>
+  <button type="button" class="group relative -mr-1 size-3.5 rounded-sm hover:bg-gray-500/20">
+    <%= link_to conference_session_assignments_path(session.conference, session, user_id: user.id), data: { turbo_method: :post, turbo_frame: dom_id(session) } do %>
+      <span class="sr-only">Add</span>
+      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" stroke-width="1" class="size-3.5 stroke-gray-600/50 group-hover:stroke-gray-600/75 fill-gray-600/50" style="stroke: <%= user.text_color %>; fill: <%= user.text_color %>">
+        <path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
+      </svg>
+      <span class="absolute -inset-1"></span>
+    <% end %>
+  </button>
+</span>
+<% if candidate.note %>
+ <span class="relative ml-1.5"><span class="max-w-60 max-h-14 overflow-scroll bg-gray-600 text-white text-xs font-medium px-2 py-1 rounded-md relative inline-block"><%= candidate.note %></span><span class="w-0 h-0 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-gray-600 absolute left-[-5px] top-[20%] transform -translate-y-1/2"></span> </span>
+<% end %>
diff --git a/app/views/sessions/_session.html.erb b/app/views/sessions/_session.html.erb
index a0eeadb..e34e709 100644
--- a/app/views/sessions/_session.html.erb
+++ b/app/views/sessions/_session.html.erb
@@ -5,7 +5,7 @@
       <small class="text-2xs uppercase font-light bg-black/10 rounded-sm p-1 mr-1 lang-<%= session.language %>"><%= session.language %></small>
       <%= link_to session.title, session.url, target: "_top" %>
     </h4>
-    <div>
+    <div class="relative">
       <span class="session-time text-xs mr-1">
         <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="inline-block size-4 stroke-slate-500">
           <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
@@ -20,6 +20,24 @@
         </svg>
         <%= session.stage.name %>
       </span>
+      <% if logged_in? %>
+        <span class="absolute top-0 right-0 text-3xl">
+        <% if candidate = session.candidates.find_by(user_id: current_user.id) %>
+          <%= link_to conference_session_candidate_path(session.conference, session, candidate), class: "pr-1", title: "Withdraw", aria_label: "Withdraw", data: { turbo_method: :delete, turbo_frame: dom_id(session) }, method: :delete do %>
+            🙅
+          <% end %>
+        <% else %>
+          <%= link_to conference_session_candidates_path(session.conference, session), class: "pr-1", title: "Volunteer Myself", aria_label: "Volunteer Myself", data: { turbo_method: :post, turbo_frame: dom_id(session) } do %>
+            🙋
+          <% end %>
+          <%= form_with url: conference_session_candidates_path(session.conference, session), method: :post, class: "inline", data: { turbo_frame: dom_id(session), controller: "bothhands", bothhands_target: "form" } do |form| %>
+            <%= link_to conference_session_candidates_path(session.conference, session), class: "pr-1", title: "Volunteer (with special skills)", aria_label: "Volunteer (with special skills)", data: { action: "bothhands#submitWithPrompt", turbo_prefetch:"false" } do %>
+                🙌
+            <% end %>
+          <% end %>
+        <% end %>
+        </span>
+      <% end %>
     </div>
     <div>
     <ul class="flex flex-wrap text-xs gap-1">
@@ -57,6 +75,15 @@
         <%= @assignment.errors.full_messages.join(", ") %>
       </div>
     <% end %>
+    <small>candidates (<%= session.candidates.length %>)</small>
+    <ul class="inline-flex flex-wrap gap-1 my-1">
+      <% session.candidates.sort_by { |cand| cand.note.nil? ? 1:0 }.each do |candidate| %>
+        <li class="inline-flex items-end">
+          <span class="assigned-user"><%= render partial: 'candidates/user_avatar', locals: { candidate: } %></span>
+        </li>
+      <% end %>
+    </ul>
+    <hr>
     <small>unassigned (<%= unassigned_users.length %>)</small>
     <%= render partial: 'assignments/filteredlist', locals: { session: session, users: unassigned_users } %>
   </div>
diff --git a/config/routes.rb b/config/routes.rb
index cca0657..0483a6e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -25,6 +25,7 @@ Rails.application.routes.draw do
     get ':date', action: :show, on: :member, as: :date, date: /\d{4}-\d{2}-\d{2}/
     resources :sessions, param: :ref_id do
       resources :assignments, only: [:create, :destroy]
+      resources :candidates, only: [:create, :destroy]
     end
     resources :speakers, param: :ref_id
   end
diff --git a/db/migrate/20241118161733_create_candidates.rb b/db/migrate/20241118161733_create_candidates.rb
new file mode 100644
index 0000000..38b6177
--- /dev/null
+++ b/db/migrate/20241118161733_create_candidates.rb
@@ -0,0 +1,11 @@
+class CreateCandidates < ActiveRecord::Migration[7.1]
+  def change
+    create_table :candidates do |t|
+      t.references :user, null: false, foreign_key: true
+      t.references :session, null: false, foreign_key: true
+      t.string :note, null: true
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 801a3e8..bcd53dd 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[7.1].define(version: 2024_05_27_191332) do
+ActiveRecord::Schema[7.1].define(version: 2024_11_18_161733) do
   create_table "assignments", force: :cascade do |t|
     t.integer "user_id", null: false
     t.integer "session_id", null: false
@@ -20,6 +20,16 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_27_191332) do
     t.index ["user_id"], name: "index_assignments_on_user_id"
   end
 
+  create_table "candidates", force: :cascade do |t|
+    t.integer "user_id", null: false
+    t.integer "session_id", null: false
+    t.string "note"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["session_id"], name: "index_candidates_on_session_id"
+    t.index ["user_id"], name: "index_candidates_on_user_id"
+  end
+
   create_table "conferences", force: :cascade do |t|
     t.string "name"
     t.datetime "starts_at"
@@ -270,6 +280,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_27_191332) do
 
   add_foreign_key "assignments", "sessions"
   add_foreign_key "assignments", "users"
+  add_foreign_key "candidates", "sessions"
+  add_foreign_key "candidates", "users"
   add_foreign_key "relevant_stages", "conferences"
   add_foreign_key "relevant_stages", "stages"
   add_foreign_key "revision_sets", "conferences"
diff --git a/test/fixtures/candidates.yml b/test/fixtures/candidates.yml
new file mode 100644
index 0000000..2d3d603
--- /dev/null
+++ b/test/fixtures/candidates.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+  note: MyString
+
+two:
+  note: MyString
diff --git a/test/models/candidate_test.rb b/test/models/candidate_test.rb
new file mode 100644
index 0000000..f8edf6f
--- /dev/null
+++ b/test/models/candidate_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class CandidateTest < ActiveSupport::TestCase
+  # test "the truth" do
+  #   assert true
+  # end
+end
-- 
GitLab