Hello tinyforms
This commit is contained in:
2
app/assets/config/manifest.js
Normal file
2
app/assets/config/manifest.js
Normal file
@@ -0,0 +1,2 @@
|
||||
//= link_tree ../images
|
||||
//= link_directory ../stylesheets .css
|
||||
0
app/assets/images/.keep
Normal file
0
app/assets/images/.keep
Normal file
15
app/assets/stylesheets/application.css
Normal file
15
app/assets/stylesheets/application.css
Normal file
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
||||
4
app/channels/application_cable/connection.rb
Normal file
4
app/channels/application_cable/connection.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
end
|
||||
end
|
||||
10
app/controllers/application_controller.rb
Normal file
10
app/controllers/application_controller.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
|
||||
def require_login
|
||||
redirect_to login_url unless current_user.present?
|
||||
end
|
||||
|
||||
def current_user
|
||||
session[:user_id] && User.find_by(id: session[:user_id])
|
||||
end
|
||||
end
|
||||
0
app/controllers/concerns/.keep
Normal file
0
app/controllers/concerns/.keep
Normal file
33
app/controllers/forms_controller.rb
Normal file
33
app/controllers/forms_controller.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
require 'google/apis/sheets_v4'
|
||||
require 'google/api_client/client_secrets'
|
||||
class FormsController < ApplicationController
|
||||
before_action :require_login
|
||||
|
||||
def new
|
||||
@form = current_user.forms.build
|
||||
end
|
||||
|
||||
def show
|
||||
@form = current_user.forms.find_by!(token: params[:id])
|
||||
@submissions = @form.submissions
|
||||
end
|
||||
|
||||
def index
|
||||
@forms = current_user.forms
|
||||
end
|
||||
|
||||
def create
|
||||
@form = current_user.forms.build(form_params)
|
||||
if @form.save
|
||||
redirect_to form_url(@form)
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def form_params
|
||||
params.require(:form).permit(:title, :thank_you_url)
|
||||
end
|
||||
end
|
||||
4
app/controllers/home_controller.rb
Normal file
4
app/controllers/home_controller.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class HomeController < ApplicationController
|
||||
|
||||
def index; end
|
||||
end
|
||||
48
app/controllers/sessions_controller.rb
Normal file
48
app/controllers/sessions_controller.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
reset_session
|
||||
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
|
||||
25
app/controllers/submissions_controller.rb
Normal file
25
app/controllers/submissions_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
require 'google/apis/sheets_v4'
|
||||
class SubmissionsController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token
|
||||
|
||||
def create
|
||||
@form = Form.find_by!(token: params[:form_id])
|
||||
@submission = @form.submissions.build(data: data_params)
|
||||
|
||||
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 }) }
|
||||
else
|
||||
format.html
|
||||
format.json { render(json: { error: @submission.errors }, status: 422) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def data_params
|
||||
request.request_parameters
|
||||
end
|
||||
end
|
||||
2
app/helpers/application_helper.rb
Normal file
2
app/helpers/application_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module ApplicationHelper
|
||||
end
|
||||
6
app/javascript/channels/consumer.js
Normal file
6
app/javascript/channels/consumer.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Action Cable provides the framework to deal with WebSockets in Rails.
|
||||
// You can generate new channels where WebSocket features live using the `rails generate channel` command.
|
||||
|
||||
import { createConsumer } from "@rails/actioncable"
|
||||
|
||||
export default createConsumer()
|
||||
5
app/javascript/channels/index.js
Normal file
5
app/javascript/channels/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Load all the channels within this directory and all subdirectories.
|
||||
// Channel files must be named *_channel.js.
|
||||
|
||||
const channels = require.context('.', true, /_channel\.js$/)
|
||||
channels.keys().forEach(channels)
|
||||
17
app/javascript/packs/application.js
Normal file
17
app/javascript/packs/application.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// This file is automatically compiled by Webpack, along with any other files
|
||||
// present in this directory. You're encouraged to place your actual application logic in
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so it'll be compiled.
|
||||
|
||||
require("@rails/ujs").start()
|
||||
require("turbolinks").start()
|
||||
require("@rails/activestorage").start()
|
||||
require("channels")
|
||||
|
||||
|
||||
// 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' %>)
|
||||
// or the `imagePath` JavaScript helper below.
|
||||
//
|
||||
// const images = require.context('../images', true)
|
||||
// const imagePath = (name) => images(name, true)
|
||||
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: 'from@example.com'
|
||||
layout 'mailer'
|
||||
end
|
||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
end
|
||||
22
app/models/authentication.rb
Normal file
22
app/models/authentication.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Authentication < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
def expired?
|
||||
expires_at <= Time.current
|
||||
end
|
||||
|
||||
def google_authorization
|
||||
@google_authorization ||= CLIENT_SECRETS.to_authorization.tap do |c|
|
||||
c.access_token = self.access_token
|
||||
c.refresh_token = self.refresh_token
|
||||
c.expires_at = self.expires_at
|
||||
if expires_at < 1.minute.from_now
|
||||
c.refresh!
|
||||
self.access_token = c.access_token if c.access_token.present?
|
||||
self.refresh_token = c.refresh_token if c.refresh_token.present?
|
||||
self.expires_at = Time.at(c.expires_at) if c.expires_at.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
69
app/models/form.rb
Normal file
69
app/models/form.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
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
|
||||
after_create :create_spreadsheet
|
||||
|
||||
has_secure_token
|
||||
|
||||
validates_presence_of :title
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
def spreadsheet_service
|
||||
@spreadsheet_service ||= Google::Apis::SheetsV4::SheetsService.new.tap do |s|
|
||||
s.authorization = user.google_authorization
|
||||
end
|
||||
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
|
||||
end
|
||||
|
||||
def to_param
|
||||
token
|
||||
end
|
||||
|
||||
end
|
||||
18
app/models/submission.rb
Normal file
18
app/models/submission.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class Submission < ApplicationRecord
|
||||
belongs_to :form
|
||||
after_create :append_to_spreadsheet
|
||||
validates_presence_of :data
|
||||
|
||||
def data=(value)
|
||||
sanitized_data = {}
|
||||
value.each do |key, value|
|
||||
sanitized_data[key] = value.to_s
|
||||
end
|
||||
write_attribute(:data, sanitized_data)
|
||||
end
|
||||
|
||||
def append_to_spreadsheet
|
||||
result = form.append(data)
|
||||
update_column(:appended_at, Time.current) if result.updates.updated_rows > 0
|
||||
end
|
||||
end
|
||||
26
app/models/user.rb
Normal file
26
app/models/user.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class User < ApplicationRecord
|
||||
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)
|
||||
return user, user.authentications.last
|
||||
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 google_authorization
|
||||
authentications.last.google_authorization
|
||||
end
|
||||
end
|
||||
5
app/views/forms/index.html.erb
Normal file
5
app/views/forms/index.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<%= link_to "new form", new_form_url %>
|
||||
<hr>
|
||||
<% @forms.each do |form| %>
|
||||
<%= link_to form.title, form_url(form) %>
|
||||
<% end %>
|
||||
5
app/views/forms/new.html.erb
Normal file
5
app/views/forms/new.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<%= form_for @form do |f| %>
|
||||
<%= f.text_field :title %>
|
||||
<%= f.text_field :thank_you_url %>
|
||||
<%= f.submit %>
|
||||
<% end %>
|
||||
18
app/views/forms/show.html.erb
Normal file
18
app/views/forms/show.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<% @form.header_values.each do |value| %>
|
||||
<th><%= value %></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @submissions.each do |submission| %>
|
||||
<tr>
|
||||
<% @form.header_values.each do |column| %>
|
||||
<td><%= submission.data[column] %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
3
app/views/home/index.html.erb
Normal file
3
app/views/home/index.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<h1>Welcome</h1>
|
||||
|
||||
<%= link_to "Login", login_url %>
|
||||
15
app/views/layouts/application.html.erb
Normal file
15
app/views/layouts/application.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tinyform</title>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
|
||||
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
13
app/views/layouts/mailer.html.erb
Normal file
13
app/views/layouts/mailer.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<style>
|
||||
/* Email styles need to be inline */
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
1
app/views/layouts/mailer.text.erb
Normal file
1
app/views/layouts/mailer.text.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
1
app/views/submissions/create.html.erb
Normal file
1
app/views/submissions/create.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
Thanks
|
||||
Reference in New Issue
Block a user