From 9f6b4b341cf31c12d4b52de72ec0d0b8289a0e34 Mon Sep 17 00:00:00 2001 From: SHAMI_TOMITA Date: Wed, 28 Jun 2023 16:34:04 -0400 Subject: [PATCH] Co-authored-by: Eric Halverson Co-authored-by: James Garcia add datatable checkboxes to volunteer#index view for bulk assignment add system test for volunteers/index --- 3 | 503 ++++++++++++++++++ app/assets/stylesheets/application.scss | 1 + app/assets/stylesheets/pages/volunteers.scss | 13 + app/assets/stylesheets/shared/header.scss | 23 + .../supervisor_volunteers_controller.rb | 56 +- app/controllers/volunteers_controller.rb | 1 + app/javascript/application.js | 2 + app/javascript/src/dashboard.js | 38 +- app/models/volunteer.rb | 2 + app/policies/supervisor_volunteer_policy.rb | 4 + app/views/supervisors/index.html.erb | 6 +- app/views/volunteers/index.html.erb | 102 +++- config/routes.rb | 3 + db/schema.rb | 7 +- package.json | 2 + spec/requests/supervisor_volunteers_spec.rb | 115 ++++ spec/system/volunteers/index_spec.rb | 48 ++ yarn.lock | 23 + 18 files changed, 918 insertions(+), 31 deletions(-) create mode 100644 3 create mode 100644 app/assets/stylesheets/shared/header.scss diff --git a/3 b/3 new file mode 100644 index 0000000000..e45619a65e --- /dev/null +++ b/3 @@ -0,0 +1,503 @@ +/* global alert */ +/* global $ */ +const AsyncNotifier = require('../src/async_notifier') +let pageNotifier + +const defineCaseContactsTable = function () { + $('table#case_contacts').DataTable( + { + scrollX: true, + searching: false, + order: [[0, 'desc']] + } + ) +} + +$(() => { // JQuery's callback for the DOM loading + const asyncNotificationsElement = $('#async-notifications') + + if (asyncNotificationsElement.length) { + pageNotifier = new AsyncNotifier(asyncNotificationsElement) + } + + $.fn.dataTable.ext.search.push( + function (settings, data, dataIndex) { + if (settings.nTable.id !== 'casa-cases') { + return true + } + + const statusArray = [] + const assignedToVolunteerArray = [] + const assignedToMoreThanOneVolunteerArray = [] + const assignedToTransitionYouthArray = [] + const caseNumberPrefixArray = [] + + $('.status-options').find('input[type="checkbox"]').each(function () { + if ($(this).is(':checked')) { + statusArray.push($(this).data('value')) + } + }) + + $('.assigned-to-volunteer-options').find('input[type="checkbox"]').each(function () { + if ($(this).is(':checked')) { + assignedToVolunteerArray.push($(this).data('value')) + } + }) + + $('.more-than-one-volunteer-options').find('input[type="checkbox"]').each(function () { + if ($(this).is(':checked')) { + assignedToMoreThanOneVolunteerArray.push($(this).data('value')) + } + }) + + $('.transition-youth-options').find('input[type="checkbox"]').each(function () { + if ($(this).is(':checked')) { + assignedToTransitionYouthArray.push($(this).data('value')) + } + }) + + $('.case-number-prefix-options').find('input[type="checkbox"]').each(function () { + if ($(this).is(':checked')) { + caseNumberPrefixArray.push($(this).data('value')) + } + }) + + const possibleCaseNumberPrefixes = ['CINA', 'TPR'] + const status = data[3] + const assignedToVolunteer = (data[5] !== '' && data[5].split(',').length >= 1) ? 'Yes' : 'No' + const assignedToMoreThanOneVolunteer = (data[5] !== '' && data[5].split(',').length > 1) ? 'Yes' : 'No' + const assignedToTransitionYouth = data[4] + const caseNumberPrefix = possibleCaseNumberPrefixes.includes(data[0].split('-')[0]) ? data[0].split('-')[0] : 'None' + + return statusArray.includes(status) && + assignedToVolunteerArray.includes(assignedToVolunteer) && + assignedToMoreThanOneVolunteerArray.includes(assignedToMoreThanOneVolunteer) && + assignedToTransitionYouthArray.includes(assignedToTransitionYouth) && + caseNumberPrefixArray.includes(caseNumberPrefix) + } + ) + + const handleAjaxError = e => { + console.error(e) + if (e.responseJSON && e.responseJSON.error) { + alert(e.responseJSON.error) + } else { + const responseErrorMessage = e.response.statusText + ? `\n${e.response.statusText}\n` + : '' + + alert(`Sorry, try that again?\n${responseErrorMessage}\nIf you're seeing a problem, please fill out the Report A Site Issue + link to the bottom left near your email address.`) + } + } + + // Enable all data tables on dashboard but only filter on volunteers table + const editSupervisorPath = id => `/supervisors/${id}/edit` + const editVolunteerPath = id => `/volunteers/${id}/edit` + const impersonateVolunteerPath = id => `/volunteers/${id}/impersonate` + const casaCasePath = id => `/casa_cases/${id}` + const volunteersTable = $('table#volunteers').DataTable({ + autoWidth: false, + stateSave: true, + initComplete: function (settings, json) { + this.api().columns().every(function (index) { + const columnVisible = this.visible() + return $('#visibleColumns input[data-column="' + index + '"]').prop('checked', columnVisible) + }) + }, + stateSaveCallback: function (settings, data) { + $.ajax({ + url: '/preference_sets/table_state_update/' + settings.nTable.id + '_table', + + data: { + table_state: JSON.stringify(data) + }, + dataType: 'json', + type: 'POST', + error: function (jqXHR, textStatus, errorMessage) { + console.error(errorMessage) + pageNotifier.notify('Error while saving preferences', 'error') + } + }) + }, + stateSaveParams: function (settings, data) { + data.search.search = '' + return data + }, + stateLoadCallback: function (settings, callback) { + $.ajax({ + url: '/preference_sets/table_state/' + settings.nTable.id + '_table', + dataType: 'json', + type: 'GET', + success: function (json) { + callback(json) + } + }) + }, + order: [[7, 'desc']], + select: { + style: 'multi' + }, + columns: [ + { + data: 'id', + targets: 0, + searchable: false, + orderable: false, + checkboxes: { + selectRow: true, + stateSave: false + } + }, + { + name: 'display_name', + render: (data, type, row, meta) => { + return ` + Name + + ${row.display_name || row.email} + + ` + } + }, + { + name: 'email', + render: (data, type, row, meta) => row.email + }, + { + className: 'supervisor-column', + name: 'supervisor_name', + render: (data, type, row, meta) => { + return row.supervisor.id + ? ` + Supervisor + + ${row.supervisor.name} + + ` + : '' + } + }, + { + name: 'active', + render: (data, type, row, meta) => { + return ` + Status + ${row.active === 'true' ? 'Active' : 'Inactive'} + ` + }, + searchable: false + }, + { + name: 'has_transition_aged_youth_cases', + render: (data, type, row, meta) => { + return ` + Assigned to Transitioned Aged Youth + ${row.has_transition_aged_youth_cases === 'true' ? 'Yes 🦋' : 'No 🐛'}` + }, + searchable: false + }, + { + name: 'casa_cases', + render: (data, type, row, meta) => { + const links = row.casa_cases.map(casaCase => { + return ` + ${casaCase.case_number} + ` + }) + const caseNumbers = ` + Case Number(s) + ${links.join(', ')} + ` + return caseNumbers + }, + orderable: false + }, + { + name: 'most_recent_attempt_occurred_at', + render: (data, type, row, meta) => { + return row.most_recent_attempt.case_id + ? ` + Last Attempted Contact + + ${row.most_recent_attempt.occurred_at} + + ` + : 'None ❌' + }, + searchable: false + }, + { + name: 'contacts_made_in_past_days', + render: (data, type, row, meta) => { + return ` + Contacts + ${row.contacts_made_in_past_days} + ` + }, + searchable: false + }, + { + name: 'hours_spent_in_days', + render: (data, type, row, meta) => { + return ` + Hours spent in last 30 days + ${row.hours_spent_in_days} + ` + }, + searchable: false + }, + { + name: 'has_any_extra_languages ', + render: (data, type, row, meta) => { + const languages = row.extra_languages.map(x => x.name).join(', ') + return row.extra_languages.length > 0 ? `🌎` : '' + }, + searchable: false + }, + { + name: 'actions', + orderable: false, + render: (data, type, row, meta) => { + return ` + Actions + + Edit + + + Impersonate + + ` + }, + searchable: false + } + ], + processing: true, + serverSide: true, + ajax: { + url: $('table#volunteers').data('source'), + type: 'POST', + data: function (d) { + const supervisorOptions = $('.supervisor-options input:checked') + const supervisorFilter = Array.from(supervisorOptions).map(option => option.dataset.value) + + const statusOptions = $('.status-options input:checked') + const statusFilter = Array.from(statusOptions).map(option => JSON.parse(option.dataset.value)) + + const transitionYouthOptions = $('.transition-youth-options input:checked') + const transitionYouthFilter = Array.from(transitionYouthOptions).map(option => JSON.parse(option.dataset.value)) + + const extraLanguageOptions = $('.extra-language-options input:checked') + const extraLanguageFilter = Array.from(extraLanguageOptions).map(option => JSON.parse(option.dataset.value)) + return $.extend({}, d, { + additional_filters: { + supervisor: supervisorFilter, + active: statusFilter, + transition_aged_youth: transitionYouthFilter, + extra_languages: extraLanguageFilter + } + }) + }, + error: handleAjaxError, + dataType: 'json' + }, + drawCallback: function (settings) { + $('[data-toggle=tooltip]').tooltip() + } + }) + + $('#form-bulk-assignment').on('submit', function (e) { + const form = this + const rowsSelected = volunteersTable.column(0).checkboxes.selected() + + $.each(rowsSelected, function (index, rowId) { + $(form).append( + $('') + .attr('type', 'hidden') + .attr('name', 'supervisor_volunteer[volunteer_ids][]') + .val(rowId) + ) + }) + }) + + volunteersTable.column(0).on('change', function () { + const rowsSelected = volunteersTable.column(0).checkboxes.selected() + if (rowsSelected.count() === 0) { + $('#volunteers-selected').html('') + } else { + $('#volunteers-selected').html('s (' + rowsSelected.count() + ')') + } + }) + + // Because the table saves state, we have to check/uncheck modal inputs based on what + // columns are visible + volunteersTable.columns().every(function (index) { + const columnVisible = this.visible() + $('#visibleColumns input[data-column="' + index + '"]').prop('checked', columnVisible) + return true + }) + + // Add Supervisors Table + const supervisorsTable = $('table#supervisors').DataTable({ + autoWidth: false, + stateSave: false, + order: [[1, 'asc']], // order by cast contacts + columns: [ + { + name: 'display_name', + className: 'min-width', + render: (data, type, row, meta) => { + return ` + + ${row.display_name || row.email} + + ` + } + }, + { + name: '', + className: 'min-width', + render: (data, type, row, meta) => { + const noContactVolunteers = Number(row.no_attempt_for_two_weeks) + const transitionAgedCaseVolunteers = Number(row.transitions_volunteers) + const activeContactVolunteers = Number(row.volunteer_assignments) - noContactVolunteers + const activeContactElement = activeContactVolunteers + ? ( + ` + + ${activeContactVolunteers} + + ` + ) + : '' + + const noContactElement = noContactVolunteers > 0 + ? ( + ` + + ${noContactVolunteers} + + ` + ) + : '' + + let volunteersCounterElement = '' + if (activeContactVolunteers <= 0 && noContactVolunteers <= 0) { + volunteersCounterElement = 'No assigned volunteers' + } else { + volunteersCounterElement = `${transitionAgedCaseVolunteers}` + } + + return ` +
+ ${activeContactElement + noContactElement + volunteersCounterElement} +
+ ` + } + }, + { + name: 'actions', + orderable: false, + render: (data, type, row, meta) => { + return ` + +
+ +
+
+ ` + }, + searchable: false + } + ], + processing: true, + serverSide: true, + ajax: { + url: $('table#supervisors').data('source'), + type: 'POST', + data: function (d) { + const statusOptions = $('.status-options input:checked') + const statusFilter = Array.from(statusOptions).map(option => JSON.parse(option.dataset.value)) + + return $.extend({}, d, { + additional_filters: { + active: statusFilter + } + }) + }, + error: handleAjaxError, + dataType: 'json' + }, + drawCallback: function (settings) { + $('[data-toggle=tooltip]').tooltip() + }, + createdRow: function (row, data, dataIndex, cells) { + row.setAttribute('id', `supervisor-${data.id}-information`) + } + }) + + const casaCasesTable = $('table#casa-cases').DataTable({ + autoWidth: false, + stateSave: false, + columnDefs: [], + language: { + emptyTable: 'No active cases' + } + }) + + casaCasesTable.columns().every(function (index) { + const columnVisible = this.visible() + + if (columnVisible) { + $('#visibleColumns input[data-column="' + index + '"]').prop('checked', true) + } else { + $('#visibleColumns input[data-column="' + index + '"]').prop('checked', false) + } + + return true + }) + + defineCaseContactsTable() + + function filterOutUnassignedVolunteers (checked) { + $('.supervisor-options').find('input[type="checkbox"]').not('#unassigned-vol-filter').each(function () { + this.checked = checked + }) + } + + $('#unassigned-vol-filter').on('click', function () { + if ($('#unassigned-vol-filter').is(':checked')) { + filterOutUnassignedVolunteers(false) + } else { + filterOutUnassignedVolunteers(true) + } + volunteersTable.draw() + }) + + $('.volunteer-filters input[type="checkbox"]').not('#unassigned-vol-filter').on('click', function () { + volunteersTable.draw() + }) + + $('.supervisor-filters input[type="checkbox"]').on('click', function () { + supervisorsTable.draw() + }) + + $('.casa-case-filters input[type="checkbox"]').on('click', function () { + casaCasesTable.draw() + }) + + $('input.toggle-visibility').on('click', function (e) { + // Get the column API object and toggle the visibility + const column = volunteersTable.column($(this).attr('data-column')) + column.visible(!column.visible()) + volunteersTable.columns.adjust().draw() + + const caseColumn = casaCasesTable.column($(this).attr('data-column')) + caseColumn.visible(!caseColumn.visible()) + casaCasesTable.columns.adjust().draw() + }) +}) + +export { defineCaseContactsTable } diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 2b1c62859f..2525e6b57c 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -5,6 +5,7 @@ ); @use "bootstrap-datepicker/dist/css/bootstrap-datepicker"; @use "datatables.net-dt/css/jquery.dataTables"; +@use "jquery-datatables-checkboxes/css/dataTables.checkboxes"; @use "@fortawesome/fontawesome-free/scss/fontawesome"; @use "@fortawesome/fontawesome-free/scss/solid"; @use "select2/dist/css/select2"; diff --git a/app/assets/stylesheets/pages/volunteers.scss b/app/assets/stylesheets/pages/volunteers.scss index 683a4b7f95..2d044fbd6a 100644 --- a/app/assets/stylesheets/pages/volunteers.scss +++ b/app/assets/stylesheets/pages/volunteers.scss @@ -18,3 +18,16 @@ body.volunteers { } } } + + +table#volunteers.dataTable.hover tbody tr.selected > * { + background-color: rgba(54, 92, 245, 0.1); + box-shadow: none; + color: inherit; +} + +table#volunteers.dataTable.hover > tbody > tr.selected:hover > * { + background-color: rgba(54, 92, 245, 0.15); + box-shadow: none !important; + color: inherit; +} diff --git a/app/assets/stylesheets/shared/header.scss b/app/assets/stylesheets/shared/header.scss new file mode 100644 index 0000000000..b4902edd78 --- /dev/null +++ b/app/assets/stylesheets/shared/header.scss @@ -0,0 +1,23 @@ +@use "../base/breakpoints.scss" as screen-sizes; + +.header { + .header-left { + .menu-toggle-btn { + display: none; + + .main-btn { + border-radius: 4px !important; + } + } + } +} + +@media only screen and (max-width: screen-sizes.$mobile) { + .header { + .header-left { + .menu-toggle-btn { + display: block; + } + } + } +} diff --git a/app/controllers/supervisor_volunteers_controller.rb b/app/controllers/supervisor_volunteers_controller.rb index a3f9616d6b..691a3e2961 100644 --- a/app/controllers/supervisor_volunteers_controller.rb +++ b/app/controllers/supervisor_volunteers_controller.rb @@ -25,13 +25,67 @@ def unassign redirect_to request.referer, notice: flash_message end + def bulk_assignment + authorize :supervisor_volunteer + if mass_assign_volunteers? + volunteer_ids = supervisor_volunteer_params[:volunteer_ids] + supervisor = supervisor_volunteer_params[:supervisor_id] + vol = "Volunteer".pluralize(volunteer_ids.length) + + if supervisor == "unassign" + name_array = bulk_unassign!(volunteer_ids) + flash_message = "#{vol} #{name_array.to_sentence} successfully unassigned" + else + supervisor = supervisor_volunteer_parent + name_array = bulk_assign!(supervisor, volunteer_ids) + flash_message = "#{vol} #{name_array.to_sentence} successfully reassigned to #{supervisor.display_name}" + end + + redirect_to volunteers_path, notice: flash_message + else + redirect_to volunteers_path, notice: "Please select at least one volunteer and one supervisor." + end + end + private def supervisor_volunteer_params - params.require(:supervisor_volunteer).permit(:supervisor_id, :volunteer_id) + params.require(:supervisor_volunteer).permit(:supervisor_id, :volunteer_id, volunteer_ids: []) end def supervisor_volunteer_parent Supervisor.find(params[:supervisor_id] || supervisor_volunteer_params[:supervisor_id]) end + + def mass_assign_volunteers? + supervisor_volunteer_params[:volunteer_ids] && supervisor_volunteer_params[:supervisor_id] ? true : false + end + + def bulk_assign!(supervisor, volunteer_ids) + created_volunteers = [] + volunteer_ids.each do |vol_id| + if (supervisor_volunteer = SupervisorVolunteer.find_by(volunteer_id: vol_id.to_i)) + supervisor_volunteer.update!(supervisor_id: supervisor.id) + else + supervisor_volunteer = supervisor.supervisor_volunteers.create(volunteer_id: vol_id.to_i) + end + supervisor_volunteer.is_active = true + volunteer = supervisor_volunteer.volunteer + supervisor_volunteer.save + created_volunteers << volunteer.display_name.to_s + end + created_volunteers + end + + def bulk_unassign!(volunteer_ids) + unassigned_volunteers = [] + volunteer_ids.each do |vol_id| + supervisor_volunteer = SupervisorVolunteer.find_by(volunteer_id: vol_id.to_i) + supervisor_volunteer.update(is_active: false) + volunteer = supervisor_volunteer.volunteer + supervisor_volunteer.save + unassigned_volunteers << volunteer.display_name.to_s # take into account single assignments and give multiple assignments proper format + end + unassigned_volunteers + end end diff --git a/app/controllers/volunteers_controller.rb b/app/controllers/volunteers_controller.rb index 8790dae554..0a3f9d6fa7 100644 --- a/app/controllers/volunteers_controller.rb +++ b/app/controllers/volunteers_controller.rb @@ -6,6 +6,7 @@ class VolunteersController < ApplicationController def index authorize Volunteer + @supervisors = policy_scope(current_organization.supervisors) end def show diff --git a/app/javascript/application.js b/app/javascript/application.js index c661eaf523..4cbefd7c25 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -8,6 +8,8 @@ import 'trix' import '@rails/actiontext' require('datatables.net-dt')(null, window.jQuery) // First parameter is the global object. Defaults to window if null +require('datatables.net-select')(null, window.jQuery) +require('jquery-datatables-checkboxes')(null, window.jQuery) require('select2')(window.jQuery) require('@rails/ujs').start() require('@rails/activestorage').start() diff --git a/app/javascript/src/dashboard.js b/app/javascript/src/dashboard.js index 0edc294589..d2d121e5ba 100644 --- a/app/javascript/src/dashboard.js +++ b/app/javascript/src/dashboard.js @@ -134,8 +134,21 @@ $(() => { // JQuery's callback for the DOM loading } }) }, - order: [[6, 'desc']], + order: [[7, 'desc']], + select: { + style: 'multi' + }, columns: [ + { + data: 'id', + targets: 0, + searchable: false, + orderable: false, + checkboxes: { + selectRow: true, + stateSave: false + } + }, { name: 'display_name', render: (data, type, row, meta) => { @@ -293,6 +306,29 @@ $(() => { // JQuery's callback for the DOM loading } }) + $('#form-bulk-assignment').on('submit', function (e) { + const form = this + const rowsSelected = volunteersTable.column(0).checkboxes.selected() + + $.each(rowsSelected, function (index, rowId) { + $(form).append( + $('') + .attr('type', 'hidden') + .attr('name', 'supervisor_volunteer[volunteer_ids][]') + .val(rowId) + ) + }) + }) + + volunteersTable.column(0).on('change', function () { + const rowsSelected = volunteersTable.column(0).checkboxes.selected() + if (rowsSelected.count() === 0) { + $('#volunteers-selected').html('') + } else { + $('#volunteers-selected').html('s (' + rowsSelected.count() + ')') + } + }) + // Because the table saves state, we have to check/uncheck modal inputs based on what // columns are visible volunteersTable.columns().every(function (index) { diff --git a/app/models/volunteer.rb b/app/models/volunteer.rb index 1304988afa..52440d3a5d 100644 --- a/app/models/volunteer.rb +++ b/app/models/volunteer.rb @@ -3,6 +3,7 @@ class Volunteer < User devise :invitable, invite_for: 1.year + BULK_COLUMN = "bulk" NAME_COLUMN = "name" EMAIL_COLUMN = "email" SUPERVISOR_COLUMN = "supervisor" @@ -16,6 +17,7 @@ class Volunteer < User EXTRA_LANGUAGES_COLUMN = "has_any_extra_languages" ACTIONS_COLUMN = "actions" TABLE_COLUMNS = [ + BULK_COLUMN, NAME_COLUMN, EMAIL_COLUMN, SUPERVISOR_COLUMN, diff --git a/app/policies/supervisor_volunteer_policy.rb b/app/policies/supervisor_volunteer_policy.rb index 3f065a6e8e..3391673264 100644 --- a/app/policies/supervisor_volunteer_policy.rb +++ b/app/policies/supervisor_volunteer_policy.rb @@ -6,4 +6,8 @@ def create? def unassign? user.casa_admin? || user.supervisor? end + + def bulk_assignment? + user.casa_admin? || user.supervisor? + end end diff --git a/app/views/supervisors/index.html.erb b/app/views/supervisors/index.html.erb index a3dc412b42..e8bcbd6855 100644 --- a/app/views/supervisors/index.html.erb +++ b/app/views/supervisors/index.html.erb @@ -129,18 +129,14 @@ - - <% @available_volunteers.each_with_index do |volunteer, index| %> + <% @available_volunteers.each do |volunteer| %> - diff --git a/app/views/volunteers/index.html.erb b/app/views/volunteers/index.html.erb index 6f527d4b5f..7408458f5e 100644 --- a/app/views/volunteers/index.html.erb +++ b/app/views/volunteers/index.html.erb @@ -161,32 +161,88 @@
+
Responsive Data Table
+ + +
-
Active volunteers not assigned to supervisors
Assigned to Case(s)
- <%= index + 1 %> - <%= link_to volunteer.display_name, edit_volunteer_path(volunteer) %>
- - - - - - - - - - - - - - - - - -
NameEmailSupervisorStatusAssigned To Transition Aged YouthCase Number(s)Last Attempted ContactContacts Made in Past 60 DaysHours spent in last 30 daysExtra LanguagesActions
+ <%= form_with(url: bulk_assignment_supervisor_volunteers_path, id: "form-bulk-assignment") do |form| %> + + + + + + + + + + + + + + + + + + + +
NameEmailSupervisorStatusAssigned To Transition Aged YouthCase Number(s)Last Attempted ContactContacts Made in Past 60 DaysHours spent in last 30 daysExtra LanguagesActions
+ +
+ +
+ <% end %> - + + diff --git a/config/routes.rb b/config/routes.rb index 4bf77eb278..2426b06d65 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -128,6 +128,9 @@ end end resources :supervisor_volunteers, only: %i[create] do + collection do + post :bulk_assignment + end member do patch :unassign end diff --git a/db/schema.rb b/db/schema.rb index ed5ef0a6d9..34d4d85192 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -363,6 +363,12 @@ t.index ["casa_org_id"], name: "index_judges_on_casa_org_id" end + create_table "jwt_denylist", force: :cascade do |t| + t.string "jti", null: false + t.datetime "exp", null: false + t.index ["jti"], name: "index_jwt_denylist_on_jti" + end + create_table "languages", force: :cascade do |t| t.string "name" t.bigint "casa_org_id", null: false @@ -392,7 +398,6 @@ create_table "learning_hours", force: :cascade do |t| t.bigint "user_id", null: false - t.integer "learning_type", default: 5 t.string "name", null: false t.integer "duration_minutes", null: false t.integer "duration_hours", null: false diff --git a/package.json b/package.json index 5b27bc1b00..9cbb722304 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ "chart.js": "^4.4.0", "chartjs-adapter-luxon": "^1.3.1", "datatables.net-dt": "^1.13.6", + "datatables.net-select": "^1.7.0", "esbuild": "^0.19.4", "faker": "^5.5.3", "jquery": "^3.7.1", + "jquery-datatables-checkboxes": "^1.2.14", "js-cookie": "^3.0.5", "jstz": "^2.1.1", "lodash": "^4.17.21", diff --git a/spec/requests/supervisor_volunteers_spec.rb b/spec/requests/supervisor_volunteers_spec.rb index 88f7897f7a..3ea79675ff 100644 --- a/spec/requests/supervisor_volunteers_spec.rb +++ b/spec/requests/supervisor_volunteers_spec.rb @@ -157,4 +157,119 @@ end end end + # using describe just for clarity + describe "POST /bulk_assign" do + let!(:volunteer_1) { create(:volunteer, casa_org: casa_org) } + let!(:volunteer_2) { create(:volunteer, casa_org: casa_org) } + let!(:volunteer_3) { create(:volunteer, casa_org: casa_org) } + + context "when no pre-existing association between supervisor and volunteer exists" do + it "creates a new supervisor_volunteers association for each selected volunteer in bulk assign" do + valid_parameters = { + supervisor_volunteer: {supervisor_id: supervisor.id, volunteer_ids: [volunteer_1.id, volunteer_2.id, volunteer_3.id]} + } + sign_in(admin) + + expect { + post bulk_assignment_supervisor_volunteers_url, params: valid_parameters, headers: {HTTP_REFERER: supervisors_path.to_s} + }.to change(SupervisorVolunteer, :count).by(3) + expect(response).to redirect_to volunteers_path + end + end + + context "when an inactive association between supervisor and volunteer exists" do + let!(:association) do + create( + :supervisor_volunteer, + supervisor: supervisor, + volunteer: volunteer_1, + is_active: false + ) + end + + it "sets that association to active" do + valid_parameters = { + supervisor_volunteer: {supervisor_id: supervisor.id, volunteer_ids: [volunteer_1.id, volunteer_2.id, volunteer_3.id]} + } + sign_in(admin) + + expect { + post bulk_assignment_supervisor_volunteers_url, params: valid_parameters, headers: {HTTP_REFERER: supervisors_path.to_s} + }.to change(SupervisorVolunteer, :count).by(2) # bc 1 already exists, we are creating 2 new ones + expect(response).to redirect_to volunteers_path + + association.reload + expect(association.is_active?).to be(true) + end + end + + context "when passing the volunteer_ids as the supervisor_volunteer_params" do + let!(:association) do + create( + :supervisor_volunteer, + supervisor: supervisor, + volunteer: volunteer_1, + is_active: false + ) + end + + it "will still set the association as active while creaing two new supervisor_volunteer records" do + valid_parameters = { + supervisor_volunteer: {supervisor_id: supervisor.id, volunteer_ids: [volunteer_1.id, volunteer_2.id, volunteer_3.id]} + } + + sign_in(admin) + + expect { + post bulk_assignment_supervisor_volunteers_url, params: valid_parameters, headers: {HTTP_REFERER: supervisors_path.to_s} + }.to change(SupervisorVolunteer, :count).by(2) # bc 1 already exists, we are creating 2 new ones + expect(response).to redirect_to volunteers_path + + association.reload + expect(association.is_active?).to be(true) + end + end + end + + describe "POST /bulk_unassign" do + let!(:volunteer_1) { create(:volunteer, casa_org: casa_org) } + let!(:volunteer_2) { create(:volunteer, casa_org: casa_org) } + let!(:volunteer_3) { create(:volunteer, casa_org: casa_org) } + + context "when passing in several volunteer with selected --No supervior--" do + let!(:assigned_supervisor) { build(:supervisor, casa_org: casa_org) } + let!(:association_1) do + create( + :supervisor_volunteer, + supervisor: assigned_supervisor, + volunteer: volunteer_1, + is_active: true + ) + end + let!(:association_2) do + create( + :supervisor_volunteer, + supervisor: assigned_supervisor, + volunteer: volunteer_2, + is_active: true + ) + end + it "will set the association to inactive" do + valid_parameters = { + supervisor_volunteer: {supervisor_id: "unassign", volunteer_ids: [volunteer_1.id, volunteer_2.id]} + } + sign_in(admin) + + expect { + post bulk_assignment_supervisor_volunteers_url, params: valid_parameters, headers: {HTTP_REFERER: supervisors_path.to_s} + }.not_to change(SupervisorVolunteer, :count) + expect(response).to redirect_to volunteers_path + + association_1.reload + association_2.reload + expect(association_1.is_active?).to be(false) + expect(association_2.is_active?).to be(false) + end + end + end end diff --git a/spec/system/volunteers/index_spec.rb b/spec/system/volunteers/index_spec.rb index b37c72464b..e6253e1d14 100644 --- a/spec/system/volunteers/index_spec.rb +++ b/spec/system/volunteers/index_spec.rb @@ -175,6 +175,54 @@ expect(supervisor_cell.text).to eq "" end + + context "when bulk assignments" do + it "displays supervisor's name when multiple volunteers assigned supervisor", js: true do + name = "Superduper Visor" + supervisor = create(:supervisor, display_name: name, casa_org: organization) + create(:volunteer, casa_org: organization) + create(:volunteer, casa_org: organization) + sign_in admin + + visit volunteers_path + + find("tr.odd").click + find("tr.even").click + click_on "Manage Volunteer" + select supervisor.display_name, from: "supervisor_volunteer[supervisor_id]" + + click_on "Confirm" + + supervisor_cell1 = page.find("tbody .odd .supervisor-column") + supervisor_cell2 = page.find("tbody .even .supervisor-column") + + expect(supervisor_cell1.text).to eq supervisor.display_name + expect(supervisor_cell2.text).to eq supervisor.display_name + end + + it "displays no supervisor name when multiple volunteers unassigned supervisor", js: true do + name = "Superduper Visor" + supervisor = create(:supervisor, display_name: name, casa_org: organization) + create(:volunteer, supervisor: supervisor, casa_org: organization) + create(:volunteer, supervisor: supervisor, casa_org: organization) + sign_in admin + + visit volunteers_path + + find("tr.odd").click + find("tr.even").click + click_on "Manage Volunteer" + select "-- No Supervisor --", from: "supervisor_volunteer[supervisor_id]" + + click_on "Confirm" + + supervisor_cell1 = page.find("tbody .odd .supervisor-column") + supervisor_cell2 = page.find("tbody .even .supervisor-column") + + expect(supervisor_cell1.text).to eq "" + expect(supervisor_cell2.text).to eq "" + end + end end context "when timed out" do diff --git a/yarn.lock b/yarn.lock index a72b4d062d..28121c1572 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2910,6 +2910,21 @@ datatables.net-dt@^1.13.6: datatables.net ">=1.13.4" jquery ">=1.7" +datatables.net-select@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/datatables.net-select/-/datatables.net-select-1.7.0.tgz#a108752ee6109a49392d19fec7406c953be11665" + integrity sha512-ps8eL8S2gUV7EdzMraw8mfQlHXpfuc8TC2onBxdk0snP8eizPe85VhpI3r4ULvPRTTI7vcViz8E7JV8aayA2lw== + dependencies: + datatables.net ">=1.13.4" + jquery ">=1.7" + +datatables.net@>=1.10.8: + version "1.13.6" + resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.13.6.tgz#6e282adbbb2732e8df495611b8bb54e19f7a943e" + integrity sha512-rHNcnW+yEP9me82/KmRcid5eKrqPqW3+I/p1TwqCW3c/7GRYYkDyF6aJQOQ9DNS/pw+nyr4BVpjyJ3yoZXiFPg== + dependencies: + jquery ">=1.7" + datatables.net@>=1.13.4: version "1.13.5" resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.13.5.tgz#790a3d70d5e103f5465ed8c52a50eb242e1e2dc4" @@ -4980,6 +4995,14 @@ joi@^17.7.0: "@sideway/formula" "^3.0.0" "@sideway/pinpoint" "^2.0.0" +jquery-datatables-checkboxes@^1.2.14: + version "1.2.14" + resolved "https://registry.yarnpkg.com/jquery-datatables-checkboxes/-/jquery-datatables-checkboxes-1.2.14.tgz#5bada981140b9ba645e80d6c2caa8bbbb4123936" + integrity sha512-99B6PmS3ZdinDyqF5vBiykd8B+NohHRBBs/a5/hrYRlsBxbWqQ2KtigSvAc2qFdxPOyrcCV75nWBYnlrtdCGgg== + dependencies: + datatables.net ">=1.10.8" + jquery ">=1.7" + jquery@>=1.7, "jquery@>=3.4.0 <4.0.0", jquery@^3.7.1: version "3.7.1" resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de"