Merge branch 'New-forms-page' of https://github.com/bumi/tinyforms into New-forms-page

This commit is contained in:
karemarsy 2020-04-14 11:31:45 +02:00
commit cd5483a9b6
35 changed files with 1073 additions and 182 deletions

View File

@ -25,10 +25,12 @@ gem 'bootsnap', '>= 1.4.2', require: false
gem 'lockbox'
gem 'aws-sdk-s3', require: false
# gem 'airrecord'
gem 'airrecord'
gem 'google-api-client'
gem 'rack-cors'
gem 'sentry-raven'
gem 'sequenced'
gem 'sorcery'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console

View File

@ -58,6 +58,9 @@ GEM
zeitwerk (~> 2.2)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
airrecord (1.0.5)
faraday (>= 0.10, < 2.0)
net-http-persistent (>= 2.9)
aws-eventstream (1.0.3)
aws-partitions (1.263.0)
aws-sdk-core (3.89.1)
@ -74,11 +77,13 @@ GEM
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.0)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.13)
bootsnap (1.4.6)
msgpack (~> 1.0)
builder (3.2.4)
byebug (11.1.1)
concurrent-ruby (1.1.6)
connection_pool (2.2.2)
crass (1.0.6)
declarative (0.0.10)
declarative-option (0.1.0)
@ -133,10 +138,20 @@ GEM
minitest (5.14.0)
msgpack (1.3.3)
multi_json (1.14.1)
multi_xml (0.6.0)
multipart-post (2.1.1)
net-http-persistent (3.1.0)
connection_pool (~> 2.2)
nio4r (2.5.2)
nokogiri (1.10.9)
mini_portile2 (~> 2.4.0)
oauth (0.5.4)
oauth2 (1.4.4)
faraday (>= 0.8, < 2.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
os (1.1.0)
pg (1.2.3)
public_suffix (4.0.4)
@ -197,11 +212,18 @@ GEM
semantic_range (2.3.0)
sentry-raven (3.0.0)
faraday (>= 1.0)
sequenced (3.2.0)
activerecord (>= 3.0)
activesupport (>= 3.0)
signet (0.14.0)
addressable (~> 2.3)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
sorcery (0.14.0)
bcrypt (~> 3.1)
oauth (~> 0.4, >= 0.4.4)
oauth2 (~> 1.0, >= 0.8.0)
spring (2.1.0)
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
@ -237,6 +259,7 @@ PLATFORMS
ruby
DEPENDENCIES
airrecord
aws-sdk-s3
bootsnap (>= 1.4.2)
byebug
@ -251,6 +274,8 @@ DEPENDENCIES
rails
sass-rails
sentry-raven
sequenced
sorcery
spring
spring-watcher-listen
sqlite3

View File

@ -1 +1,2 @@
release: bundle exec rails db:migrate
web: bundle exec puma -C config/puma.rb

View File

@ -1,2 +1,2 @@
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link application.css

View File

@ -1,18 +1,3 @@
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
* vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
*= require_tree .
*= require_self
*/
@import "bulma/sass/utilities/initial-variables";
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Lobster&family=Comfortaa:wght@400;500;600;700&display=swap');
@import url('https://use.fontawesome.com/releases/v5.12.0/css/all.css');
@ -40,6 +25,8 @@ $light: #fff;
@import 'bulma/bulma';
@import './demo';
.is-font-logo {
font-family: 'Lobster', cursive;
}
@ -60,4 +47,9 @@ $light: #fff;
body {
min-height: 100vh;
}
.button.google {
background: rgb(225, 98, 89);
color: white;
}

View File

@ -0,0 +1,16 @@
section.demo {
height: 90vh;
min-height: 600px;
}
.demo-form {
border: 1px solid $grey-light;
padding: 1em;
height: 100%;
}
section.demo .container, section.demo .columns, section.demo .column {
height: 100%;
}
#demo-success {
text-align: center;
padding-top: 1em;
}

View File

@ -2,7 +2,7 @@ class FileUploadsController < ApplicationController
def show
@form = Form.find_by!(token: params[:form_id])
@submission = @form.submissions.find(params[:submission_id])
@file_upload = @submission.files_attachments.find(params[:id])
@file_upload = @submission.files_attachments.find_by!(token: params[:id])
redirect_to url_for(@file_upload)
end
end

View File

@ -0,0 +1,34 @@
class OauthsController < ApplicationController
# Sends the user on a trip to the provider,
# and after authorizing there back to the callback url.
def oauth
login_at(params[:provider])
end
def callback
provider = params[:provider]
if @user = login_from(provider)
redirect_to root_path, :notice => "Logged in from #{provider.titleize}!"
else
begin
@user = create_from(provider)
if authentication = @user.authentications.find_by(provider: provider)
authentication.update({
access_token: @access_token.token,
refresh_token: @access_token.refresh_token,
expires_at: Time.at(@access_token.expires_at)
})
end
reset_session
auto_login(@user)
redirect_to root_path, :notice => "Logged in from #{provider.titleize}!"
rescue
Rails.logger.error("Failed to login from #{provider}")
redirect_to root_path, :alert => "Failed to login from #{provider.titleize}!"
end
end
end
end

View File

@ -1,29 +1,7 @@
require 'google/apis/oauth2_v2'
class SessionsController < ApplicationController
def new
reset_session
redirect_to auth_client.authorization_uri.to_s
end
def auth
reset_session
if params[:error]
flash[:error] = 'Login failed'
redirect_to root_url
else
auth_client.code = params[:code]
auth_client.fetch_access_token!
@user, @authentication = User.find_by_oauth_info(auth_client)
if @user.persisted? && @authentication.persisted?
session[:user_id] = @user.id.to_s
redirect_to forms_url
else
flash[:error] = 'Login failed'
redirect_to root_url
end
end
end
def destroy
@ -31,18 +9,4 @@ class SessionsController < ApplicationController
redirect_to root_url
end
private
def auth_client
@auth_client ||= CLIENT_SECRETS.to_authorization.tap do |c|
c.update!(
scope: 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/spreadsheets',
redirect_uri: auth_url,
additional_parameters: {
'access_type' => 'offline', # offline access
'include_granted_scopes' => 'true' # incremental auth
}
)
end
end
end

View File

@ -14,7 +14,7 @@ class SubmissionsController < ApplicationController
respond_to do |format|
if @submission.save
format.html { redirect_to(@form.thank_you_url) if @form.thank_you_url.present? }
format.json { render(json: { success: true, data: @submission.data }) }
format.json { render(json: { success: true, submission: @submission.data }) }
else
format.html { redirect_to(@form.thank_you_url) if @form.thank_you_url.present? }
format.json { render(json: { error: @submission.errors }, status: 422) }

20
app/javascript/demo.js Normal file
View File

@ -0,0 +1,20 @@
document.addEventListener('DOMContentLoaded', function() {
var demoForm = document.getElementById('demo-form');
if (!demoForm) {
return;
}
demoForm.addEventListener('submit', function(e) {
var submitButton = document.getElementById('demo-submit');
submitButton.innerText = 'loading...';
submitButton.disabled = 'true'
});
demoForm.addEventListener('tinyforms:submitted', function(e) {
var name = document.getElementById('demo-submission-name');
var demoFields = document.getElementById('demo-fields');
var demoSucess = document.getElementById('demo-success');
demoFields.style.display = 'none';
demoSucess.style.display = 'block';
name.innerText = e.detail.submission.Name;
});
});

View File

@ -9,6 +9,8 @@ require("@rails/activestorage").start()
require("channels")
require('burger_menu');
require('tinyforms');
require('demo');
// Uncomment to copy all static images under ../images to the output folder and reference
// them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>)

View File

@ -0,0 +1,51 @@
window.tinyforms = {
init: function() {
var forms = document.querySelectorAll('form[data-tinyforms]');
forms.forEach(function(form) {
form.addEventListener('submit', tinyforms.onSubmit);
});
},
submitForm: function(form) {
return new Promise(function(resolve, reject) {
var action = form.getAttribute('action');
var formData = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.open('POST', action, true);
xhr.setRequestHeader("Accept", "application/json");
xhr.onload = function () {
var response;
try {
response = JSON.parse(xhr.responseText);
} catch (e) {
response = xhr.responseText;
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(response);
} else {
reject(response);
}
};
xhr.onerror = function() {
reject(xhr.responseText)
};
xhr.send(formData);
});
},
onSubmit: function(e) {
e.preventDefault();
return tinyforms.submitForm(this)
.then((response) => {
this.dispatchEvent(new CustomEvent('tinyforms:submitted', {detail: { submission: response.submission, response: response }}));
})
.catch((response) => {
this.dispatchEvent(new CustomEvent('tinyforms:error', {detail: { submission: response.submission, response: response }}));
});
}
};
document.addEventListener('DOMContentLoaded', function() {
tinyforms.init();
});

View File

@ -1,6 +1,8 @@
class Authentication < ApplicationRecord
belongs_to :user
scope :for, -> (provider) { where(provider: provider) }
encrypts :access_token
encrypts :refresh_token
@ -9,6 +11,7 @@ class Authentication < ApplicationRecord
end
def google_authorization
return nil unless provider == 'google'
@google_authorization ||= CLIENT_SECRETS.to_authorization.tap do |c|
c.access_token = self.access_token
c.refresh_token = self.refresh_token

View File

@ -1,23 +1,29 @@
require 'google/apis/sheets_v4'
class Form < ApplicationRecord
# Hash to translate an index to a A1 notation. e.g. 1 => 'B', 27 => 'AA'
COLUMN_INDEX_TO_LETTER = Hash.new {|hash,key| hash[key] = hash[key - 1].next }.merge({0 => "A"})
belongs_to :user
has_many :submissions, dependent: :destroy
before_validation :insert_defaults, on: :create
after_create :create_spreadsheet
has_secure_token
encrypts :airtable_api_key
encrypts :airtable_app_key
validates_presence_of :title
validates_inclusion_of :backend_name, in: ['google_sheets', 'airtable']
# Airtable validations
validates_presence_of :airtable_api_key, if: :airtable?
validates_presence_of :airtable_app_key, if: :airtable?
validates_presence_of :airtable_table, if: :airtable?
# TODO: use counter_cache option on association
def submissions_count
submissions.count
@submissions_count ||= submissions.count
end
def last_submission_date
submissions.order(created_at: :desc).first&.created_at
@last_submission_date ||= submissions.order(created_at: :desc).first&.created_at
end
def deactivate!(reason = nil)
@ -28,63 +34,39 @@ class Form < ApplicationRecord
self.user.active?
end
def google_spreadsheet_url
"https://docs.google.com/spreadsheets/d/#{google_spreadsheet_id}/edit" if google_spreadsheet_id.present?
def airtable?
backend_name == 'airtable'
end
def google
backend_name == 'google'
end
def backend
@backend ||= SpreadsheetBackends.const_get(backend_name.camelize).new(self)
end
def spreadsheet_url
backend.url
end
def create_spreadsheet
sheets = Google::Apis::SheetsV4::SheetsService.new
sheets.authorization = user.google_authorization
create_object = Google::Apis::SheetsV4::Spreadsheet.new(properties: { title: title})
spreadsheet = sheets.create_spreadsheet(create_object)
update(google_spreadsheet_id: spreadsheet.spreadsheet_id)
backend.create
end
def spreadsheet_service
@spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s|
s.authorization = user.google_authorization
end
def append_to_spreadsheet(data)
backend.append(data)
end
def header_values
@header_values ||= begin
values = spreadsheet_service.get_spreadsheet_values(google_spreadsheet_id, 'A1:An').values
# if there are no headers yet, return an empty array
if values
values[0].map(&:strip)
else
[]
end
end
end
def append(data)
data = data.with_indifferent_access
check_spreadsheed_headers!(data)
values = header_values.map { |key| data[key] }
range = "A1:A#{COLUMN_INDEX_TO_LETTER[values.length]}1"
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [values], major_dimension: 'ROWS')
spreadsheet_service.append_spreadsheet_value(google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
end
def check_spreadsheed_headers!(data)
missing_headers = data.keys.map { |k| k.to_s.strip } - header_values
append_missing_headers(missing_headers) unless missing_headers.empty?
end
def append_missing_headers(missing_headers)
start_column = COLUMN_INDEX_TO_LETTER[header_values.length]
end_column = COLUMN_INDEX_TO_LETTER[header_values.length + missing_headers.length]
range = "#{start_column}1:#{end_column}1"
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [missing_headers], major_dimension: 'ROWS')
spreadsheet_service.update_spreadsheet_value(google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
@header_values = nil # reset header values to refresh memoization on next access
def spreadsheet_headers
backend.headers
end
def to_param
token
end
def insert_defaults
self.backend_name ||= airtable_app_key.present? ? 'airtable' : 'google_sheets'
end
end

View File

@ -2,6 +2,8 @@ class Submission < ApplicationRecord
belongs_to :form
has_many_attached :files
acts_as_sequenced scope: :form_id
validates_presence_of :data, if: :appended_at?
def process_data(submitted_data)
@ -21,6 +23,10 @@ class Submission < ApplicationRecord
JSON.dump(value)
when 'tinyforms_now'
Time.now.utc.to_formatted_s(:rfc822)
when 'tinyforms_token'
form.token
when 'tinyforms_id'
sequential_id
when ActionDispatch::Http::UploadedFile
# manually create the ActiveStorage attachment because we need the ID of the Attachment to create the URL
# first the file needs to be uplaoded then we can create an Attachment
@ -31,14 +37,14 @@ class Submission < ApplicationRecord
attachment = ActiveStorage::Attachment.new(record: self, name: 'files', blob: create_one.blob)
attachment.save
# return the URL that we use to show in the Spreadsheet
Rails.application.routes.url_helpers.file_upload_url(form_id: form, submission_id: self, id: attachment.id, host: DEFAULT_HOST)
Rails.application.routes.url_helpers.file_upload_url(form_id: form, submission_id: self, id: attachment.token, host: DEFAULT_HOST)
else
value.to_s
end
end
def append_to_spreadsheet
result = form.append(data)
update_column(:appended_at, Time.current) if result.updates.updated_rows > 0
form.append_to_spreadsheet(data) &&
update_column(:appended_at, Time.current)
end
end

View File

@ -1,40 +1,18 @@
class User < ApplicationRecord
authenticates_with_sorcery!
has_many :authentications, dependent: :destroy
has_many :forms, dependent: :destroy
def self.find_by_oauth_info(auth_client)
oauth = Google::Apis::Oauth2V2::Oauth2Service.new
oauth.authorization = auth_client
user_info = oauth.get_userinfo
if user = User.find_by(google_id: user_info.id)
authentication = user.authentications.last
authentication.access_token = auth_client.access_token if auth_client.access_token.present?
authentication.refresh_token = auth_client.refresh_token if auth_client.refresh_token.present?
authentication.expires_at = Time.at(auth_client.expires_at) if auth_client.expires_at.present?
authentication.save
return user, authentication
else
user = User.create(name: user_info.name, email: user_info.email, google_id: user_info.id)
authentication = user.authentications.create(
access_token: auth_client.access_token,
refresh_token: auth_client.refresh_token,
expires_at: Time.at(auth_client.expires_at)
)
return user, authentication
end
end
def deactivate!(reason = nil)
# currently we only use deactivate if we get an authentication exception appending data to a spreadsheet
authentications.last&.update(expires_at: Time.current)
end
def active?
authentications.last.present? && !authentications.last.expired?
authentications.any? { |a| !a.expired? }
end
def google_authorization
authentications.last.google_authorization
authentications.for(:google).last.google_authorization
end
end

View File

@ -25,7 +25,7 @@
</div>
<div class="media-right has-text-right is-size-4">
<figure class="image is-48x48 ">
<%= link_to form.google_spreadsheet_url, {class: "has-text-success", target: "__blank"} do %>
<%= link_to form.spreadsheet_url, {class: "has-text-success", target: "__blank"} do %>
<i class="far fa-file-excel"></i>
<% end %>
</figure>

View File

@ -3,12 +3,12 @@
<%= submission_url(@form) %>
</p>
<p>
<%= link_to 'Google spreadsheet', @form.google_spreadsheet_url %>
<%= link_to 'Google spreadsheet', @form.spreadsheet_url %>
</p>
<table>
<thead>
<tr>
<% @form.header_values.each do |value| %>
<% @form.spreadsheet_headers.each do |value| %>
<th><%= value %></th>
<% end %>
</tr>
@ -16,7 +16,7 @@
<tbody>
<% @submissions.each do |submission| %>
<tr>
<% @form.header_values.each do |column| %>
<% @form.spreadsheet_headers.each do |column| %>
<td><%= submission.data[column] %></td>
<% end %>
</tr>

View File

@ -0,0 +1,88 @@
<section class="section demo">
<div class="container content">
<div class="columns">
<div class="column is-one-third">
<form class="form demo-form" id="demo-form" action="<%= submission_url(DEMO_FORM) %>" method="POST" enctype="multipart/form-data" data-tinyforms="true">
<input type="hidden" name="ID" value="tinyforms_id">
<h3>Demo</h3>
<p>
This short form is connected to the embedded <%= link_to 'document', DEMO_FORM.spreadsheet_url %> on the right. <br>
Submit the form and see the update of the document in realtime.
</p>
<div id="demo-success" style="display:none;">
<p class="is-size-4 has-text-success">
<span id="demo-submission-name"></span>, thanks for your submission!
</p>
<p>See your entry in the spreadsheet?!</p>
<p>
<%= link_to "Create your form now!", signup_url, class: 'button' %>
<br>
or got <%= link_to 'further questions?', contact_url %>
</p>
</div>
<div id="demo-fields">
<div class="field">
<label class="label">What's your name?*</label>
<div class="control">
<input class="input" type="text" name="Name" required="true" placeholder="Your name">
</div>
</div>
<div class="field">
<label class="label">Where are you from?*</label>
<div class="control">
<div class="select">
<select name="Continent">
<option value="Africa">Africa</option>
<option value="Antartica">Antartica</option>
<option value="Asia">Asia</option>
<option value="Australia">Australia</option>
<option value="Europe">Europe</option>
<option value="North America">North America</option>
<option value="South America">South America</option>
<option value="Australia">Australia</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Do you like Pizza with Pinapples?*</label>
<div class="control">
<label class="radio">
<input type="radio" name="Pinapples Pizza?" required value="yes">
Yes
</label>
<label class="radio">
<input type="radio" name="Pinapples Pizza?" required value="no">
Hell, no!
</label>
</div>
</div>
<div class="field">
<label class="label">What's your favorit cat picture?</label>
<div class="control">
<input class="input" type="file" name="Cat Picture">
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-link" id="demo-submit">Submit</button>
</div>
</div>
</div>
</form>
</div>
<div class="column">
<iframe id="demo-sheet" style="width:100%;height:100%;" src="https://docs.google.com/spreadsheets/d/<%= DEMO_FORM.google_spreadsheet_id %>/edit?usp=sharing&nocache=<%= Time.now.to_i %>#gid=0&range=<%= DEMO_FORM.submissions.count + 1 %>:<%= DEMO_FORM.submissions.count + 2 %>"></iframe>
<p class="has-text-grey has-text-centered">Document not loading? <%= link_to 'Open it here', DEMO_FORM.spreadsheet_url %>.</p>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,29 @@
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item is-size-4 has-text-black has-text-weight-bold is-font-logo" href="/">
TinyForms
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false"
data-target="navbar-menu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbar-menu" class="navbar-menu">
<div class="navbar-end">
<%= link_to 'Help', help_url, class: 'navbar-item' %>
<div class="navbar-item">
<% if !logged_in? -%>
<%= link_to "Login", login_url, { :class => "button is-primary"} %>
<% else -%>
<%= link_to "Logout", logout_url, { :class => "button is-light"} %>
<% end -%>
</div>
</div>
</div>
</div>
</nav>

View File

@ -11,49 +11,12 @@
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<!-- Wrapper -->
<div id="wrapper" class="has-text-centered-mobile">
<body class="has-text-centered-mobile">
<!-- Hero -->
<section class="hero is-medium is-light">
<div class="hero-head">
<div class="container">
<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item is-size-4 has-text-black has-text-weight-bold is-font-logo" href="/">
TinyForms
</a>
<a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false"
data-target="navbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbar" class="navbar-menu">
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<% if !logged_in? -%>
<%= link_to "Login", login_url, { :class => "button is-primary"} %>
<% else -%>
<%= link_to "Logout", logout_url, { :class => "button is-light"} %>
<% end -%>
</div>
</div>
</div>
</div>
</nav>
</div>
</section>
<main>
<%= yield %>
</main>
</div>
<%= render 'layouts/header' %>
<main>
<%= yield %>
</main>
</body>
</html>

View File

@ -0,0 +1,22 @@
<div class="columns">
<div class="column">
</div>
<div class="column">
<div class="container has-text-centered">
<h1 class="title has-text-centered">
Login
</h1>
<%= link_to auth_at_provider_url(provider: 'google'), class: 'button google' do %>
<svg viewBox="0 0 15 15" class="googleLogo" style="width: 14px; height: 14px; display: block; fill: currentcolor; flex-shrink: 0; backface-visibility: hidden; margin-right: 6px;"><path d="M 7.28571 6.4125L 7.28571 9L 11.3929 9C 11.2143 10.0875 10.1429 12.225 7.28571 12.225C 4.78571 12.225 2.78571 10.0875 2.78571 7.5C 2.78571 4.9125 4.82143 2.775 7.28571 2.775C 8.71429 2.775 9.64286 3.4125 10.1786 3.9375L 12.1429 1.9875C 10.8929 0.75 9.25 0 7.28571 0C 3.25 0 0 3.3375 0 7.5C 0 11.6625 3.25 15 7.28571 15C 11.5 15 14.25 11.9625 14.25 7.6875C 14.25 7.2 14.2143 6.825 14.1429 6.45L 7.28571 6.45L 7.28571 6.4125Z"></path></svg>
Login with Google
<% end %>
<hr>
<p>
As tinyforms builds on Google Sheets.<br>
Simply login with your Google account.
</p>
</div>
</div>
<div class="column">
</div>
</div>

View File

@ -15,6 +15,17 @@ module Tinyform
# Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application.
config.generators do |generate|
generate.helper false
generate.javascript_engine false
generate.request_specs false
generate.routing_specs false
generate.stylesheets false
generate.view_specs false
end
config.autoload_paths << Rails.root.join('lib')
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'

View File

@ -0,0 +1,3 @@
Rails.configuration.to_prepare do
ActiveStorage::Attachment.send(:has_secure_token)
end

View File

@ -0,0 +1 @@
DEMO_FORM = Form.find_by(id: ENV['DEMO_FORM_ID'])

View File

@ -0,0 +1,532 @@
# The first thing you need to configure is which modules you need in your app.
# The default is nothing which will include only core features (password encryption, login/logout).
#
# Available submodules are: :user_activation, :http_basic_auth, :remember_me,
# :reset_password, :session_timeout, :brute_force_protection, :activity_logging,
# :magic_login, :external
Rails.application.config.sorcery.submodules = [:reset_password, :external, :magic_login]
# Here you can configure each submodule's features.
Rails.application.config.sorcery.configure do |config|
# -- core --
# What controller action to call for non-authenticated users. You can also
# override the 'not_authenticated' method of course.
# Default: `:not_authenticated`
#
# config.not_authenticated_action =
# When a non logged-in user tries to enter a page that requires login, save
# the URL he wants to reach, and send him there after login, using 'redirect_back_or_to'.
# Default: `true`
#
# config.save_return_to_url =
# Set domain option for cookies; Useful for remember_me submodule.
# Default: `nil`
#
# config.cookie_domain =
# Allow the remember_me cookie to be set through AJAX
# Default: `true`
#
# config.remember_me_httponly =
# Set token randomness. (e.g. user activation tokens)
# The length of the result string is about 4/3 of `token_randomness`.
# Default: `15`
#
# config.token_randomness =
# -- session timeout --
# How long in seconds to keep the session alive.
# Default: `3600`
#
# config.session_timeout =
# Use the last action as the beginning of session timeout.
# Default: `false`
#
# config.session_timeout_from_last_action =
# Invalidate active sessions. Requires an `invalidate_sessions_before` timestamp column
# Default: `false`
#
# config.session_timeout_invalidate_active_sessions_enabled =
# -- http_basic_auth --
# What realm to display for which controller name. For example {"My App" => "Application"}
# Default: `{"application" => "Application"}`
#
# config.controller_to_realm_map =
# -- activity logging --
# Will register the time of last user login, every login.
# Default: `true`
#
# config.register_login_time =
# Will register the time of last user logout, every logout.
# Default: `true`
#
# config.register_logout_time =
# Will register the time of last user action, every action.
# Default: `true`
#
# config.register_last_activity_time =
# -- external --
# What providers are supported by this app
# i.e. [:twitter, :facebook, :github, :linkedin, :xing, :google, :liveid, :salesforce, :slack, :line].
# Default: `[]`
#
config.external_providers = [:google]
# You can change it by your local ca_file. i.e. '/etc/pki/tls/certs/ca-bundle.crt'
# Path to ca_file. By default use a internal ca-bundle.crt.
# Default: `'path/to/ca_file'`
#
# config.ca_file =
# config.linkedin.key = ""
# config.linkedin.secret = ""
# config.linkedin.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=linkedin"
# config.linkedin.user_info_mapping = {first_name: "firstName", last_name: "lastName"}
# config.linkedin.scope = "r_basicprofile"
#
#
# For information about XING API:
# - user info fields go to https://dev.xing.com/docs/get/users/me
#
# config.xing.key = ""
# config.xing.secret = ""
# config.xing.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=xing"
# config.xing.user_info_mapping = {first_name: "first_name", last_name: "last_name"}
#
#
# Twitter will not accept any requests nor redirect uri containing localhost,
# Make sure you use 0.0.0.0:3000 to access your app in development
#
# config.twitter.key = ""
# config.twitter.secret = ""
# config.twitter.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=twitter"
# config.twitter.user_info_mapping = {:email => "screen_name"}
#
# config.facebook.key = ""
# config.facebook.secret = ""
# config.facebook.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=facebook"
# config.facebook.user_info_path = "me?fields=email"
# config.facebook.user_info_mapping = {:email => "email"}
# config.facebook.access_permissions = ["email"]
# config.facebook.display = "page"
# config.facebook.api_version = "v2.3"
# config.facebook.parse = :json
#
# config.instagram.key = ""
# config.instagram.secret = ""
# config.instagram.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=instagram"
# config.instagram.user_info_mapping = {:email => "username"}
# config.instagram.access_permissions = ["basic", "public_content", "follower_list", "comments", "relationships", "likes"]
#
# config.github.key = ""
# config.github.secret = ""
# config.github.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=github"
# config.github.user_info_mapping = {:email => "name"}
# config.github.scope = ""
#
# config.paypal.key = ""
# config.paypal.secret = ""
# config.paypal.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=paypal"
# config.paypal.user_info_mapping = {:email => "email"}
#
# config.wechat.key = ""
# config.wechat.secret = ""
# config.wechat.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=wechat"
#
# For Auth0, site is required and should match the domain provided by Auth0.
#
# config.auth0.key = ""
# config.auth0.secret = ""
# config.auth0.callback_url = "https://0.0.0.0:3000/oauth/callback?provider=auth0"
# config.auth0.site = "https://example.auth0.com"
#
config.google.key = ENV['GOOGLE_CLIENT_ID']
config.google.secret = ENV['GOOGLE_CLIENT_SECRET']
config.google.callback_url = (ENV['GOOGLE_AUTH_CALLBACK_URL'] || "http://localhost:3000/oauth/callback?provider=google")
config.google.user_info_mapping = {:email => "email", :name => "name", :google_id => "id"}
config.google.scope = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/spreadsheets"
config.google.auth_url = '/o/oauth2/auth?access_type=offline&include_granted_scopes=true'
#
# For Microsoft Graph, the key will be your App ID, and the secret will be your app password/public key.
# The callback URL "can't contain a query string or invalid special characters"
# See: https://docs.microsoft.com/en-us/azure/active-directory/active-directory-v2-limitations#restrictions-on-redirect-uris
# More information at https://graph.microsoft.io/en-us/docs
#
# config.microsoft.key = ""
# config.microsoft.secret = ""
# config.microsoft.callback_url = "http://0.0.0.0:3000/oauth/callback/microsoft"
# config.microsoft.user_info_mapping = {:email => "userPrincipalName", :username => "displayName"}
# config.microsoft.scope = "openid email https://graph.microsoft.com/User.Read"
#
# config.vk.key = ""
# config.vk.secret = ""
# config.vk.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=vk"
# config.vk.user_info_mapping = {:login => "domain", :name => "full_name"}
# config.vk.api_version = "5.71"
#
# config.slack.callback_url = "http://0.0.0.0:3000/oauth/callback?provider=slack"
# config.slack.key = ''
# config.slack.secret = ''
# config.slack.user_info_mapping = {email: 'email'}
#
# To use liveid in development mode you have to replace mydomain.com with
# a valid domain even in development. To use a valid domain in development
# simply add your domain in your /etc/hosts file in front of 127.0.0.1
#
# config.liveid.key = ""
# config.liveid.secret = ""
# config.liveid.callback_url = "http://mydomain.com:3000/oauth/callback?provider=liveid"
# config.liveid.user_info_mapping = {:username => "name"}
# For information about JIRA API:
# https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+OAuth+authentication
# To obtain the consumer key and the public key you can use the jira-ruby gem https://github.com/sumoheavy/jira-ruby
# or run openssl req -x509 -nodes -newkey rsa:1024 -sha1 -keyout rsakey.pem -out rsacert.pem to obtain the public key
# Make sure you have configured the application link properly
# config.jira.key = "1234567"
# config.jira.secret = "jiraTest"
# config.jira.site = "http://localhost:2990/jira/plugins/servlet/oauth"
# config.jira.signature_method = "RSA-SHA1"
# config.jira.private_key_file = "rsakey.pem"
# For information about Salesforce API:
# https://developer.salesforce.com/signup &
# https://www.salesforce.com/us/developer/docs/api_rest/
# Salesforce callback_url must be https. You can run the following to generate self-signed ssl cert:
# openssl req -new -newkey rsa:2048 -sha1 -days 365 -nodes -x509 -keyout server.key -out server.crt
# Make sure you have configured the application link properly
# config.salesforce.key = '123123'
# config.salesforce.secret = 'acb123'
# config.salesforce.callback_url = "https://127.0.0.1:9292/oauth/callback?provider=salesforce"
# config.salesforce.scope = "full"
# config.salesforce.user_info_mapping = {:email => "email"}
# config.line.key = ""
# config.line.secret = ""
# config.line.callback_url = "http://mydomain.com:3000/oauth/callback?provider=line"
# --- user config ---
config.user_config do |user|
# -- core --
# Specify username attributes, for example: [:username, :email].
# Default: `[:email]`
#
# user.username_attribute_names =
# Change *virtual* password attribute, the one which is used until an encrypted one is generated.
# Default: `:password`
#
# user.password_attribute_name =
# Downcase the username before trying to authenticate, default is false
# Default: `false`
#
# user.downcase_username_before_authenticating =
# Change default email attribute.
# Default: `:email`
#
# user.email_attribute_name =
# Change default crypted_password attribute.
# Default: `:crypted_password`
#
# user.crypted_password_attribute_name =
# What pattern to use to join the password with the salt
# Default: `""`
#
# user.salt_join_token =
# Change default salt attribute.
# Default: `:salt`
#
# user.salt_attribute_name =
# How many times to apply encryption to the password.
# Default: 1 in test env, `nil` otherwise
#
user.stretches = 1 if Rails.env.test?
# Encryption key used to encrypt reversible encryptions such as AES256.
# WARNING: If used for users' passwords, changing this key will leave passwords undecryptable!
# Default: `nil`
#
# user.encryption_key =
# Use an external encryption class.
# Default: `nil`
#
# user.custom_encryption_provider =
# Encryption algorithm name. See 'encryption_algorithm=' for available options.
# Default: `:bcrypt`
#
# user.encryption_algorithm =
# Make this configuration inheritable for subclasses. Useful for ActiveRecord's STI.
# Default: `false`
#
# user.subclasses_inherit_config =
# -- remember_me --
# How long in seconds the session length will be
# Default: `60 * 60 * 24 * 7`
#
# user.remember_me_for =
# When true, sorcery will persist a single remember me token for all
# logins/logouts (to support remembering on multiple browsers simultaneously).
# Default: false
#
# user.remember_me_token_persist_globally =
# -- user_activation --
# The attribute name to hold activation state (active/pending).
# Default: `:activation_state`
#
# user.activation_state_attribute_name =
# The attribute name to hold activation code (sent by email).
# Default: `:activation_token`
#
# user.activation_token_attribute_name =
# The attribute name to hold activation code expiration date.
# Default: `:activation_token_expires_at`
#
# user.activation_token_expires_at_attribute_name =
# How many seconds before the activation code expires. nil for never expires.
# Default: `nil`
#
# user.activation_token_expiration_period =
# REQUIRED:
# User activation mailer class.
# Default: `nil`
#
# user.user_activation_mailer =
# When true, sorcery will not automatically
# send the activation details email, and allow you to
# manually handle how and when the email is sent.
# Default: `false`
#
# user.activation_mailer_disabled =
# Method to send email related
# options: `:deliver_later`, `:deliver_now`, `:deliver`
# Default: :deliver (Rails version < 4.2) or :deliver_now (Rails version 4.2+)
#
# user.email_delivery_method =
# Activation needed email method on your mailer class.
# Default: `:activation_needed_email`
#
# user.activation_needed_email_method_name =
# Activation success email method on your mailer class.
# Default: `:activation_success_email`
#
# user.activation_success_email_method_name =
# Do you want to prevent users who did not activate by email from logging in?
# Default: `true`
#
# user.prevent_non_active_users_to_login =
# -- reset_password --
# Password reset token attribute name.
# Default: `:reset_password_token`
#
# user.reset_password_token_attribute_name =
# Password token expiry attribute name.
# Default: `:reset_password_token_expires_at`
#
# user.reset_password_token_expires_at_attribute_name =
# When was password reset email sent. Used for hammering protection.
# Default: `:reset_password_email_sent_at`
#
# user.reset_password_email_sent_at_attribute_name =
# REQUIRED:
# Password reset mailer class.
# Default: `nil`
#
# user.reset_password_mailer =
# Reset password email method on your mailer class.
# Default: `:reset_password_email`
#
# user.reset_password_email_method_name =
# When true, sorcery will not automatically
# send the password reset details email, and allow you to
# manually handle how and when the email is sent
# Default: `false`
#
# user.reset_password_mailer_disabled =
# How many seconds before the reset request expires. nil for never expires.
# Default: `nil`
#
# user.reset_password_expiration_period =
# Hammering protection: how long in seconds to wait before allowing another email to be sent.
# Default: `5 * 60`
#
# user.reset_password_time_between_emails =
# Access counter to a reset password page attribute name
# Default: `:access_count_to_reset_password_page`
#
# user.reset_password_page_access_count_attribute_name =
# -- magic_login --
# Magic login code attribute name.
# Default: `:magic_login_token`
#
# user.magic_login_token_attribute_name =
# Magic login expiry attribute name.
# Default: `:magic_login_token_expires_at`
#
# user.magic_login_token_expires_at_attribute_name =
# When was magic login email sent — used for hammering protection.
# Default: `:magic_login_email_sent_at`
#
# user.magic_login_email_sent_at_attribute_name =
# REQUIRED:
# Magic login mailer class.
# Default: `nil`
#
# user.magic_login_mailer_class =
# Magic login email method on your mailer class.
# Default: `:magic_login_email`
#
# user.magic_login_email_method_name =
# When true, sorcery will not automatically
# send magic login details email, and allow you to
# manually handle how and when the email is sent
# Default: `true`
#
# user.magic_login_mailer_disabled =
# How many seconds before the request expires. nil for never expires.
# Default: `nil`
#
# user.magic_login_expiration_period =
# Hammering protection: how long in seconds to wait before allowing another email to be sent.
# Default: `5 * 60`
#
# user.magic_login_time_between_emails =
# -- brute_force_protection --
# Failed logins attribute name.
# Default: `:failed_logins_count`
#
# user.failed_logins_count_attribute_name =
# This field indicates whether user is banned and when it will be active again.
# Default: `:lock_expires_at`
#
# user.lock_expires_at_attribute_name =
# How many failed logins are allowed.
# Default: `50`
#
# user.consecutive_login_retries_amount_limit =
# How long the user should be banned, in seconds. 0 for permanent.
# Default: `60 * 60`
#
# user.login_lock_time_period =
# Unlock token attribute name
# Default: `:unlock_token`
#
# user.unlock_token_attribute_name =
# Unlock token mailer method
# Default: `:send_unlock_token_email`
#
# user.unlock_token_email_method_name =
# When true, sorcery will not automatically
# send email with the unlock token
# Default: `false`
#
# user.unlock_token_mailer_disabled = true
# REQUIRED:
# Unlock token mailer class.
# Default: `nil`
#
# user.unlock_token_mailer =
# -- activity logging --
# Last login attribute name.
# Default: `:last_login_at`
#
# user.last_login_at_attribute_name =
# Last logout attribute name.
# Default: `:last_logout_at`
#
# user.last_logout_at_attribute_name =
# Last activity attribute name.
# Default: `:last_activity_at`
#
# user.last_activity_at_attribute_name =
# How long since user's last activity will they be considered logged out?
# Default: `10 * 60`
#
# user.activity_timeout =
# -- external --
# Class which holds the various external provider data for this user.
# Default: `nil`
#
user.authentications_class = Authentication
# User's identifier in the `authentications` class.
# Default: `:user_id`
#
# user.authentications_user_id_attribute_name =
# Provider's identifier in the `authentications` class.
# Default: `:provider`
#
# user.provider_attribute_name =
# User's external unique identifier in the `authentications` class.
# Default: `:uid`
#
# user.provider_uid_attribute_name =
end
# This line must come after the 'user config' block.
# Define which model authenticates with sorcery.
config.user_class = "User"
end

View File

@ -13,9 +13,18 @@ Rails.application.routes.draw do
# short URL for form page
get '/s/:id/form' => 'forms#form', as: :form_submitter
get 'oauth/callback', to: 'oauths#callback'
get 'oauth/:provider', to: 'oauths#oauth', as: :auth_at_provider
get '/signup' => 'sessions#new', as: :signup # TODO: add proper signup page
get '/login' => 'sessions#new', as: :login
get '/logout' => 'sessions#destroy', as: :logout
get '/auth' => 'sessions#auth', as: :auth
get '/demo' => 'home#demo', as: :demo
get '/contact' => 'home#contact', as: :contact
get '/help', to: redirect('https://www.notion.so/Tinyforms-Help-Center-04f13b5908bc46cfb4283079a3cb1149')
root 'home#index'
end

View File

@ -0,0 +1,5 @@
class AddSequentialIdToSubmissions < ActiveRecord::Migration[6.0]
def change
add_column :submissions, :sequential_id, :integer
end
end

View File

@ -0,0 +1,6 @@
class AddTokenToAttachments < ActiveRecord::Migration[6.0]
def change
add_column :active_storage_attachments, :token, :string
add_index :active_storage_attachments, :token, unique: true
end
end

View File

@ -0,0 +1,15 @@
class SorceryCore < ActiveRecord::Migration[6.0]
def change
add_column :users, :crypted_password, :string
add_column :users, :salt, :string
add_column :users, :magic_login_token, :string
add_column :users, :magic_login_token_expires_at, :datetime
add_column :users, :magic_login_email_sent_at, :datetime
add_column :authentications, :provider, :string, null: false
add_column :authentications, :uid, :string, null: false
add_index :users, :magic_login_token
add_index :users, :email, unique: true
end
end

View File

@ -0,0 +1,8 @@
class AddAirtableSupport < ActiveRecord::Migration[6.0]
def change
add_column :forms, :airtable_app_key_ciphertext, :string
add_column :forms, :airtable_api_key_ciphertext, :string
add_column :forms, :airtable_table, :string
add_column :forms, :backend_name, :string
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_04_09_001610) do
ActiveRecord::Schema.define(version: 2020_04_13_152532) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -21,8 +21,10 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "token"
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
t.index ["token"], name: "index_active_storage_attachments_on_token", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
@ -43,6 +45,8 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
t.datetime "updated_at", precision: 6, null: false
t.text "access_token_ciphertext"
t.text "refresh_token_ciphertext"
t.string "provider"
t.string "uid"
end
create_table "forms", force: :cascade do |t|
@ -53,6 +57,10 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
t.string "thank_you_url"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.string "airtable_app_key_ciphertext"
t.string "airtable_api_key_ciphertext"
t.string "airtable_table"
t.string "backend_name"
end
create_table "submissions", force: :cascade do |t|
@ -63,6 +71,7 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
t.datetime "updated_at", precision: 6, null: false
t.string "remote_ip"
t.string "referrer"
t.integer "sequential_id"
end
create_table "users", force: :cascade do |t|
@ -71,6 +80,13 @@ ActiveRecord::Schema.define(version: 2020_04_09_001610) do
t.string "google_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.string "crypted_password"
t.string "salt"
t.string "magic_login_token"
t.datetime "magic_login_token_expires_at"
t.datetime "magic_login_email_sent_at"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["magic_login_token"], name: "index_users_on_magic_login_token"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"

View File

@ -0,0 +1,36 @@
require 'airrecord'
module SpreadsheetBackends
class Airtable
attr_accessor :form, :user
def initialize(form)
@form = form
@user = form.user
end
def url
"https://airtable.com/#{form.airtable_app_key}"
end
def append(data)
result = table.create(data, typecast: true)
result.id.present?
rescue Airrecord::Error => e
return false
end
def create
# Airtable must already exist
# TODO: maybe add validation here?
end
def headers
table.records.first&.fields&.keys # we only know the headers once we have at least one record
end
def table
@table ||= Airrecord.table(form.airtable_api_key, form.airtable_app_key, form.airtable_table)
end
end
end

View File

@ -0,0 +1,71 @@
require 'google/apis/sheets_v4'
module SpreadsheetBackends
class GoogleSheets
# Hash to translate an index to a A1 notation. e.g. 1 => 'B', 27 => 'AA'
COLUMN_INDEX_TO_LETTER = Hash.new {|hash,key| hash[key] = hash[key - 1].next }.merge({0 => "A"})
attr_accessor :form, :user
def initialize(form)
@form = form
@user = form.user
end
def url
"https://docs.google.com/spreadsheets/d/#{form.google_spreadsheet_id}/edit" if form.google_spreadsheet_id.present?
end
def append(data)
data = data.with_indifferent_access
check_spreadsheed_headers!(data)
values = headers.map { |key| data[key] }
range = "A1:A#{COLUMN_INDEX_TO_LETTER[values.length]}1"
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [values], major_dimension: 'ROWS')
result = spreadsheet_service.append_spreadsheet_value(form.google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
result.updates.updated_rows > 0
end
def create
sheets = Google::Apis::SheetsV4::SheetsService.new
sheets.authorization = user.google_authorization
create_object = Google::Apis::SheetsV4::Spreadsheet.new(properties: { title: form.title})
spreadsheet = sheets.create_spreadsheet(create_object)
form.update(google_spreadsheet_id: spreadsheet.spreadsheet_id)
end
def headers
@headers ||= begin
values = spreadsheet_service.get_spreadsheet_values(form.google_spreadsheet_id, 'A1:An').values
# if there are no headers yet, return an empty array
if values
values[0].map(&:strip)
else
[]
end
end
end
def spreadsheet_service
@spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s|
s.authorization = user.google_authorization
end
end
def check_spreadsheed_headers!(data)
missing_headers = data.keys.map { |k| k.to_s.strip } - headers
append_missing_headers(missing_headers) unless missing_headers.empty?
end
def append_missing_headers(missing_headers)
start_column = COLUMN_INDEX_TO_LETTER[headers.length]
end_column = COLUMN_INDEX_TO_LETTER[headers.length + missing_headers.length]
range = "#{start_column}1:#{end_column}1"
value_range = Google::Apis::SheetsV4::ValueRange.new(values: [missing_headers], major_dimension: 'ROWS')
spreadsheet_service.update_spreadsheet_value(form.google_spreadsheet_id, range, value_range, value_input_option: 'USER_ENTERED')
@headers = nil # reset header values to refresh memoization on next access
end
end
end