diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss index 61a468ea..7ddab255 100644 --- a/app/assets/stylesheets/application.sass.scss +++ b/app/assets/stylesheets/application.sass.scss @@ -71,3 +71,17 @@ dialog::backdrop { .usa-combo-box .border-secondary + input { border: 1px solid red; } + +.export-checkboxes { + .usa-checkbox { + .usa-checkbox__label::before { + border-radius: 0; + left: 1rem; + } + + .usa-checkbox__label { + padding-left: 3rem; + text-align: left; + } + } +} diff --git a/app/controllers/phases_controller.rb b/app/controllers/phases_controller.rb index 368d30f4..ecc7a2ac 100644 --- a/app/controllers/phases_controller.rb +++ b/app/controllers/phases_controller.rb @@ -31,6 +31,18 @@ def submissions render_response end + def export_submissions + authorize_user('challenge_manager') + service = ExportSubmissionsService.new(@phase, params[:options]) + csv_data = service.export + + respond_to do |format| + format.csv do + send_data(csv_data, type: 'text/csv') + end + end + end + private def set_phase diff --git a/app/javascript/controllers/export_submissions_controller.js b/app/javascript/controllers/export_submissions_controller.js new file mode 100644 index 00000000..29afd936 --- /dev/null +++ b/app/javascript/controllers/export_submissions_controller.js @@ -0,0 +1,101 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["modal"] + static values = { + phaseId: String, + challengeTitle: String, + phaseNumber: String + } + + connect() { + this.modalTarget.addEventListener('click', this.handleOutsideClick.bind(this)); + } + + disconnect() { + this.modalTarget.removeEventListener('click', this.handleOutsideClick.bind(this)); + } + + open(event) { + event.preventDefault(); + this.phaseIdValue = event.currentTarget.dataset.phaseId; + this.challengeTitleValue = event.currentTarget.dataset.challengeTitle; + this.phaseNumberValue = event.currentTarget.dataset.phaseNumber; + this.modalTarget.showModal(); + } + + close() { + this.modalTarget.close(); + } + + handleOutsideClick = (event) => { + if (event.target === this.modalTarget) { + this.close(); + } + } + + getSelectedOptions() { + return Array.from( + document.querySelectorAll('input[name="export-options"]:checked') + ).map(checkbox => checkbox.value); + } + + createFilename(option, sanitizedTitle) { + return `${sanitizedTitle}_${option}_${new Date().toISOString().split('T')[0]}.csv`; + } + + sanitizeTitle() { + const title = `${this.challengeTitleValue} - Phase ${this.phaseNumberValue}`; + return title.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); + } + + async downloadCSV(blob, filename) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } + + async fetchExportData(option) { + const params = new URLSearchParams({ options: option, format: 'csv' }); + const response = await fetch(`/phases/${this.phaseIdValue}/export_submissions?${params}`, { + method: 'GET', + headers: { + 'Accept': 'text/csv', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (!response.ok) throw new Error('Export failed'); + return response.blob(); + } + + async exportSubmissions(event) { + event.preventDefault(); + const selectedOptions = this.getSelectedOptions(); + + if (selectedOptions.length === 0) { + alert('Please select at least one export option'); + return; + } + + try { + const sanitizedTitle = this.sanitizeTitle(); + + for (const option of selectedOptions) { + const blob = await this.fetchExportData(option); + const filename = this.createFilename(option, sanitizedTitle); + await this.downloadCSV(blob, filename); + } + + this.close(); + } catch (error) { + console.error('Export failed:', error); + alert('Export failed. Please try again.'); + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 6e928fed..d086e2c9 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -13,6 +13,9 @@ application.register("evaluation-criteria", EvaluationCriteriaController); import EvaluationFormController from "./evaluation_form_controller"; application.register("evaluation-form", EvaluationFormController); +import ExportSubmissionsController from "./export_submissions_controller"; +application.register("export-submissions", ExportSubmissionsController); + import FormValidationController from "./form_validation_controller"; application.register("form-validation", FormValidationController); diff --git a/app/services/export_submissions_service.rb b/app/services/export_submissions_service.rb new file mode 100644 index 00000000..d08246f5 --- /dev/null +++ b/app/services/export_submissions_service.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'csv' + +# This service handles exporting submissions and evaluations into a csv. +class ExportSubmissionsService + def initialize(phase, options) + @phase = phase + @options = options&.split(',') || [] + end + + def export + return create_submissions_csv if @options.include?('submissions') + return create_evaluations_csv if @options.include?('evaluations') + end + + private + + def sanitize_text(text) + ActionView::Base.full_sanitizer.sanitize(text.to_s) + end + + # submission csv + def submissions_headers + [ + 'Submission ID', 'Title', 'Brief Description', 'Description', + 'External URL', 'Status', 'Created At', 'Updated At', + 'Eligible for Evaluation', 'Selected to Advance' + ] + end + + def submission_data(submission) + [ + submission.id, + submission.title || '', + sanitize_text(submission.brief_description) || '', + sanitize_text(submission.description) || '', + submission.external_url || '', + submission.status, + submission.inserted_at, + submission.updated_at, + submission.eligible_for_evaluation? ? 'Eligible' : 'Not Eligible', + submission.selected_to_advance? ? 'Selected' : 'Not Selected' + ] + end + + def create_submissions_csv + CSV.generate(headers: true) do |csv| + csv << submissions_headers + @phase.submissions.find_each do |submission| + csv << submission_data(submission) + end + end + end + + # evaluations csv + def evaluations_headers + [ + 'Submission ID', 'Evaluator Name', 'Evaluator Email', + 'Evaluation Status', 'Total Score' + ] + end + + def evaluation_data(submission, assignment) + [ + submission.id, + "#{assignment.evaluator.first_name || ''} #{assignment.evaluator.last_name || ''}", + assignment.evaluator.email, + assignment.evaluation_status.to_s.titleize, + assignment.evaluation&.total_score || '' + ] + end + + def create_evaluations_csv + CSV.generate(headers: true) do |csv| + csv << evaluations_headers + @phase.submissions.includes(evaluator_submission_assignments: [:evaluator, :evaluation]).find_each do |submission| + submission.evaluator_submission_assignments.each do |assignment| + csv << evaluation_data(submission, assignment) + end + end + end + end +end diff --git a/app/views/evaluator_submission_assignments/index.html.erb b/app/views/evaluator_submission_assignments/index.html.erb index b991ed3f..5c8dc78e 100644 --- a/app/views/evaluator_submission_assignments/index.html.erb +++ b/app/views/evaluator_submission_assignments/index.html.erb @@ -1,7 +1,6 @@
+ data-unassign-evaluator-submission-modal-phase-id-value="<%= @phase.id %>" > <%= render 'shared/back_link', path: phase_evaluators_path(@phase) %> diff --git a/app/views/phases/_export_submissions.html.erb b/app/views/phases/_export_submissions.html.erb new file mode 100644 index 00000000..08b608ea --- /dev/null +++ b/app/views/phases/_export_submissions.html.erb @@ -0,0 +1,116 @@ +
+
+
+
+ +
+
+
+ + +
+
+

+ Please select what data you would like to export. +

+

+ Select from export submissions options submissions CSV, submission attachments zip, and evaluations CSV. +

+
+
+ Export submissions options + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+
+
diff --git a/app/views/phases/_sort_and_filter.html.erb b/app/views/phases/_sort_and_filter.html.erb index 781d2cb1..923c6ae4 100644 --- a/app/views/phases/_sort_and_filter.html.erb +++ b/app/views/phases/_sort_and_filter.html.erb @@ -1,9 +1,9 @@
-
+