add export button

pull/105/head
Alex Turchyn 2 years ago
parent cbc6f4497a
commit bc3b1b477e

@ -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
Loading…
Cancel
Save