add export button
This commit is contained in:
@@ -28,6 +28,7 @@ gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||
gem 'ruby-vips'
|
||||
gem 'rubyXL'
|
||||
gem 'shakapacker'
|
||||
gem 'sidekiq', require: ENV.key?('REDIS_URL')
|
||||
gem 'sqlite3', require: false
|
||||
|
||||
@@ -458,6 +458,10 @@ GEM
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyXL (3.4.25)
|
||||
nokogiri (>= 1.10.8)
|
||||
rubyzip (>= 1.3.0)
|
||||
rubyzip (2.3.2)
|
||||
semantic_range (3.0.0)
|
||||
shakapacker (7.0.0)
|
||||
activesupport (>= 5.2)
|
||||
@@ -566,6 +570,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-vips
|
||||
rubyXL
|
||||
shakapacker
|
||||
sidekiq
|
||||
simplecov
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsExportController < ApplicationController
|
||||
before_action :load_template
|
||||
|
||||
def index
|
||||
submissions = @template.submissions.active
|
||||
.preload(submitters: { documents_attachments: :blob,
|
||||
attachments_attachments: :blob })
|
||||
.order(id: :asc)
|
||||
|
||||
if params[:format] == 'csv'
|
||||
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
|
||||
filename: "#{@template.name}.csv"
|
||||
elsif params[:format] == 'xlsx'
|
||||
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
|
||||
filename: "#{@template.name}.xlsx"
|
||||
end
|
||||
end
|
||||
|
||||
def new; end
|
||||
|
||||
private
|
||||
|
||||
def load_template
|
||||
@template = current_account.templates.find(params[:template_id])
|
||||
end
|
||||
end
|
||||
@@ -45,6 +45,27 @@ window.customElements.define('set-timezone', SetTimezone)
|
||||
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
|
||||
|
||||
if (!resp?.headers?.get('content-disposition')?.includes('attachment')) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
|
||||
|
||||
document.body.appendChild(link)
|
||||
|
||||
link.click()
|
||||
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
|
||||
window.customElements.define('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
|
||||
@@ -5,8 +5,12 @@ export default actionable(class extends HTMLElement {
|
||||
document.body.classList.add('overflow-hidden')
|
||||
|
||||
document.addEventListener('keyup', this.onEscKey)
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
|
||||
document.addEventListener('turbo:before-cache', this.close)
|
||||
|
||||
if (this.dataset.closeAfterSubmit !== 'false') {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
|
||||
@@ -6,12 +6,22 @@
|
||||
<span class="hidden md:inline">
|
||||
<%= local_assigns[:copy_title] || 'Copy' %>
|
||||
</span>
|
||||
<% if local_assigns[:copy_title_md] %>
|
||||
<span class="inline md:hidden">
|
||||
<%= local_assigns[:copy_title_md] %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="hidden peer-checked:flex items-center space-x-2">
|
||||
<%= svg_icon(local_assigns[:copied_icon] || 'clipboard_copy', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
|
||||
<span class="hidden md:inline">
|
||||
<%= local_assigns[:copied_title] || 'Copied' %>
|
||||
</span>
|
||||
<% if local_assigns[:copied_title_md] %>
|
||||
<span class="inline md:hidden">
|
||||
<%= local_assigns[:copied_title_md] %>
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
</label>
|
||||
</clipboard-copy>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<turbo-frame id="modal">
|
||||
<turbo-modal class="modal modal-open items-start !animate-none overflow-y-auto">
|
||||
<turbo-modal class="modal modal-open items-start !animate-none overflow-y-auto" data-close-after-submit="<%= local_assigns.key?(:close_after_submit) ? local_assigns[:close_after_submit] : true %>">
|
||||
<div class="absolute top-0 bottom-0 right-0 left-0" data-action="click:turbo-modal#close"></div>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
|
||||
<% if local_assigns[:title] %>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<%= render 'shared/turbo_modal', title: 'Export', close_after_submit: false do %>
|
||||
<div class="space-y-2">
|
||||
<%= button_to template_submissions_export_index_path(@template), params: { format: :xlsx }, method: :get, data: { turbo_frame: :_top } do %>
|
||||
<div class="flex items-center p-4 text-left rounded-2xl border border-neutral-300 hover:cursor-pointer hover:bg-neutral hover:text-gray-300">
|
||||
<div class="enabled">
|
||||
<%= svg_icon('download', class: 'w-12 h-12 stroke-2 mr-2') %>
|
||||
</div>
|
||||
<div class="disabled">
|
||||
<%= svg_icon('loader', class: 'w-12 h-12 stroke-2 mr-2 animate-spin') %>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="mb-1 text-lg font-semibold">XLSX</span>
|
||||
<p class="text-sm"> Primarily opened with Microsoft Excel. Other options include Google Sheets, LibreOffice Calc, and OpenOffice Calc.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= button_to template_submissions_export_index_path(@template), params: { format: :csv }, method: :get, data: { turbo_frame: :_top } do %>
|
||||
<div class="flex items-center text-left p-4 rounded-2xl border border-neutral-300 hover:cursor-pointer hover:bg-neutral hover:text-gray-300">
|
||||
<div class="enabled">
|
||||
<%= svg_icon('download', class: 'w-12 h-12 stroke-2 mr-2') %>
|
||||
</div>
|
||||
<div class="disabled">
|
||||
<%= svg_icon('loader', class: 'w-12 h-12 stroke-2 mr-2 animate-spin') %>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="mb-1 text-lg font-semibold">CSV</span>
|
||||
<p class="text-sm">Can be opened with Microsoft Excel, Google Sheets, or any text editor like Notepad.</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="flex flex-col md:flex-row space-y-2 md:space-y-0 md:justify-between items-start mb-8">
|
||||
<div class="flex flex-col items-start md:flex-row space-y-2 md:space-y-0 md:justify-between md:items-center mb-8">
|
||||
<h1 class="text-4xl font-semibold mr-4" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
|
||||
<%= @template.name %>
|
||||
</h1>
|
||||
@@ -17,16 +17,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<% if !@pagy.count.zero? || @template.submitters.to_a.size == 1 %>
|
||||
<div class="flex justify-between mb-6 items-end">
|
||||
<div class="flex justify-between mb-6 md:items-end flex-col md:flex-row">
|
||||
<p class="text-3xl font-bold">Submissions</p>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex space-x-2 mt-3 md:mt-0">
|
||||
<%= link_to new_template_submissions_export_path(@template), class: 'order-3 md:order-1 btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
|
||||
<span>Export</span>
|
||||
<% end %>
|
||||
<% if @template.submitters.to_a.size == 1 %>
|
||||
<%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Share Link', copied_title: 'Copied to Clipboard' %>
|
||||
<span class="order-1">
|
||||
<%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Share Link', copied_title: 'Copied to Clipboard', copy_title_md: 'Copy', copied_title_md: 'Copied' %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% unless @pagy.count.zero? %>
|
||||
<%= link_to new_template_submission_path(@template), class: 'btn btn-primary text-base', data: { turbo_frame: 'modal' } do %>
|
||||
<%= link_to new_template_submission_path(@template), class: 'order-1 btn btn-primary text-base', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
|
||||
<span class="hidden md:block">Add Recipients</span>
|
||||
<span>Add <span class="hidden md:inline">Recipients</span></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
@@ -44,6 +44,7 @@ Rails.application.routes.draw do
|
||||
resources :console_redirect, only: %i[index]
|
||||
resources :templates, only: %i[new create edit show destroy] do
|
||||
resources :submissions, only: %i[new create]
|
||||
resources :submissions_export, only: %i[index new]
|
||||
end
|
||||
|
||||
resources :start_form, only: %i[show update], path: 'd', param: 'slug' do
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Submissions
|
||||
module GenerateExportFiles
|
||||
UnknownFormat = Class.new(StandardError)
|
||||
|
||||
module_function
|
||||
|
||||
def call(submissions, format: :csv)
|
||||
rows = build_table_rows(submissions)
|
||||
|
||||
if format.to_sym == :csv
|
||||
rows_to_csv(rows)
|
||||
elsif format.to_sym == :xlsx
|
||||
rows_to_xlsx(rows)
|
||||
else
|
||||
raise UnknownFormat
|
||||
end
|
||||
end
|
||||
|
||||
def rows_to_xlsx(rows)
|
||||
workbook = RubyXL::Workbook.new
|
||||
worksheet = workbook[0]
|
||||
worksheet.sheet_name = I18n.l(Time.current.to_date)
|
||||
|
||||
headers = build_headers(rows)
|
||||
headers.each_with_index do |column_name, column_index|
|
||||
worksheet.add_cell(0, column_index, column_name)
|
||||
end
|
||||
|
||||
rows.each.with_index(1) do |row, row_index|
|
||||
extract_columns(row, headers).each_with_index do |value, column_index|
|
||||
worksheet.add_cell(row_index, column_index, value)
|
||||
end
|
||||
end
|
||||
|
||||
workbook.stream.string
|
||||
end
|
||||
|
||||
def rows_to_csv(rows)
|
||||
headers = build_headers(rows)
|
||||
|
||||
CSV.generate do |csv|
|
||||
csv << headers
|
||||
|
||||
rows.each do |row|
|
||||
csv << extract_columns(row, headers)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_headers(rows)
|
||||
rows.reduce(Set.new) { |acc, row| acc + row.pluck(:name) }
|
||||
end
|
||||
|
||||
def extract_columns(row, headers)
|
||||
headers.map { |key| row.find { |e| e[:name] == key }&.dig(:value) }
|
||||
end
|
||||
|
||||
def build_table_rows(submissions)
|
||||
submissions.map do |submission|
|
||||
submission_data = []
|
||||
submitters_count = submission.submitters.size
|
||||
|
||||
submission.submitters.each do |submitter|
|
||||
template_submitters = submission.template_submitters || submission.template.submitters
|
||||
submitter_name = template_submitters.find { |s| s['uuid'] == submitter.uuid }['name']
|
||||
|
||||
submission_data += build_submission_data(submitter, submitter_name, submitters_count)
|
||||
|
||||
submission_data += submitter_formatted_fields(submitter).map do |field|
|
||||
{
|
||||
name: column_name(field[:name], submitter_name, submitters_count),
|
||||
value: field[:value]
|
||||
}
|
||||
end
|
||||
|
||||
next if submitter != submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
submission_data += submitter.documents.map.with_index(1) do |attachment, index|
|
||||
{
|
||||
name: "Document #{index}",
|
||||
value: attachment.url
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
submission_data
|
||||
end
|
||||
end
|
||||
|
||||
def build_submission_data(submitter, submitter_name, submitters_count)
|
||||
[
|
||||
{
|
||||
name: column_name('Email', submitter_name, submitters_count),
|
||||
value: submitter.email
|
||||
},
|
||||
{
|
||||
name: column_name('Completed At', submitter_name, submitters_count),
|
||||
value: submitter.completed_at
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def column_name(name, submitter_name, submitters_count = 1)
|
||||
submitters_count > 1 ? "#{submitter_name} - #{name}" : name
|
||||
end
|
||||
|
||||
def submitter_formatted_fields(submitter)
|
||||
fields = submitter.submission.template_fields || submitter.submission.template.fields
|
||||
|
||||
template_fields = fields.select { |f| f['submitter_uuid'] == submitter.uuid }
|
||||
|
||||
attachments_index = submitter.attachments.index_by(&:uuid)
|
||||
|
||||
template_field_counters = Hash.new { 0 }
|
||||
template_fields.map do |template_field|
|
||||
submitter_value = submitter.values.fetch(template_field['uuid'], nil)
|
||||
template_field_type = template_field['type']
|
||||
template_field_counters[template_field_type] += 1
|
||||
template_field_name = template_field['name'].presence
|
||||
template_field_name ||= "#{template_field_type.titleize} Field #{template_field_counters[template_field_type]}"
|
||||
|
||||
value =
|
||||
if template_field_type.in?(%w[image signature])
|
||||
attachments_index[submitter_value]&.url
|
||||
elsif template_field_type == 'file'
|
||||
Array.wrap(submitter_value).compact_blank.filter_map { |e| attachments_index[e]&.url }
|
||||
else
|
||||
submitter_value
|
||||
end
|
||||
|
||||
{ name: template_field_name, uuid: template_field['uuid'], value: }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user