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,15 @@
|
|
|
1
|
+
<%# A survey without allow_anonymous, opened by a visitor who is neither
|
|
2
|
+
signed in nor arriving by an attributed email link. %>
|
|
3
|
+
<% content_for(:title, @survey.title) %>
|
|
4
|
+
<%= render "studio/surveys/styles" %>
|
|
5
|
+
<main class="studio-survey" data-studio-survey-sign-in>
|
|
6
|
+
<div class="studio-survey__shell">
|
|
7
|
+
<div class="studio-survey__card">
|
|
8
|
+
<h1 class="studio-survey__title"><%= @survey.title %></h1>
|
|
9
|
+
<p class="studio-survey__intro-copy">Sign in to answer this survey.</p>
|
|
10
|
+
<% if respond_to?(:login_path) %>
|
|
11
|
+
<a href="<%= login_path %>" class="btn btn-primary btn-lg">Sign in</a>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</main>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# /surveys/:slug/thanks — shown once a response completes. The next action is
|
|
2
|
+
the survey's own (next_action label:, url:), when it declares one. %>
|
|
3
|
+
<% content_for(:title, "Thank you") %>
|
|
4
|
+
<%= render "studio/surveys/styles" %>
|
|
5
|
+
<main class="studio-survey" data-studio-survey-thanks>
|
|
6
|
+
<div class="studio-survey__shell">
|
|
7
|
+
<div class="studio-survey__card">
|
|
8
|
+
<div class="studio-survey__burst" aria-hidden="true">🙌</div>
|
|
9
|
+
<h1 class="studio-survey__title" tabindex="-1" autofocus>Thank you!</h1>
|
|
10
|
+
<p class="studio-survey__intro-copy"><%= @survey.thank_you %></p>
|
|
11
|
+
<% if @survey.next_action %>
|
|
12
|
+
<a href="<%= @survey.next_action[:url] %>" class="btn btn-primary btn-lg" data-survey-next-action><%= @survey.next_action[:label] %></a>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</main>
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
whose box runs from 200 to 600), cropped by the same arithmetic the real
|
|
13
13
|
wrapper uses (Studio::Booking.crop). No real schedule's numbers are here. %>
|
|
14
14
|
<%
|
|
15
|
-
|
|
15
|
+
# The app's OWN facts only. An app on the default footer (nothing declared)
|
|
16
|
+
# sees the sample, which shows every kind of row the default does not have.
|
|
17
|
+
own_facts = Studio.site_footer_declared? ? studio_site_footer_facts : nil
|
|
16
18
|
sample = {
|
|
17
19
|
name: Studio.site_identity[:title], tagline: "What this app is, in a line",
|
|
18
20
|
email: "team@example.com",
|
|
@@ -42,7 +44,7 @@
|
|
|
42
44
|
</p>
|
|
43
45
|
<p class="text-muted text-sm" data-site-footer-status>
|
|
44
46
|
This app:
|
|
45
|
-
footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> ·
|
|
47
|
+
footer <strong class="text-body"><%= own_facts ? "declared" : (Studio.site_footer_enabled? ? "default (not declared)" : "off") %></strong> ·
|
|
46
48
|
booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> ·
|
|
47
49
|
booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong> ·
|
|
48
50
|
frame <strong class="text-body"><%= own_crop ? "cropped at rest" : "whole at rest" %></strong>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# One respondent's answers to one survey (Studio::SurveyResponse,
|
|
2
|
+
# docs/SURVEYS.md). Surveys themselves are defined in app code, not stored; the
|
|
3
|
+
# row keys on the survey's slug and stamps the definition version it was taken
|
|
4
|
+
# under.
|
|
5
|
+
#
|
|
6
|
+
# `answers` is { question_key => { "value", "label", "type", "display",
|
|
7
|
+
# "answered_at" } }: each answer carries the question label and option label it
|
|
8
|
+
# was given under, so a later edit to the definition never rewrites history.
|
|
9
|
+
#
|
|
10
|
+
# No raw IP and no raw user agent: user_agent_class is mobile / tablet /
|
|
11
|
+
# desktop / bot / unknown, which is all the results panel reads.
|
|
12
|
+
#
|
|
13
|
+
# The two partial unique indexes enforce ONE IN-PROGRESS response per signed-in
|
|
14
|
+
# user, and per anonymous session token, for each survey. Completed rows fall
|
|
15
|
+
# out of both, so a respondent's history is unconstrained.
|
|
16
|
+
class CreateStudioSurveyResponses < ActiveRecord::Migration[7.2]
|
|
17
|
+
def change
|
|
18
|
+
create_table :studio_survey_responses do |t|
|
|
19
|
+
t.string :survey_slug, null: false
|
|
20
|
+
t.string :survey_version
|
|
21
|
+
if connection.adapter_name.match?(/postg/i)
|
|
22
|
+
t.jsonb :answers, null: false, default: {}
|
|
23
|
+
else
|
|
24
|
+
t.json :answers, null: false, default: {}
|
|
25
|
+
end
|
|
26
|
+
t.bigint :user_id
|
|
27
|
+
t.string :email_ref
|
|
28
|
+
t.string :session_token
|
|
29
|
+
t.string :user_agent_class
|
|
30
|
+
t.string :current_key
|
|
31
|
+
t.datetime :started_at, null: false
|
|
32
|
+
t.datetime :completed_at
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
add_index :studio_survey_responses, %i[survey_slug completed_at]
|
|
38
|
+
add_index :studio_survey_responses, :user_id
|
|
39
|
+
add_index :studio_survey_responses, :email_ref
|
|
40
|
+
add_index :studio_survey_responses, %i[survey_slug user_id], unique: true,
|
|
41
|
+
where: "completed_at IS NULL AND user_id IS NOT NULL",
|
|
42
|
+
name: "index_studio_survey_responses_one_open_per_user"
|
|
43
|
+
add_index :studio_survey_responses, %i[survey_slug session_token], unique: true,
|
|
44
|
+
where: "completed_at IS NULL AND session_token IS NOT NULL",
|
|
45
|
+
name: "index_studio_survey_responses_one_open_per_session"
|
|
46
|
+
end
|
|
47
|
+
end
|
data/docs/SITE_FOOTER.md
CHANGED
|
@@ -14,6 +14,79 @@ studio-engine | grep docs/` finds them. The repository copy is
|
|
|
14
14
|
|
|
15
15
|
## Adopt it
|
|
16
16
|
|
|
17
|
+
The app owns its layout, so the footer is one line the app writes. The engine
|
|
18
|
+
never injects it.
|
|
19
|
+
|
|
20
|
+
```erb
|
|
21
|
+
<%# app/views/layouts/application.html.erb, at the end of <body> %>
|
|
22
|
+
<%= studio_site_footer %>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**The footer is on by default.** That line, with nothing configured, renders
|
|
26
|
+
the default footer. `config.site_footer` has three states:
|
|
27
|
+
|
|
28
|
+
| `config.site_footer` | What `studio_site_footer` renders |
|
|
29
|
+
|----------------------|-----------------------------------|
|
|
30
|
+
| unset (`nil`) | The default footer |
|
|
31
|
+
| a Hash, or a callable receiving the view | The app's own facts ([Your own facts](#your-own-facts)) |
|
|
32
|
+
| `false` | Nothing: the opt-out |
|
|
33
|
+
|
|
34
|
+
### The default footer
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/initializers/studio.rb
|
|
38
|
+
Studio.configure do |config|
|
|
39
|
+
config.app_name = "Example Co" # nothing about the footer: it is already on
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It prints only what the engine knows to be true of the app:
|
|
44
|
+
|
|
45
|
+
- **The name**, as the wordmark and the © line: the site identity's title
|
|
46
|
+
(`Studio.site_identity[:title]`), which is `config.app_name` until the
|
|
47
|
+
operator or `config.site_title` says otherwise.
|
|
48
|
+
- **The logo**: the app's "Footer Logo" in `config.theme_logos`, else its
|
|
49
|
+
navbar logo. None configured, none shown.
|
|
50
|
+
- **A legal line**, for the routes the host has: "Privacy Policy" when it has a
|
|
51
|
+
route named `privacy`, "Terms of Service" when it has one named `terms`
|
|
52
|
+
(`get "privacy", to: "legal#privacy", as: :privacy`). A route is linked only
|
|
53
|
+
when a GET reaches it and its path needs no arguments. A host with neither
|
|
54
|
+
gets the © line alone.
|
|
55
|
+
|
|
56
|
+
It has no address, no map, no phone, no email, no tagline, no social row and no
|
|
57
|
+
link columns, and the page names no Leaflet. The engine cannot know those, and
|
|
58
|
+
a guessed link is a dead one. For any of them, add a location (below) or
|
|
59
|
+
declare [your own facts](#your-own-facts).
|
|
60
|
+
|
|
61
|
+
### A location, in one setting
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Studio.configure do |config|
|
|
65
|
+
config.site_footer_address = { street: "123 Example St", city_line: "Washington, DC 20024",
|
|
66
|
+
lat: 38.8894, lng: -77.0352 }
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
That adds the Location band, and the map when `lat:` and `lng:` are both there,
|
|
71
|
+
to the default footer. The keys are the facts' `address:` keys
|
|
72
|
+
([The address and the map](#the-address-and-the-map)). It also gives a location
|
|
73
|
+
to an app's own facts that carry none. **An address in the app's own facts
|
|
74
|
+
wins**, whole, and `address: false` there shows none.
|
|
75
|
+
|
|
76
|
+
### Turning it off
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Studio.configure do |config|
|
|
80
|
+
config.site_footer = false # no footer on any page; unset (nil) is the default footer
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
To keep the footer off some pages rather than all of them, narrow
|
|
85
|
+
[where it shows](#where-it-shows) instead. A facts callable that answers `nil`
|
|
86
|
+
for a request renders no footer for that request.
|
|
87
|
+
|
|
88
|
+
### Your own facts
|
|
89
|
+
|
|
17
90
|
```ruby
|
|
18
91
|
# config/initializers/studio.rb
|
|
19
92
|
Studio.configure do |config|
|
|
@@ -39,11 +112,6 @@ Studio.configure do |config|
|
|
|
39
112
|
end
|
|
40
113
|
```
|
|
41
114
|
|
|
42
|
-
```erb
|
|
43
|
-
<%# app/views/layouts/application.html.erb, at the end of <body> %>
|
|
44
|
-
<%= studio_site_footer %>
|
|
45
|
-
```
|
|
46
|
-
|
|
47
115
|
That is the whole adoption. Nothing is copied into the app's `public/`, and
|
|
48
116
|
nothing is added to its asset manifest or its Tailwind build.
|
|
49
117
|
|
|
@@ -78,6 +146,10 @@ end
|
|
|
78
146
|
<%= studio_site_footer %>
|
|
79
147
|
```
|
|
80
148
|
|
|
149
|
+
Leave `config.site_footer` unset and the same app gets
|
|
150
|
+
[the default footer](#the-default-footer), its name read from `config.app_name`
|
|
151
|
+
since there is no site-identity table to ask.
|
|
152
|
+
|
|
81
153
|
Do not call `Studio.routes(self)`: everything it draws (sign-in, admin, error
|
|
82
154
|
logs) needs a database. The app also needs an asset pipeline (Propshaft or
|
|
83
155
|
Sprockets), as every Rails 8 app has, and a mapping of the theme tokens onto its
|
|
@@ -107,8 +179,9 @@ development and with `eager_load = true`.
|
|
|
107
179
|
## The facts
|
|
108
180
|
|
|
109
181
|
`config.site_footer` is a Hash, or a callable that receives the view (so it can
|
|
110
|
-
use route helpers). `nil
|
|
111
|
-
`studio_site_footer` renders nothing
|
|
182
|
+
use route helpers). Unset (`nil`), the facts are the default footer's; `false`
|
|
183
|
+
means the app has no footer and `studio_site_footer` renders nothing
|
|
184
|
+
([Adopt it](#adopt-it)).
|
|
112
185
|
|
|
113
186
|
Every key is optional. A missing key removes its part of the footer.
|
|
114
187
|
|
|
@@ -121,7 +194,7 @@ Every key is optional. A missing key removes its part of the footer.
|
|
|
121
194
|
| `home_path` | Where the logo and wordmark link | `/` |
|
|
122
195
|
| `tagline` | A line under the wordmark | No line |
|
|
123
196
|
| `email` | A `mailto:` link under the tagline | No line. (To list the email in a column instead, write it as a column link.) |
|
|
124
|
-
| `address` | The Location band, and the map |
|
|
197
|
+
| `address` | The Location band, and the map | `config.site_footer_address`; with neither, no Location band, no map, and Leaflet is never requested. `address: false` shows none even with the setting |
|
|
125
198
|
| `social` | A row of round icons | No row |
|
|
126
199
|
| `columns` | Link columns under bold headings. A column may carry a width hint: `[ "Contact", links, { width: 1.5 } ]` | Brand only |
|
|
127
200
|
| `legal` | The centred line above the © | The © alone |
|
|
@@ -356,14 +429,19 @@ link. Those rules are more specific than the engine's and still apply.
|
|
|
356
429
|
|
|
357
430
|
## Tests
|
|
358
431
|
|
|
359
|
-
- `test/lib/studio/site_footer_test.rb`: the facts' rules
|
|
432
|
+
- `test/lib/studio/site_footer_test.rb`: the facts' rules, the default facts, the
|
|
433
|
+
address setting's precedence, and the visibility rule.
|
|
360
434
|
- `test/lib/vendored_leaflet_test.rb`: Leaflet is vendored, precompiled, and
|
|
361
435
|
addressed through the asset pipeline.
|
|
362
436
|
- `test/integration/site_footer_test.rb`: what a host renders, with and without
|
|
363
|
-
each fact, signed in and out
|
|
437
|
+
each fact, signed in and out; the default footer with nothing set, the
|
|
438
|
+
`false` opt-out, `config.site_footer_address` on the default footer and
|
|
439
|
+
against an app's own address, and the default legal links with and without
|
|
440
|
+
the host's routes.
|
|
364
441
|
- `test/integration/footer_only_consumer_test.rb`: an app with no ActiveRecord
|
|
365
442
|
boots (development and eager-loaded), renders the footer with no Leaflet, and
|
|
366
|
-
runs `studio_engine:install:migrations` to a clean exit
|
|
443
|
+
runs `studio_engine:install:migrations` to a clean exit; with nothing set it
|
|
444
|
+
renders the default footer.
|
|
367
445
|
- `test/lib/site_footer_link_contrast_test.rb`: the link ink clears 4.5:1 on the
|
|
368
446
|
band, on the default tokens, in both themes.
|
|
369
447
|
- `e2e/site_footer.spec.js`: the map's script and the footer's layout, in a
|
|
@@ -372,4 +450,5 @@ link. Those rules are more specific than the engine's and still apply.
|
|
|
372
450
|
line inside its column at each, and the line heights. These lab pages load Turbo, so the map's remount after a Turbo visit is
|
|
373
451
|
covered.
|
|
374
452
|
|
|
375
|
-
`/admin/style` shows the footer under Tricks, with this app's facts or a
|
|
453
|
+
`/admin/style` shows the footer under Tricks, with this app's own facts, or a
|
|
454
|
+
sample when it has declared none (the default footer or `false`).
|
data/lib/studio/engine.rb
CHANGED
|
@@ -170,6 +170,15 @@ module Studio
|
|
|
170
170
|
|
|
171
171
|
public
|
|
172
172
|
|
|
173
|
+
# Survey definitions (docs/SURVEYS.md): every *.rb under
|
|
174
|
+
# Studio.survey_definitions_path, loaded on boot and again on each
|
|
175
|
+
# development reload so an edited definition takes effect without a restart.
|
|
176
|
+
# Surveys defined in an initializer instead stay registered either way.
|
|
177
|
+
config.to_prepare do
|
|
178
|
+
dir = Studio.survey_definitions_path
|
|
179
|
+
Studio::Survey.load_definitions!(Rails.root.join(dir)) if dir.present? && Rails.root
|
|
180
|
+
end
|
|
181
|
+
|
|
173
182
|
config.after_initialize do
|
|
174
183
|
# Configure the IP -> location provider for every app that has the gem,
|
|
175
184
|
# so geo detection works the same way everywhere: HTTPS (without which
|
data/lib/studio/site_footer.rb
CHANGED
|
@@ -7,7 +7,8 @@ require "cgi"
|
|
|
7
7
|
# booting the dummy app. See docs/SITE_FOOTER.md.
|
|
8
8
|
#
|
|
9
9
|
# The declared value is a Hash, or a callable that receives the view (for route
|
|
10
|
-
# helpers).
|
|
10
|
+
# helpers). Unset (nil) is the DEFAULT footer (`default_facts`), and `false` is
|
|
11
|
+
# no footer. EVERY KEY IS OPTIONAL, and a missing key removes its part of the
|
|
11
12
|
# footer rather than printing a placeholder:
|
|
12
13
|
#
|
|
13
14
|
# config.site_footer = ->(view) {
|
|
@@ -105,21 +106,73 @@ module Studio
|
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
def validate!(declared)
|
|
108
|
-
return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
|
|
109
|
+
return if declared.nil? || declared == false || declared.is_a?(Hash) || declared.respond_to?(:call)
|
|
109
110
|
|
|
110
111
|
raise ArgumentError,
|
|
111
|
-
"Studio.site_footer must be nil, a Hash, or a
|
|
112
|
+
"Studio.site_footer must be nil (the default footer), false (no footer), a Hash, or a " \
|
|
113
|
+
"callable receiving the view (got #{declared.class}). See docs/SITE_FOOTER.md."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_address!(declared)
|
|
117
|
+
return if declared.nil? || declared == false || declared.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Studio.site_footer_address must be nil or a Hash of street:, city_line:, lat:, lng: " \
|
|
112
121
|
"(got #{declared.class}). See docs/SITE_FOOTER.md."
|
|
113
122
|
end
|
|
114
123
|
|
|
115
|
-
#
|
|
124
|
+
# THE DEFAULT FOOTER'S FACTS: what an app that sets nothing gets. Only what
|
|
125
|
+
# is known to be true of the host: a legal line linking the routes in
|
|
126
|
+
# DEFAULT_LEGAL that the host really has. The name and the logo come from
|
|
127
|
+
# `resolve`'s own fallbacks. No address, no email, no tagline, no social row
|
|
128
|
+
# and no link columns: the engine cannot know them, and a guessed link is a
|
|
129
|
+
# dead one.
|
|
130
|
+
#
|
|
131
|
+
# routes: the host's route set (Rails.application.routes), or nil to ask
|
|
132
|
+
# the view alone.
|
|
133
|
+
DEFAULT_LEGAL = [["Privacy Policy", :privacy], ["Terms of Service", :terms]].freeze
|
|
134
|
+
|
|
135
|
+
def default_facts(view, routes: nil)
|
|
136
|
+
legal = DEFAULT_LEGAL.filter_map do |label, route_name|
|
|
137
|
+
path = host_path(view, route_name, routes)
|
|
138
|
+
[label, path] unless path.nil?
|
|
139
|
+
end
|
|
140
|
+
{ legal: legal }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The path of the host's route `name`, or nil unless the view can build it
|
|
144
|
+
# with no arguments and (when the route set is given) a GET reaches it.
|
|
145
|
+
def host_path(view, name, routes = nil)
|
|
146
|
+
helper = :"#{name}_path"
|
|
147
|
+
return nil unless view.respond_to?(helper)
|
|
148
|
+
return nil unless routes.nil? || get_route?(routes, name)
|
|
149
|
+
|
|
150
|
+
text(view.public_send(helper))
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def get_route?(routes, name)
|
|
156
|
+
route = routes.named_routes[name]
|
|
157
|
+
return false if route.nil? || route.required_parts.any?
|
|
158
|
+
|
|
159
|
+
verbs = route.verb.to_s
|
|
160
|
+
verbs.empty? || verbs.split("|").include?("GET")
|
|
161
|
+
rescue StandardError
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The normalized facts, or nil when there is no footer (the declared value,
|
|
166
|
+
# or what its callable answered, is nil or false).
|
|
116
167
|
#
|
|
117
168
|
# name: the site name the engine resolved (the site identity's title)
|
|
118
169
|
# logo: the logo the engine resolved (the navbar logo), or nil
|
|
119
170
|
# booking_path: the booking page's path (Studio.booking_path_for), or nil
|
|
120
|
-
|
|
171
|
+
# address: Studio.site_footer_address: the address of facts that
|
|
172
|
+
# carry none of their own
|
|
173
|
+
def resolve(declared, view, name: nil, logo: nil, booking_path: nil, address: nil)
|
|
121
174
|
raw = declared.respond_to?(:call) ? declared.call(view) : declared
|
|
122
|
-
return nil if raw.nil?
|
|
175
|
+
return nil if raw.nil? || raw == false
|
|
123
176
|
|
|
124
177
|
facts = symbolize(raw)
|
|
125
178
|
site_name = text(facts[:name]) || text(name)
|
|
@@ -133,7 +186,7 @@ module Studio
|
|
|
133
186
|
home_path: safe_href(facts[:home_path], "home_path") || "/",
|
|
134
187
|
tagline: text(facts[:tagline]),
|
|
135
188
|
email: text(facts[:email]),
|
|
136
|
-
address:
|
|
189
|
+
address: address_for(facts, address),
|
|
137
190
|
social: Array(facts[:social]).filter_map { |row| social(row) },
|
|
138
191
|
columns: Array(facts[:columns]).filter_map { |row| column(row, booking_path) },
|
|
139
192
|
legal: Array(facts[:legal]).filter_map { |row| link(row, booking_path) },
|
|
@@ -205,7 +258,7 @@ module Studio
|
|
|
205
258
|
# no address at all: no Location band and no map.
|
|
206
259
|
def address(facts)
|
|
207
260
|
declared = facts[:address]
|
|
208
|
-
source = declared.nil? ? facts : symbolize(declared)
|
|
261
|
+
source = declared.nil? || declared == false ? facts : symbolize(declared)
|
|
209
262
|
street = text(source[:street])
|
|
210
263
|
city_line = text(source[:city_line])
|
|
211
264
|
return nil if street.nil? && city_line.nil?
|
|
@@ -228,6 +281,16 @@ module Studio
|
|
|
228
281
|
}
|
|
229
282
|
end
|
|
230
283
|
|
|
284
|
+
# The facts' own address, else the configured one (Studio.site_footer_address).
|
|
285
|
+
# The facts win: an address of their own is used whole, and an `address:` key
|
|
286
|
+
# they wrote, even nil or false, is their answer.
|
|
287
|
+
def address_for(facts, configured)
|
|
288
|
+
own = address(facts)
|
|
289
|
+
return own if !own.nil? || facts.key?(:address) || configured.nil? || configured == false
|
|
290
|
+
|
|
291
|
+
address(symbolize(configured))
|
|
292
|
+
end
|
|
293
|
+
|
|
231
294
|
def coordinate(value)
|
|
232
295
|
return nil if value.nil? || value.to_s.strip.empty?
|
|
233
296
|
|
|
@@ -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
|