Merge branch 'master' into New-forms-page

This commit is contained in:
Yannick 2020-04-14 09:10:38 +02:00
commit b978d7d197
17 changed files with 197 additions and 68 deletions

View File

@ -24,7 +24,7 @@ gem 'bootsnap', '>= 1.4.2', require: false
gem 'lockbox' gem 'lockbox'
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
# gem 'airrecord' gem 'airrecord'
gem 'google-api-client' gem 'google-api-client'
gem 'rack-cors' gem 'rack-cors'
gem 'sentry-raven' gem 'sentry-raven'

View File

@ -58,6 +58,9 @@ GEM
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
addressable (2.7.0) addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.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-eventstream (1.0.3)
aws-partitions (1.263.0) aws-partitions (1.263.0)
aws-sdk-core (3.89.1) aws-sdk-core (3.89.1)
@ -80,6 +83,7 @@ GEM
builder (3.2.4) builder (3.2.4)
byebug (11.1.1) byebug (11.1.1)
concurrent-ruby (1.1.6) concurrent-ruby (1.1.6)
connection_pool (2.2.2)
crass (1.0.6) crass (1.0.6)
declarative (0.0.10) declarative (0.0.10)
declarative-option (0.1.0) declarative-option (0.1.0)
@ -136,6 +140,8 @@ GEM
multi_json (1.14.1) multi_json (1.14.1)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-http-persistent (3.1.0)
connection_pool (~> 2.2)
nio4r (2.5.2) nio4r (2.5.2)
nokogiri (1.10.9) nokogiri (1.10.9)
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
@ -252,6 +258,7 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
airrecord
aws-sdk-s3 aws-sdk-s3
bootsnap (>= 1.4.2) bootsnap (>= 1.4.2)
byebug byebug

View File

@ -3,10 +3,13 @@ document.addEventListener('DOMContentLoaded', function() {
if (!demoForm) { if (!demoForm) {
return; return;
} }
demoForm.addEventListener('submit', function(e) {
var submitButton = document.getElementById('demo-submit');
submitButton.innerText = 'loading...';
submitButton.disabled = 'true'
});
demoForm.addEventListener('tinyforms:submitted', function(e) { demoForm.addEventListener('tinyforms:submitted', function(e) {
console.log(e);
console.log(e.detail);
var name = document.getElementById('demo-submission-name'); var name = document.getElementById('demo-submission-name');
var demoFields = document.getElementById('demo-fields'); var demoFields = document.getElementById('demo-fields');
var demoSucess = document.getElementById('demo-success'); var demoSucess = document.getElementById('demo-success');

View File

@ -1,15 +1,21 @@
require 'google/apis/sheets_v4'
class Form < ApplicationRecord 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 belongs_to :user
has_many :submissions, dependent: :destroy has_many :submissions, dependent: :destroy
before_validation :insert_defaults, on: :create
after_create :create_spreadsheet after_create :create_spreadsheet
has_secure_token has_secure_token
encrypts :airtable_api_key
encrypts :airtable_app_key
validates_presence_of :title 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 # TODO: use counter_cache option on association
def submissions_count def submissions_count
@ -28,63 +34,39 @@ class Form < ApplicationRecord
self.user.active? self.user.active?
end end
def google_spreadsheet_url def airtable?
"https://docs.google.com/spreadsheets/d/#{google_spreadsheet_id}/edit" if google_spreadsheet_id.present? 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 end
def create_spreadsheet def create_spreadsheet
sheets = Google::Apis::SheetsV4::SheetsService.new backend.create
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)
end end
def spreadsheet_service def append_to_spreadsheet(data)
@spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s| backend.append(data)
s.authorization = user.google_authorization
end
end end
def header_values def spreadsheet_headers
@header_values ||= begin backend.headers
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
end end
def to_param def to_param
token token
end end
def insert_defaults
self.backend_name ||= airtable_app_key.present? ? 'airtable' : 'google_sheets'
end
end end

View File

@ -44,7 +44,7 @@ class Submission < ApplicationRecord
end end
def append_to_spreadsheet def append_to_spreadsheet
result = form.append(data) form.append_to_spreadsheet(data) &&
update_column(:appended_at, Time.current) if result.updates.updated_rows > 0 update_column(:appended_at, Time.current)
end end
end end

View File

@ -25,7 +25,7 @@
</div> </div>
<div class="media-right has-text-right is-size-4"> <div class="media-right has-text-right is-size-4">
<figure class="image is-48x48 "> <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> <i class="far fa-file-excel"></i>
<% end %> <% end %>
</figure> </figure>

View File

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

View File

@ -3,9 +3,10 @@
<div class="columns"> <div class="columns">
<div class="column is-one-third"> <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"> <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> <h3>Demo</h3>
<p> <p>
This short form is connected to the embedded <%= link_to 'document', DEMO_FORM.google_spreadsheet_url %> on the right. <br> 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. Submit the form and see the update of the document in realtime.
</p> </p>
@ -15,7 +16,9 @@
</p> </p>
<p>See your entry in the spreadsheet?!</p> <p>See your entry in the spreadsheet?!</p>
<p> <p>
<%= link_to "Get started now", signup_url, class: 'button' %> <%= link_to "Create your form now!", signup_url, class: 'button' %>
<br>
or got <%= link_to 'further questions?', contact_url %>
</p> </p>
</div> </div>
@ -68,7 +71,7 @@
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control"> <div class="control">
<button class="button is-link">Submit</button> <button class="button is-link" id="demo-submit">Submit</button>
</div> </div>
</div> </div>
</div> </div>
@ -77,7 +80,7 @@
<div class="column"> <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> <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.google_spreadsheet_url %>.</p> <p class="has-text-grey has-text-centered">Document not loading? <%= link_to 'Open it here', DEMO_FORM.spreadsheet_url %>.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -15,7 +15,7 @@
<div id="navbar-menu" class="navbar-menu"> <div id="navbar-menu" class="navbar-menu">
<div class="navbar-end"> <div class="navbar-end">
<a href="https://www.notion.so/Tinyforms-Help-Center-04f13b5908bc46cfb4283079a3cb1149" class="navbar-item">Help</a> <%= link_to 'Help', help_url, class: 'navbar-item' %>
<div class="navbar-item"> <div class="navbar-item">
<% if !logged_in? -%> <% if !logged_in? -%>
<%= link_to "Login", login_url, { :class => "button is-primary"} %> <%= link_to "Login", login_url, { :class => "button is-primary"} %>

View File

@ -6,10 +6,10 @@
<h1 class="title has-text-centered"> <h1 class="title has-text-centered">
Login Login
</h1> </h1>
<div class="button google"> <%= 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> <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 Login with Google
</div> <% end %>
<hr> <hr>
<p> <p>
As tinyforms builds on Google Sheets.<br> As tinyforms builds on Google Sheets.<br>

View File

@ -15,6 +15,17 @@ module Tinyform
# Application configuration can go into files in config/initializers # Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading # -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application. # 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 config.middleware.insert_before 0, Rack::Cors do
allow do allow do
origins '*' origins '*'

View File

@ -152,7 +152,7 @@ Rails.application.config.sorcery.configure do |config|
# #
config.google.key = ENV['GOOGLE_CLIENT_ID'] config.google.key = ENV['GOOGLE_CLIENT_ID']
config.google.secret = ENV['GOOGLE_CLIENT_SECRET'] config.google.secret = ENV['GOOGLE_CLIENT_SECRET']
config.google.callback_url = "http://localhost:3000/oauth/callback?provider=google" 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.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.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' config.google.auth_url = '/o/oauth2/auth?access_type=offline&include_granted_scopes=true'

View File

@ -22,5 +22,9 @@ Rails.application.routes.draw do
get '/auth' => 'sessions#auth', as: :auth get '/auth' => 'sessions#auth', as: :auth
get '/demo' => 'home#demo', as: :demo 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' root 'home#index'
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. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_04_13_101920) do ActiveRecord::Schema.define(version: 2020_04_13_152532) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -57,6 +57,10 @@ ActiveRecord::Schema.define(version: 2020_04_13_101920) do
t.string "thank_you_url" t.string "thank_you_url"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_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 end
create_table "submissions", force: :cascade do |t| create_table "submissions", force: :cascade do |t|

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