Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JotForm API client, translation, and question helper classes. #22474

Merged
merged 2 commits into from
May 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 additions & 0 deletions dashboard/lib/pd/jot_form/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module Pd
module JotForm
module Constants
QUESTION_TYPES = [
TYPE_HEADING = 'head'.freeze,
TYPE_TEXTBOX = 'textbox'.freeze,
TYPE_TEXTAREA = 'textarea'.freeze,
TYPE_DROPDOWN = 'dropdown'.freeze,
TYPE_RADIO = 'radio'.freeze,
TYPE_CHECKBOX = 'checkbox'.freeze,
TYPE_SCALE = 'scale'.freeze,
TYPE_MATRIX = 'matrix'.freeze,
TYPE_BUTTON = 'button'.freeze
].freeze

IGNORED_TYPES = [
TYPE_HEADING,
TYPE_BUTTON
].freeze

ANSWER_TYPES = [
ANSWER_TEXT = 'text'.freeze,

# We can convert the text response to a numeric value for certain single select controls
# (radio, dropdown, scale, part of a matrix).
ANSWER_SELECT_VALUE = 'selectValue'.freeze,
ANSWER_SELECT_TEXT = 'selectText'.freeze,

# Multi-select is always text
ANSWER_MULTI_SELECT = 'multiSelect'.freeze,

# No answer, just question metadata, e.g. matrix heading
ANSWER_NONE = 'none'.freeze
].freeze
end
end
end
78 changes: 78 additions & 0 deletions dashboard/lib/pd/jot_form/form_questions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Collection of Questions for a form
# The Question classes can directly parse JotForm API data via Translation
# This Question data can be stored and retrieved from our DB, and used to process JotForm answers
# also directly from JotForm's API via Translation
module Pd
module JotForm
class FormQuestions
attr_reader :form_id

def initialize(form_id, questions)
@form_id = form_id
@questions_by_id = questions.index_by {|q| q.id.to_i}
end

# Construct from an array of serialized questions (hashes)
# @param form_id [Integer]
# @param serialized_questions [Array<Hash>]
def self.deserialize(form_id, serialized_questions)
questions = serialized_questions.map do |question_hash|
sanitized_question_hash = question_hash.symbolize_keys
question_type = sanitized_question_hash[:type]
Translation.get_question_class_for(question_type).new(sanitized_question_hash)
end

new(form_id, questions)
end

# Serialize the questions as an array of question hashes
# @see Question::to_h
def serialize
@questions_by_id.values.map(&:to_h)
end

# @param id [Integer]
# @raise [KeyError]
# @returns [Question]
def get_question_by_id(id)
question = @questions_by_id[id.to_i]
raise KeyError, "No question exists for id #{id} in form #{form_id}" unless question
question
end

# Constructs a form-question summary, a hash of
# {question_name => {text:, answer_type:}}
# Note: matrix questions are expanded into their sub-questions,
# so the resulting summary may contain more items than the original list.
# See #Question::to_summary
def to_summary
{}.tap do |summary|
@questions_by_id.values.sort_by(&:order).each do |question|
summary.merge! question.to_summary
end
end
end

# Constructs form_data for answer_data (translated from a JotForm submission),
# based on these questions.
# @return [Hash] {question_id => answer_data (format depends on the question type)}
# @see Question#to_form_data
def to_form_data(answers_data)
questions_with_form_data = answers_data.map do |question_id, answer_data|
question = get_question_by_id(question_id)
raise "Unrecognized question id #{question_id} in #{form_id}.#{submission_id}" unless question

{
question: question,
form_data: question.to_form_data(answer_data)
}
end

questions_with_form_data.
sort_by {|d| d[:question].order}.
map {|d| d[:form_data]}.
reduce({}) {|form_data, question_form_data_part| form_data.merge(question_form_data_part)}
end
end
end
end
76 changes: 76 additions & 0 deletions dashboard/lib/pd/jot_form/jot_form_rest_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Wrapper for JotForms REST API.
# JotForm (jotform.com) is a 3rd party form / survey provider we're using for certain forms.
# API docs: https://api.jotform.com/docs/
#
# Note - while JotForm does provide a Ruby API client (https://github.com/jotform/jotform-api-ruby),
# it's very minimal and doesn't support the filter param we need to query submissions.
module Pd
module JotForm
class JotFormRestClient
API_ENDPOINT = 'http://api.jotform.com/'.freeze

def initialize
@api_key = CDO.jotform_api_key
raise KeyError, 'Unable to find configuration entry jotform_api_key' unless @api_key

@resource = RestClient::Resource.new(
API_ENDPOINT,
headers: {
content_type: :json,
accept: :json
}
)
end

# Get a list of all questions on a form
# @param form_id [Integer]
# See https://api.jotform.com/docs/#form-id-questions
def get_questions(form_id)
get "/form/#{form_id}/questions"
end

# Get a list of form submissions, optionally after a known submission id
# @param form_id [Integer]
# @param last_known_submission_id [Integer] (optional)
# when specified, only new submissions after the known id will be returned.
# Note - get_submissions has a default limit of 100.
# The API returns the limit (which will be 100), and the count.
# We can add functionality to override the limit if it becomes an issue.
# See https://api.jotform.com/docs/#form-id-submissions
def get_submissions(form_id, last_known_submission_id: nil)
params = {
orderby: 'id asc'
}
if last_known_submission_id
params[:filter] = {
'id:gt' => last_known_submission_id.to_s
}.to_json
end

get "form/#{form_id}/submissions", params
end

private

# Makes a GET call to the specified path
# @param path [String]
# @param params [Hash] url params
# @return [Hash] parsed JSON response body, on success
# @raises [RestClient::ExceptionWithResponse] on known error codes.
# See https://github.com/rest-client/rest-client#exceptions-see-httpwwww3orgprotocolsrfc2616rfc2616-sec10html
# Note the supplied params will be merged with default_params
def get(path, params = {})
path_with_params = "#{path}?#{default_params.merge(params).to_query}"
response = @resource[path_with_params].get
JSON.parse response.body
end

# We must pass the API Key on the url to authenticate.
def default_params
{
apiKey: @api_key
}
end
end
end
end
88 changes: 88 additions & 0 deletions dashboard/lib/pd/jot_form/matrix_question.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Matrix questions consist of a heading followed by multiple sub-questions in a grid,
# one question per row (radio buttons), and options as columns
#
# It looks something like this, for example:
# +------------------------------------------------------------------------------------+
# |How much do you agree or disagree with the following statements about this workshop?|
# +------------------------------------------------------------------------------------+
# | +----------------------------+ |
# | | Disagree | Neutral | Agree | |
# +---------------------------------------------------------+ |
# |I learned something | O | O | O | |
# +---------------------------------------------------------+ |
# |It was a good use of time | O | O | O | |
# +---------------------------------------------------------+ |
# |I enjoyed it | O | O | O | |
# +----------------------------+----------+---------+-------+--------------------------+
#
# This has 3 sub_questions: ['I learned something', 'It was a good use of time', 'I enjoyed it']
# And 3 options: ['Disagree', 'Neutral', 'Agree'], each of which will compute to a numeric value,
# 1-3 from left to right.
#
# Note: JotForm has the ability to provide calculateValues,
# but we are not using that to keep it simple.
module Pd
module JotForm
class MatrixQuestion < QuestionWithOptions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give an example of a matrix question in a comment here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

def self.supported_types
[
TYPE_MATRIX
]
end

attr_accessor :sub_questions

def self.from_jotform_question(id:, type:, jotform_question:)
super.tap do |matrix_question|
matrix_question.options = jotform_question['mcolumns'].split('|')
matrix_question.sub_questions = jotform_question['mrows'].split('|')
end
end

def to_h
super.merge(
sub_questions: sub_questions
)
end

def get_value(answer)
# Matrix answer is a Hash of sub_question => string_answer
answer.map do |sub_question, sub_answer|
sub_question_index = sub_questions.index(sub_question)
raise "Unable to find sub-question '#{sub_question}' in matrix question #{id}" unless sub_question_index

sub_answer_index = options.index(sub_answer)
raise "Unable to find '#{sub_answer}' in the options for matrix question #{id}" unless sub_answer_index

# Return a 1-based value
[sub_question_index, sub_answer_index + 1]
end.to_h
end

def to_summary
heading_hash = {name => {text: text, answer_type: ANSWER_NONE}}
sub_questions_hash = sub_questions.each_with_index.map do |sub_question, i|
[
generate_sub_question_key(i),
{
text: sub_question,
answer_type: ANSWER_SELECT_VALUE,
parent: name
}
]
end.to_h

heading_hash.merge(sub_questions_hash)
end

def to_form_data(answer)
# Prefix the matrix name to each sub question key
get_value(answer).transform_keys {|sub_question_index| generate_sub_question_key(sub_question_index)}
end

def generate_sub_question_key(sub_question_index)
"#{name}_#{sub_question_index}"
end
end
end
end
84 changes: 84 additions & 0 deletions dashboard/lib/pd/jot_form/question.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Base class for JotForm questions
# This cannot be constructed directly.
# @see Translation::get_question_class_for(type)
module Pd
module JotForm
class Question
include Constants

attr_accessor(
:id, # question id
:type, # See Translation::QUESTION_TYPES_TO_CLASS for a complete list of supported types
:name, # "unique" (not actually enforced by JotForm) name per form
:text, # label
:order, # 1-based order the question appears in the form
)

def type=(value)
raise "Invalid type #{value} for #{self.class}" unless self.class.supported_types.include? value
@type = value
end

# Construct from a hash of attributes
def initialize(params)
params.each do |k, v|
send "#{k}=", v
end
end

# Parse jotform question data
def self.from_jotform_question(id:, type:, jotform_question:)
new(
id: id.to_i,
type: type,
name: jotform_question['name'],
text: jotform_question['text'],
order: jotform_question['order'].to_i
)
end

# Serialize to hash
def to_h
{
id: id,
type: type,
name: name,
text: text,
order: order
}
end

# Override in derived classes to designate types they represent.
# All question types are defined in Constants::QUESTION_TYPES
def self.supported_types
[]
end

# @return [String] one of ANSWER_TYPES
def answer_type
raise 'Must override in derived class'
end

# Processes an answer based on the question details,
# converting to a numeric value where possible.
# @return type depends on the details: [Integer], or the raw answer.
def get_value(answer)
raise 'Must override in derived class'
end

# Generate question summary
# @return [Hash] {question_name => {text:, answer_type:}}
def to_summary
{name => {text: text, answer_type: answer_type}}
end

# Generate form_data for an answer to this question.
# When merged with the other questions in a form, it will form the entire form_data.
# @see FormQuestions
# @return [Hash] {question_name => answer}
def to_form_data(answer)
{name => get_value(answer)}
end
end
end
end
17 changes: 17 additions & 0 deletions dashboard/lib/pd/jot_form/question_with_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module Pd
module JotForm
class QuestionWithOptions < Question
attr_accessor :options

def to_h
super.merge(
options: options
)
end

def answer_type
ANSWER_SELECT_VALUE
end
end
end
end