-
Notifications
You must be signed in to change notification settings - Fork 479
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
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done