Add support for Airtable
This commit is contained in:
parent
80fe5f4220
commit
edf6884647
2
Gemfile
2
Gemfile
@ -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'
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<input type="hidden" name="ID" value="tinyforms_id">
|
<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>
|
||||||
|
|
||||||
@ -80,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>
|
||||||
|
@ -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 '*'
|
||||||
|
8
db/migrate/20200413152532_add_airtable_support.rb
Normal file
8
db/migrate/20200413152532_add_airtable_support.rb
Normal 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
|
@ -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|
|
||||||
|
36
lib/spreadsheet_backends/airtable.rb
Normal file
36
lib/spreadsheet_backends/airtable.rb
Normal 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
|
71
lib/spreadsheet_backends/google_sheets.rb
Normal file
71
lib/spreadsheet_backends/google_sheets.rb
Normal 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
|
Loading…
x
Reference in New Issue
Block a user