studio-engine 0.87.0 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +3 -0
- data/app/controllers/studio/admin_surveys_controller.rb +94 -0
- data/app/controllers/studio/surveys_controller.rb +173 -0
- data/app/models/studio/survey_response.rb +150 -0
- data/app/views/studio/admin_surveys/index.html.erb +41 -0
- data/app/views/studio/admin_surveys/show.html.erb +99 -0
- data/app/views/studio/surveys/_question.html.erb +77 -0
- data/app/views/studio/surveys/_script.html.erb +288 -0
- data/app/views/studio/surveys/_styles.html.erb +95 -0
- data/app/views/studio/surveys/not_found.html.erb +11 -0
- data/app/views/studio/surveys/show.html.erb +60 -0
- data/app/views/studio/surveys/sign_in_required.html.erb +15 -0
- data/app/views/studio/surveys/thanks.html.erb +16 -0
- data/db/migrate/20261005120000_create_studio_survey_responses.rb +47 -0
- data/lib/studio/engine.rb +9 -0
- data/lib/studio/survey/breakdown.rb +96 -0
- data/lib/studio/survey/export.rb +65 -0
- data/lib/studio/survey.rb +303 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +64 -0
- metadata +18 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
class Survey
|
|
5
|
+
# The per-question results the admin panel draws. Pure Ruby over plain rows,
|
|
6
|
+
# so the arithmetic is unit-tested without a database.
|
|
7
|
+
#
|
|
8
|
+
# rows: an Enumerable of hashes { answers: {key => {"value" => ...}},
|
|
9
|
+
# respondent: "alex" | "anonymous",
|
|
10
|
+
# at: Time }
|
|
11
|
+
#
|
|
12
|
+
# Counts are over the rows that ANSWERED the question, not all rows — a
|
|
13
|
+
# skipped optional question does not dilute its own distribution. A stored
|
|
14
|
+
# value whose option was later removed from the definition still counts,
|
|
15
|
+
# under its label snapshot and marked retired, so editing a survey never
|
|
16
|
+
# silently drops answers from the totals.
|
|
17
|
+
class Breakdown
|
|
18
|
+
Bucket = Struct.new(:value, :label, :face, :count, :percent, :retired, keyword_init: true)
|
|
19
|
+
TextEntry = Struct.new(:text, :respondent, :at, keyword_init: true)
|
|
20
|
+
Result = Struct.new(:question, :answered, :buckets, :average, :entries, keyword_init: true) do
|
|
21
|
+
def text? = question.text?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(survey, rows)
|
|
25
|
+
@survey = survey
|
|
26
|
+
@rows = rows.to_a
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def results
|
|
30
|
+
@results ||= @survey.questions.map { |q| result_for(q) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.percent(count, total)
|
|
34
|
+
return 0 if total.to_i.zero?
|
|
35
|
+
|
|
36
|
+
((count.to_f / total) * 100).round
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def answers_for(question)
|
|
42
|
+
@rows.filter_map do |row|
|
|
43
|
+
entry = (row[:answers] || {})[question.key]
|
|
44
|
+
next unless entry.is_a?(Hash) && !question.blank_value?(entry["value"])
|
|
45
|
+
|
|
46
|
+
[entry, row]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def result_for(question)
|
|
51
|
+
found = answers_for(question)
|
|
52
|
+
return text_result(question, found) if question.text?
|
|
53
|
+
|
|
54
|
+
counts = Hash.new(0)
|
|
55
|
+
snapshots = {}
|
|
56
|
+
found.each do |entry, _row|
|
|
57
|
+
displays = Array(entry["display"])
|
|
58
|
+
Array(entry["value"]).each_with_index do |v, i|
|
|
59
|
+
counts[v.to_s] += 1
|
|
60
|
+
snapshots[v.to_s] ||= displays[i]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
total = found.size
|
|
65
|
+
buckets = question.options.map do |opt|
|
|
66
|
+
n = counts.delete(opt.value.to_s) || 0
|
|
67
|
+
Bucket.new(value: opt.value, label: opt.label, face: opt.face, count: n,
|
|
68
|
+
percent: self.class.percent(n, total), retired: false)
|
|
69
|
+
end
|
|
70
|
+
counts.sort.each do |value, n|
|
|
71
|
+
buckets << Bucket.new(value: value, label: snapshots[value].presence || value, count: n,
|
|
72
|
+
percent: self.class.percent(n, total), retired: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Result.new(question: question, answered: total, buckets: buckets, average: average(question, found), entries: [])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def average(question, found)
|
|
79
|
+
return nil unless question.scale?
|
|
80
|
+
|
|
81
|
+
nums = found.map { |entry, _| Integer(entry["value"].to_s, exception: false) }.compact
|
|
82
|
+
return nil if nums.empty?
|
|
83
|
+
|
|
84
|
+
(nums.sum.to_f / nums.size).round(2)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def text_result(question, found)
|
|
88
|
+
entries = found.map do |entry, row|
|
|
89
|
+
TextEntry.new(text: entry["value"].to_s, respondent: row[:respondent].presence || "anonymous", at: row[:at])
|
|
90
|
+
end
|
|
91
|
+
entries.sort_by! { |e| e.at || Time.at(0) }.reverse!
|
|
92
|
+
Result.new(question: question, answered: entries.size, buckets: [], average: nil, entries: entries)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
class Survey
|
|
5
|
+
# CSV for one survey's responses. One row per response; one column per
|
|
6
|
+
# question key in the CURRENT definition, then any retired key a stored
|
|
7
|
+
# answer still carries, so an export never drops data the definition forgot.
|
|
8
|
+
#
|
|
9
|
+
# Written without the csv gem (a bundled, not default, gem from Ruby 3.4):
|
|
10
|
+
# RFC 4180 quoting, plus a leading apostrophe on any cell a spreadsheet
|
|
11
|
+
# would execute as a formula (=, +, -, @, tab, CR) — respondents type these
|
|
12
|
+
# cells, so they are untrusted.
|
|
13
|
+
class Export
|
|
14
|
+
META = %w[response_id survey_version status started_at completed_at respondent user_id email_ref user_agent_class].freeze
|
|
15
|
+
FORMULA_LEAD = /\A[=+\-@\t\r]/
|
|
16
|
+
|
|
17
|
+
# rows: hashes with the META keys (as symbols) plus :answers. survey may be
|
|
18
|
+
# nil for a slug whose definition was deleted; its stored keys still export.
|
|
19
|
+
def initialize(survey, rows)
|
|
20
|
+
@survey = survey
|
|
21
|
+
@rows = rows.to_a
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def keys
|
|
25
|
+
@keys ||= begin
|
|
26
|
+
current = @survey ? @survey.keys : []
|
|
27
|
+
retired = @rows.flat_map { |r| (r[:answers] || {}).keys }.uniq - current
|
|
28
|
+
current + retired.sort
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_csv
|
|
33
|
+
lines = [line(META + keys)]
|
|
34
|
+
@rows.each do |row|
|
|
35
|
+
answers = row[:answers] || {}
|
|
36
|
+
cells = META.map { |k| format_time(row[k.to_sym]) }
|
|
37
|
+
cells += keys.map { |k| answer_cell(answers[k]) }
|
|
38
|
+
lines << line(cells)
|
|
39
|
+
end
|
|
40
|
+
lines.join
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.cell(value)
|
|
44
|
+
text = value.to_s
|
|
45
|
+
text = "'#{text}" if text.match?(FORMULA_LEAD)
|
|
46
|
+
text.match?(/[",\n\r]/) || text != text.strip ? %("#{text.gsub('"', '""')}") : text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def line(cells) = "#{cells.map { |c| self.class.cell(c) }.join(",")}\r\n"
|
|
52
|
+
|
|
53
|
+
def format_time(value)
|
|
54
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def answer_cell(entry)
|
|
58
|
+
return nil unless entry.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
value = entry["value"]
|
|
61
|
+
value.is_a?(Array) ? value.join("; ") : value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
|
|
6
|
+
module Studio
|
|
7
|
+
# A SURVEY, defined in app code (docs/SURVEYS.md).
|
|
8
|
+
#
|
|
9
|
+
# # config/surveys/first_game.rb — loaded by the engine on boot and reload
|
|
10
|
+
# Studio.define_survey "first-game" do
|
|
11
|
+
# title "How was your first game?"
|
|
12
|
+
# intro "Two minutes, six questions. It shapes what we build next."
|
|
13
|
+
# thank_you "We read every answer."
|
|
14
|
+
# next_action label: "Play another game", url: "/play"
|
|
15
|
+
# allow_anonymous true
|
|
16
|
+
#
|
|
17
|
+
# emoji_scale :overall, "How was your first game?", required: true
|
|
18
|
+
# rating :rules, "How clear were the rules?", low_label: "Lost", high_label: "Crystal clear"
|
|
19
|
+
# choice :found_us, "How did you find us?", options: ["Email", "A friend", "Search", "Other"]
|
|
20
|
+
# multi_choice :liked, "What did you enjoy?", options: ["The board", "The pace", "The art"]
|
|
21
|
+
# short_text :one_word, "Describe it in one word."
|
|
22
|
+
# long_text :anything_else, "Anything else?", help: "Bugs, ideas, complaints — all welcome."
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# The definition is the CURRENT shape of the survey, nothing more. A stored
|
|
26
|
+
# answer carries its own question key, type and label snapshot (and the chosen
|
|
27
|
+
# option's label), so editing or removing a question never rewrites what an
|
|
28
|
+
# earlier respondent was asked. `version` defaults to a digest of the questions
|
|
29
|
+
# and is stamped on every response.
|
|
30
|
+
class Survey
|
|
31
|
+
class DefinitionError < ArgumentError; end
|
|
32
|
+
|
|
33
|
+
SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
34
|
+
KEY_FORMAT = /\A[a-z][a-z0-9_]{0,62}\z/
|
|
35
|
+
# The same two shapes, unanchored, for route constraints.
|
|
36
|
+
ROUTE_SLUG = /[a-z0-9]+(?:-[a-z0-9]+)*/
|
|
37
|
+
ROUTE_KEY = /[a-z][a-z0-9_]{0,62}/
|
|
38
|
+
TYPES = %i[emoji_scale rating choice multi_choice short_text long_text].freeze
|
|
39
|
+
SCALE_TYPES = %i[emoji_scale rating].freeze
|
|
40
|
+
SELECT_TYPES = %i[emoji_scale rating choice].freeze
|
|
41
|
+
TEXT_TYPES = %i[short_text long_text].freeze
|
|
42
|
+
|
|
43
|
+
EMOJI_FACES = ["\u{1F61E}", "\u{1F615}", "\u{1F610}", "\u{1F642}", "\u{1F60D}"].freeze
|
|
44
|
+
DEFAULT_EMOJI_LABELS = ["Awful", "Not great", "Okay", "Good", "Loved it"].freeze
|
|
45
|
+
TEXT_LIMITS = { short_text: 280, long_text: 5000 }.freeze
|
|
46
|
+
|
|
47
|
+
Option = Struct.new(:value, :label, :face, keyword_init: true)
|
|
48
|
+
|
|
49
|
+
# One question. Immutable once built.
|
|
50
|
+
class Question
|
|
51
|
+
attr_reader :key, :type, :label, :help, :options, :low_label, :high_label, :placeholder, :max_length
|
|
52
|
+
|
|
53
|
+
def initialize(key:, type:, label:, required: false, help: nil, options: nil, labels: nil,
|
|
54
|
+
low_label: nil, high_label: nil, placeholder: nil, max_length: nil)
|
|
55
|
+
@key = key.to_s
|
|
56
|
+
@type = type.to_sym
|
|
57
|
+
@label = label.to_s.strip
|
|
58
|
+
@required = required == true
|
|
59
|
+
@help = help.to_s.strip.presence
|
|
60
|
+
@low_label = low_label.to_s.strip.presence
|
|
61
|
+
@high_label = high_label.to_s.strip.presence
|
|
62
|
+
@placeholder = placeholder.to_s.strip.presence
|
|
63
|
+
validate_shape!(options, labels, max_length)
|
|
64
|
+
@options = build_options(options, labels).freeze
|
|
65
|
+
@max_length = text? ? Integer(max_length || TEXT_LIMITS.fetch(@type)) : nil
|
|
66
|
+
freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def required? = @required
|
|
70
|
+
def scale? = SCALE_TYPES.include?(type)
|
|
71
|
+
def select? = SELECT_TYPES.include?(type)
|
|
72
|
+
def multi? = type == :multi_choice
|
|
73
|
+
def text? = TEXT_TYPES.include?(type)
|
|
74
|
+
def numbered? = !text?
|
|
75
|
+
|
|
76
|
+
def option(value)
|
|
77
|
+
options.find { |o| o.value.to_s == value.to_s }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def blank_value?(value)
|
|
81
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) || value.to_s.strip.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# [normalized_value, error_message]. A blank input normalizes to nil, which
|
|
85
|
+
# the response stores as "unanswered".
|
|
86
|
+
def normalize(raw)
|
|
87
|
+
raw = Array(raw).map(&:to_s).reject { |v| v.strip.empty? } if multi?
|
|
88
|
+
return [nil, nil] if blank_value?(raw)
|
|
89
|
+
|
|
90
|
+
case type
|
|
91
|
+
when :emoji_scale, :rating
|
|
92
|
+
int = Integer(raw.to_s.strip, exception: false)
|
|
93
|
+
return [nil, "Pick one of the options."] unless int && option(int)
|
|
94
|
+
|
|
95
|
+
[int, nil]
|
|
96
|
+
when :choice
|
|
97
|
+
opt = option(raw.to_s)
|
|
98
|
+
opt ? [opt.value, nil] : [nil, "Pick one of the options."]
|
|
99
|
+
when :multi_choice
|
|
100
|
+
values = raw.uniq
|
|
101
|
+
return [nil, "Pick from the listed options."] unless values.all? { |v| option(v) }
|
|
102
|
+
|
|
103
|
+
# Keep the definition's order, not the click order.
|
|
104
|
+
[options.map(&:value).select { |v| values.include?(v) }, nil]
|
|
105
|
+
else
|
|
106
|
+
text = raw.to_s.strip.gsub("\r\n", "\n")
|
|
107
|
+
return [nil, "Keep it under #{max_length} characters."] if text.length > max_length
|
|
108
|
+
|
|
109
|
+
[text, nil]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The human snapshot of an answer: the option label(s), or the text itself.
|
|
114
|
+
def display(value)
|
|
115
|
+
return nil if value.nil?
|
|
116
|
+
return Array(value).map { |v| option(v)&.label || v.to_s } if multi?
|
|
117
|
+
return value.to_s if text?
|
|
118
|
+
|
|
119
|
+
option(value)&.label || value.to_s
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def to_h
|
|
123
|
+
{ key: key, type: type, label: label, required: required?, help: help,
|
|
124
|
+
options: options.map { |o| [o.value, o.label] }, low_label: low_label, high_label: high_label }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def validate_shape!(options, labels, max_length)
|
|
130
|
+
raise DefinitionError, "question key #{key.inspect} must match #{KEY_FORMAT.inspect}" unless key.match?(KEY_FORMAT)
|
|
131
|
+
raise DefinitionError, "question #{key}: unknown type #{type.inspect} (one of #{TYPES.join(", ")})" unless TYPES.include?(type)
|
|
132
|
+
raise DefinitionError, "question #{key}: label is required" if label.empty?
|
|
133
|
+
|
|
134
|
+
if %i[choice multi_choice].include?(type)
|
|
135
|
+
raise DefinitionError, "question #{key}: #{type} needs at least two options" if Array(options).size < 2
|
|
136
|
+
elsif options
|
|
137
|
+
raise DefinitionError, "question #{key}: options: is only for choice and multi_choice"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if labels && type != :emoji_scale
|
|
141
|
+
raise DefinitionError, "question #{key}: labels: is only for emoji_scale"
|
|
142
|
+
end
|
|
143
|
+
if labels && Array(labels).size != 5
|
|
144
|
+
raise DefinitionError, "question #{key}: emoji_scale takes exactly five labels"
|
|
145
|
+
end
|
|
146
|
+
if (low_label || high_label) && type != :rating
|
|
147
|
+
raise DefinitionError, "question #{key}: low_label/high_label are only for rating"
|
|
148
|
+
end
|
|
149
|
+
if max_length && !text?
|
|
150
|
+
raise DefinitionError, "question #{key}: max_length: is only for short_text and long_text"
|
|
151
|
+
end
|
|
152
|
+
return unless max_length && !(Integer(max_length, exception: false).to_i.between?(1, TEXT_LIMITS[:long_text]))
|
|
153
|
+
|
|
154
|
+
raise DefinitionError, "question #{key}: max_length must be 1..#{TEXT_LIMITS[:long_text]}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_options(options, labels)
|
|
158
|
+
case type
|
|
159
|
+
when :emoji_scale
|
|
160
|
+
names = Array(labels || DEFAULT_EMOJI_LABELS).map { |l| l.to_s.strip }
|
|
161
|
+
raise DefinitionError, "question #{key}: emoji labels cannot be blank" if names.any?(&:empty?)
|
|
162
|
+
|
|
163
|
+
names.each_with_index.map { |name, i| Option.new(value: i + 1, label: name, face: EMOJI_FACES[i]) }
|
|
164
|
+
when :rating
|
|
165
|
+
(1..5).map { |n| Option.new(value: n, label: n.to_s) }
|
|
166
|
+
when :choice, :multi_choice
|
|
167
|
+
opts = Array(options).map { |o| coerce_option(o) }
|
|
168
|
+
values = opts.map(&:value)
|
|
169
|
+
raise DefinitionError, "question #{key}: option values must be unique" if values.uniq.size != values.size
|
|
170
|
+
|
|
171
|
+
opts
|
|
172
|
+
else
|
|
173
|
+
[]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def coerce_option(raw)
|
|
178
|
+
label, value =
|
|
179
|
+
case raw
|
|
180
|
+
when Hash then [raw[:label] || raw["label"], raw[:value] || raw["value"]]
|
|
181
|
+
when Array then [raw[1], raw[0]]
|
|
182
|
+
else [raw, nil]
|
|
183
|
+
end
|
|
184
|
+
label = label.to_s.strip
|
|
185
|
+
raise DefinitionError, "question #{key}: option labels cannot be blank" if label.empty?
|
|
186
|
+
|
|
187
|
+
value = (value.presence || label.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")).to_s
|
|
188
|
+
raise DefinitionError, "question #{key}: option #{label.inspect} has no usable value" if value.empty?
|
|
189
|
+
|
|
190
|
+
Option.new(value: value, label: label)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The block DSL. Every setter is a plain method so `title "x"` reads well.
|
|
195
|
+
class Builder
|
|
196
|
+
def initialize(slug)
|
|
197
|
+
@attrs = { slug: slug.to_s, questions: [] }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def title(text) = @attrs[:title] = text
|
|
201
|
+
def intro(text) = @attrs[:intro] = text
|
|
202
|
+
def thank_you(text) = @attrs[:thank_you] = text
|
|
203
|
+
def allow_anonymous(flag = true) = @attrs[:allow_anonymous] = flag
|
|
204
|
+
def version(value) = @attrs[:version] = value
|
|
205
|
+
|
|
206
|
+
def next_action(label:, url:)
|
|
207
|
+
@attrs[:next_action] = { label: label.to_s.strip, url: url.to_s.strip }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
TYPES.each do |type|
|
|
211
|
+
define_method(type) do |key, label, **opts|
|
|
212
|
+
@attrs[:questions] << Question.new(key: key, type: type, label: label, **opts)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build = Survey.new(**@attrs)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
attr_reader :slug, :title, :intro, :thank_you, :next_action, :questions, :version
|
|
220
|
+
|
|
221
|
+
def initialize(slug:, title: nil, intro: nil, thank_you: nil, next_action: nil, allow_anonymous: false,
|
|
222
|
+
version: nil, questions: [])
|
|
223
|
+
@slug = slug.to_s
|
|
224
|
+
@title = title.to_s.strip
|
|
225
|
+
@intro = intro.to_s.strip.presence
|
|
226
|
+
@thank_you = thank_you.to_s.strip.presence || "We appreciate you taking the time."
|
|
227
|
+
@next_action = next_action
|
|
228
|
+
@allow_anonymous = allow_anonymous == true
|
|
229
|
+
@questions = questions.dup.freeze
|
|
230
|
+
validate!
|
|
231
|
+
@version = (version.presence || fingerprint).to_s
|
|
232
|
+
freeze
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def allow_anonymous? = @allow_anonymous
|
|
236
|
+
|
|
237
|
+
def question(key)
|
|
238
|
+
questions.find { |q| q.key == key.to_s }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def keys = questions.map(&:key)
|
|
242
|
+
def required_keys = questions.select(&:required?).map(&:key)
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def validate!
|
|
247
|
+
raise DefinitionError, "survey slug #{slug.inspect} must be lowercase words joined by hyphens" unless slug.match?(SLUG_FORMAT)
|
|
248
|
+
raise DefinitionError, "survey #{slug}: title is required" if title.empty?
|
|
249
|
+
raise DefinitionError, "survey #{slug}: needs at least one question" if questions.empty?
|
|
250
|
+
|
|
251
|
+
dupes = keys.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
|
252
|
+
raise DefinitionError, "survey #{slug}: duplicate question keys #{dupes.join(", ")}" if dupes.any?
|
|
253
|
+
|
|
254
|
+
validate_next_action!
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_next_action!
|
|
258
|
+
return if next_action.nil?
|
|
259
|
+
|
|
260
|
+
label, url = next_action.values_at(:label, :url)
|
|
261
|
+
raise DefinitionError, "survey #{slug}: next_action needs a label and a url" if label.to_s.empty? || url.to_s.empty?
|
|
262
|
+
# A relative path or an http(s) URL — never javascript: or a protocol-relative //host.
|
|
263
|
+
return if url.match?(%r{\A/(?!/)}) || url.match?(%r{\Ahttps?://}i)
|
|
264
|
+
|
|
265
|
+
raise DefinitionError, "survey #{slug}: next_action url must be a /path or an http(s) URL"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def fingerprint
|
|
269
|
+
Digest::SHA256.hexdigest(questions.map(&:to_h).inspect)[0, 12]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# --- registry --------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
@registry = {}
|
|
275
|
+
@mutex = Mutex.new
|
|
276
|
+
|
|
277
|
+
class << self
|
|
278
|
+
def define(slug, &block)
|
|
279
|
+
raise DefinitionError, "define_survey needs a block" unless block
|
|
280
|
+
|
|
281
|
+
builder = Builder.new(slug)
|
|
282
|
+
builder.instance_eval(&block)
|
|
283
|
+
survey = builder.build
|
|
284
|
+
@mutex.synchronize { @registry[survey.slug] = survey }
|
|
285
|
+
survey
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def find(slug) = @registry[slug.to_s]
|
|
289
|
+
|
|
290
|
+
def all = @registry.values.sort_by(&:slug)
|
|
291
|
+
|
|
292
|
+
def reset! = @mutex.synchronize { @registry.clear }
|
|
293
|
+
|
|
294
|
+
# Loads every *.rb under the app's survey directory. `load`, not `require`,
|
|
295
|
+
# so a development reload picks up an edited definition.
|
|
296
|
+
def load_definitions!(dir)
|
|
297
|
+
return [] unless dir && File.directory?(dir.to_s)
|
|
298
|
+
|
|
299
|
+
Dir[File.join(dir.to_s, "**", "*.rb")].sort.each { |path| load path }
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -49,6 +49,9 @@ require "studio/email_smoke"
|
|
|
49
49
|
require "studio/mail_transport"
|
|
50
50
|
require "studio/redis"
|
|
51
51
|
require "studio/cable"
|
|
52
|
+
require "studio/survey"
|
|
53
|
+
require "studio/survey/breakdown"
|
|
54
|
+
require "studio/survey/export"
|
|
52
55
|
|
|
53
56
|
module Studio
|
|
54
57
|
mattr_accessor :app_name, default: "Studio"
|
|
@@ -708,6 +711,48 @@ module Studio
|
|
|
708
711
|
Booking.path_for(booking_path, view, drawn: draw_booking_routes)
|
|
709
712
|
end
|
|
710
713
|
|
|
714
|
+
# ── Surveys (docs/SURVEYS.md) ──────────────────────────────────────────────
|
|
715
|
+
# The public survey pages, /surveys/:slug (Studio::SurveysController). OPT-IN:
|
|
716
|
+
# `/surveys` is a path an app may already own, and a duplicate helper name
|
|
717
|
+
# raises while the host's routes.rb loads, which kills every route in it.
|
|
718
|
+
#
|
|
719
|
+
# config.draw_survey_routes = true
|
|
720
|
+
mattr_accessor :draw_survey_routes, default: false
|
|
721
|
+
|
|
722
|
+
# The admin results panel, /admin/surveys (Studio::AdminSurveysController).
|
|
723
|
+
# OPT-IN for the same reason, and independent of the public pages, so an app
|
|
724
|
+
# can read results it collects elsewhere.
|
|
725
|
+
#
|
|
726
|
+
# config.draw_admin_survey_routes = true
|
|
727
|
+
mattr_accessor :draw_admin_survey_routes, default: false
|
|
728
|
+
|
|
729
|
+
# Where the app's survey definitions live, relative to Rails.root. Every *.rb
|
|
730
|
+
# in it is loaded at boot and on each development reload.
|
|
731
|
+
mattr_accessor :survey_definitions_path, default: "config/surveys"
|
|
732
|
+
|
|
733
|
+
# Attribution for an email arrival: ->(controller) { String | nil }. Called on
|
|
734
|
+
# every survey request; its answer is stamped on the response as email_ref the
|
|
735
|
+
# first time one is present. Cyvasse passes its EmailReferral ref here.
|
|
736
|
+
mattr_accessor :survey_ref_resolver, default: ->(_controller) {}
|
|
737
|
+
|
|
738
|
+
# Called once, after a response completes: ->(response) { ... }. A raise is
|
|
739
|
+
# logged and swallowed — the respondent still lands on the thank-you screen.
|
|
740
|
+
mattr_accessor :on_survey_completed, default: ->(_response) {}
|
|
741
|
+
|
|
742
|
+
# Define a survey in app code. See Studio::Survey for the DSL.
|
|
743
|
+
def self.define_survey(slug, &block)
|
|
744
|
+
Survey.define(slug, &block)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Every defined survey, by slug.
|
|
748
|
+
def self.surveys
|
|
749
|
+
Survey.all
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def self.survey(slug)
|
|
753
|
+
Survey.find(slug)
|
|
754
|
+
end
|
|
755
|
+
|
|
711
756
|
# THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
|
|
712
757
|
# page; a Hash of the schedule's measured box crops the frame to the slot
|
|
713
758
|
# picker until it is used. The numbers belong to ONE schedule, so there is no
|
|
@@ -1558,6 +1603,25 @@ module Studio
|
|
|
1558
1603
|
patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
|
|
1559
1604
|
constraints: { variant: /[a-z_]+/ }
|
|
1560
1605
|
|
|
1606
|
+
# Surveys (docs/SURVEYS.md). Both halves OPT-IN — see
|
|
1607
|
+
# Studio.draw_survey_routes and Studio.draw_admin_survey_routes.
|
|
1608
|
+
if Studio.draw_survey_routes
|
|
1609
|
+
slug = { slug: Studio::Survey::ROUTE_SLUG }
|
|
1610
|
+
get "surveys/:slug", to: "studio/surveys#show", as: :studio_survey, constraints: slug
|
|
1611
|
+
post "surveys/:slug", to: "studio/surveys#submit", constraints: slug
|
|
1612
|
+
patch "surveys/:slug/answers/:key", to: "studio/surveys#answer", as: :studio_survey_answer,
|
|
1613
|
+
constraints: slug.merge(key: Studio::Survey::ROUTE_KEY)
|
|
1614
|
+
get "surveys/:slug/thanks", to: "studio/surveys#thanks", as: :studio_survey_thanks, constraints: slug
|
|
1615
|
+
end
|
|
1616
|
+
|
|
1617
|
+
if Studio.draw_admin_survey_routes
|
|
1618
|
+
get "admin/surveys", to: "studio/admin_surveys#index", as: :admin_surveys
|
|
1619
|
+
get "admin/surveys/:slug/export", to: "studio/admin_surveys#export", as: :admin_survey_export,
|
|
1620
|
+
constraints: { slug: %r{[^/.]+} }, defaults: { format: :csv }
|
|
1621
|
+
get "admin/surveys/:slug", to: "studio/admin_surveys#show", as: :admin_survey,
|
|
1622
|
+
constraints: { slug: %r{[^/.]+} }
|
|
1623
|
+
end
|
|
1624
|
+
|
|
1561
1625
|
# Model-page protocol (v1) — a reusable per-record inspector. Drawn into
|
|
1562
1626
|
# every consuming app: /models/:model/:id renders one record as pretty JSON
|
|
1563
1627
|
# plus a copy/paste rails-console command; /models/:model/random bounces to
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.88.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -336,6 +336,7 @@ files:
|
|
|
336
336
|
- app/controllers/schema_controller.rb
|
|
337
337
|
- app/controllers/sessions_controller.rb
|
|
338
338
|
- app/controllers/solana_sessions_controller.rb
|
|
339
|
+
- app/controllers/studio/admin_surveys_controller.rb
|
|
339
340
|
- app/controllers/studio/bookings_controller.rb
|
|
340
341
|
- app/controllers/studio/email_images_controller.rb
|
|
341
342
|
- app/controllers/studio/emails_controller.rb
|
|
@@ -350,6 +351,7 @@ files:
|
|
|
350
351
|
- app/controllers/studio/public_users_controller.rb
|
|
351
352
|
- app/controllers/studio/session_states_controller.rb
|
|
352
353
|
- app/controllers/studio/site_identities_controller.rb
|
|
354
|
+
- app/controllers/studio/surveys_controller.rb
|
|
353
355
|
- app/controllers/style_controller.rb
|
|
354
356
|
- app/controllers/theme_settings_controller.rb
|
|
355
357
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
@@ -386,6 +388,7 @@ files:
|
|
|
386
388
|
- app/models/studio/link.rb
|
|
387
389
|
- app/models/studio/model_page.rb
|
|
388
390
|
- app/models/studio/site_identity.rb
|
|
391
|
+
- app/models/studio/survey_response.rb
|
|
389
392
|
- app/models/theme_setting.rb
|
|
390
393
|
- app/services/google_oauth_validator.rb
|
|
391
394
|
- app/services/studio/banner.rb
|
|
@@ -439,6 +442,8 @@ files:
|
|
|
439
442
|
- app/views/studio/admin_models/_teams_table.html.erb
|
|
440
443
|
- app/views/studio/admin_models/index.html.erb
|
|
441
444
|
- app/views/studio/admin_models/show.html.erb
|
|
445
|
+
- app/views/studio/admin_surveys/index.html.erb
|
|
446
|
+
- app/views/studio/admin_surveys/show.html.erb
|
|
442
447
|
- app/views/studio/banners/_app_banner.html.erb
|
|
443
448
|
- app/views/studio/banners/_button.html.erb
|
|
444
449
|
- app/views/studio/banners/_dev_mode_button.html.erb
|
|
@@ -544,6 +549,13 @@ files:
|
|
|
544
549
|
- app/views/studio/site_footer/_map_assets.html.erb
|
|
545
550
|
- app/views/studio/site_footer/_social_icon.html.erb
|
|
546
551
|
- app/views/studio/site_identities/edit.html.erb
|
|
552
|
+
- app/views/studio/surveys/_question.html.erb
|
|
553
|
+
- app/views/studio/surveys/_script.html.erb
|
|
554
|
+
- app/views/studio/surveys/_styles.html.erb
|
|
555
|
+
- app/views/studio/surveys/not_found.html.erb
|
|
556
|
+
- app/views/studio/surveys/show.html.erb
|
|
557
|
+
- app/views/studio/surveys/sign_in_required.html.erb
|
|
558
|
+
- app/views/studio/surveys/thanks.html.erb
|
|
547
559
|
- app/views/style/_host.html.erb
|
|
548
560
|
- app/views/style/_modal_specimen.html.erb
|
|
549
561
|
- app/views/style/_modals.html.erb
|
|
@@ -585,6 +597,7 @@ files:
|
|
|
585
597
|
- db/migrate/20260902000001_create_studio_knowledge_expectations.rb
|
|
586
598
|
- db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
|
|
587
599
|
- db/migrate/20260930120000_create_studio_site_identities.rb
|
|
600
|
+
- db/migrate/20261005120000_create_studio_survey_responses.rb
|
|
588
601
|
- docs/BOOKING.md
|
|
589
602
|
- docs/SITE_FOOTER.md
|
|
590
603
|
- lib/active_storage/service/studio_trash_s3_service.rb
|
|
@@ -627,6 +640,9 @@ files:
|
|
|
627
640
|
- lib/studio/session_state.rb
|
|
628
641
|
- lib/studio/sidebar_sections.rb
|
|
629
642
|
- lib/studio/site_footer.rb
|
|
643
|
+
- lib/studio/survey.rb
|
|
644
|
+
- lib/studio/survey/breakdown.rb
|
|
645
|
+
- lib/studio/survey/export.rb
|
|
630
646
|
- lib/studio/theme_resolver.rb
|
|
631
647
|
- lib/studio/ui_primitives.rb
|
|
632
648
|
- lib/studio/username_generator.rb
|