Add support for Airtable

This commit is contained in:
bumi 2020-04-13 22:59:46 +02:00
parent 80fe5f4220
commit edf6884647
12 changed files with 179 additions and 60 deletions

View File

@ -24,7 +24,7 @@ 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'

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)
@ -80,6 +83,7 @@ GEM
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)
@ -136,6 +140,8 @@ GEM
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)
@ -252,6 +258,7 @@ PLATFORMS
ruby
DEPENDENCIES
airrecord
aws-sdk-s3
bootsnap (>= 1.4.2)
byebug

View File

@ -1,15 +1,21 @@
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
@ -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

@ -44,7 +44,7 @@ class Submission < ApplicationRecord
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

@ -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

@ -6,7 +6,7 @@
<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.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.
</p>
@ -80,7 +80,7 @@
<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.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>

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,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_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
enable_extension "plpgsql"
@ -57,6 +57,10 @@ ActiveRecord::Schema.define(version: 2020_04_13_101920) 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|

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