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.
@@ -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">&#x1F64C;</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
- own_facts = studio_site_footer_facts
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> &middot;
47
+ footer <strong class="text-body"><%= own_facts ? "declared" : (Studio.site_footer_enabled? ? "default (not declared)" : "off") %></strong> &middot;
46
48
  booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> &middot;
47
49
  booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong> &middot;
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`, the default, means the app has no footer and
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 | No Location band, no map, and Leaflet is never requested |
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 and the visibility rule.
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 sample.
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
@@ -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). EVERY KEY IS OPTIONAL, and a missing key removes its part of the
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 callable receiving the view " \
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
- # The normalized facts, or nil when the app declared no footer.
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
- def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
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: address(facts),
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