studio-engine 0.86.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 +65 -0
- data/README.md +4 -1
- data/app/controllers/studio/admin_surveys_controller.rb +94 -0
- data/app/controllers/studio/surveys_controller.rb +173 -0
- data/app/helpers/studio/site_footer_helper.rb +9 -6
- 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/app/views/style/_site_footer.html.erb +4 -2
- data/db/migrate/20261005120000_create_studio_survey_responses.rb +47 -0
- data/docs/SITE_FOOTER.md +91 -12
- data/lib/studio/engine.rb +9 -0
- data/lib/studio/site_footer.rb +71 -8
- 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 +120 -10
- metadata +18 -2
|
@@ -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"
|
|
@@ -582,10 +585,16 @@ module Studio
|
|
|
582
585
|
# primitives: Google Calendar's appointment page inline, in a popup, and on a
|
|
583
586
|
# page. See docs/SITE_FOOTER.md and docs/BOOKING.md.
|
|
584
587
|
|
|
585
|
-
# The footer's facts
|
|
586
|
-
#
|
|
587
|
-
#
|
|
588
|
-
#
|
|
588
|
+
# The footer's facts. THE FOOTER IS ON BY DEFAULT: left unset (nil), the
|
|
589
|
+
# layout line `studio_site_footer` renders the DEFAULT footer, built from what
|
|
590
|
+
# the engine already knows (the site name, the footer or navbar logo, and a
|
|
591
|
+
# legal line linking the host's `privacy` and `terms` routes when it has
|
|
592
|
+
# them). No address, no map, no email, no link columns.
|
|
593
|
+
#
|
|
594
|
+
# nil (unset) the default footer
|
|
595
|
+
# false NO footer: the one opt-out
|
|
596
|
+
# a Hash, or a callable receiving the view: the app's own facts. Every key
|
|
597
|
+
# is optional; shape and rules: lib/studio/site_footer.rb.
|
|
589
598
|
#
|
|
590
599
|
# config.site_footer = ->(view) {
|
|
591
600
|
# { tagline: "Everything, by example",
|
|
@@ -601,6 +610,35 @@ module Studio
|
|
|
601
610
|
@@site_footer = declared
|
|
602
611
|
end
|
|
603
612
|
|
|
613
|
+
# True unless the app turned the footer off (`config.site_footer = false`).
|
|
614
|
+
def self.site_footer_enabled?
|
|
615
|
+
site_footer != false
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# True when the app wrote its own facts, as against taking the default footer
|
|
619
|
+
# (unset) or none (false).
|
|
620
|
+
def self.site_footer_declared?
|
|
621
|
+
!site_footer.nil? && site_footer != false
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# THE LOCATION, AS ONE SETTING: the address the footer prints in its Location
|
|
625
|
+
# band, and maps when it carries lat: and lng:. nil (the default) is no
|
|
626
|
+
# location. It is how an app on the default footer adds one without writing
|
|
627
|
+
# the facts callable:
|
|
628
|
+
#
|
|
629
|
+
# config.site_footer_address = { street: "123 Example St", city_line: "Washington, DC 20024",
|
|
630
|
+
# lat: 38.8894, lng: -77.0352 }
|
|
631
|
+
#
|
|
632
|
+
# The keys are the facts' `address:` keys (docs/SITE_FOOTER.md). An app's own
|
|
633
|
+
# facts WIN: when they carry an address this is not read, and `address: false`
|
|
634
|
+
# there shows none.
|
|
635
|
+
mattr_reader :site_footer_address, default: nil
|
|
636
|
+
|
|
637
|
+
def self.site_footer_address=(declared)
|
|
638
|
+
SiteFooter.validate_address!(declared)
|
|
639
|
+
@@site_footer_address = declared || nil
|
|
640
|
+
end
|
|
641
|
+
|
|
604
642
|
# Controllers whose pages keep the footer for a SIGNED-IN viewer, by
|
|
605
643
|
# controller name ("landing") or path ("admin/reports"). A visitor gets the
|
|
606
644
|
# footer on every page; a signed-in viewer gets it only here, because every
|
|
@@ -673,6 +711,48 @@ module Studio
|
|
|
673
711
|
Booking.path_for(booking_path, view, drawn: draw_booking_routes)
|
|
674
712
|
end
|
|
675
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
|
+
|
|
676
756
|
# THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
|
|
677
757
|
# page; a Hash of the schedule's measured box crops the frame to the slot
|
|
678
758
|
# picker until it is used. The numbers belong to ONE schedule, so there is no
|
|
@@ -1207,14 +1287,25 @@ module Studio
|
|
|
1207
1287
|
end
|
|
1208
1288
|
|
|
1209
1289
|
# The site footer's facts resolved for a view (lib/studio/site_footer.rb), or
|
|
1210
|
-
# nil when the app
|
|
1211
|
-
#
|
|
1212
|
-
#
|
|
1290
|
+
# nil when the app turned the footer off (or its own callable answered nil).
|
|
1291
|
+
# Unset, the facts are the default footer's. The name defaults to the site
|
|
1292
|
+
# identity's title and the logo to the navbar logo, so an app that has set
|
|
1293
|
+
# those up says them once; Studio.site_footer_address is the address of any
|
|
1294
|
+
# facts that carry none.
|
|
1213
1295
|
def self.site_footer_for(view)
|
|
1214
|
-
return nil
|
|
1296
|
+
return nil unless site_footer_enabled?
|
|
1215
1297
|
|
|
1216
|
-
SiteFooter.
|
|
1217
|
-
|
|
1298
|
+
declared = site_footer.nil? ? SiteFooter.default_facts(view, routes: host_route_set) : site_footer
|
|
1299
|
+
SiteFooter.resolve(declared, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
|
|
1300
|
+
booking_path: booking_path_for(view), address: site_footer_address)
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
# The host's route set, for the default footer's legal links. nil outside a
|
|
1304
|
+
# booted Rails app.
|
|
1305
|
+
def self.host_route_set
|
|
1306
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
1307
|
+
|
|
1308
|
+
::Rails.application.routes
|
|
1218
1309
|
end
|
|
1219
1310
|
|
|
1220
1311
|
# Navbar links resolved for a view context (lib/studio/navbar_links.rb).
|
|
@@ -1512,6 +1603,25 @@ module Studio
|
|
|
1512
1603
|
patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
|
|
1513
1604
|
constraints: { variant: /[a-z_]+/ }
|
|
1514
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
|
+
|
|
1515
1625
|
# Model-page protocol (v1) — a reusable per-record inspector. Drawn into
|
|
1516
1626
|
# every consuming app: /models/:model/:id renders one record as pretty JSON
|
|
1517
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
|