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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e16f65ea44055886fea1ffb0743a9415caa612f858caa0ff7dabb7c87beeeb15
|
|
4
|
+
data.tar.gz: 9690ad40a9e766646995ddfa1c69c8c22191e3509bb944d5c184ea5c1a1b21e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae4786abebc9f1d9dc86cc3992f4a6f4225c4fceaa20b73fca12ddd43afad1e4d6ef2807a8d3302bdf2d811792995fdac42961a02c6b70637f8923e841e61402
|
|
7
|
+
data.tar.gz: 3ba6c43faf9e1f7a03596b70924fc099469d8c95ce967f63e4a2ba8f4f0da0112db6068e701e739fa7e3ce5435bd07ee456b2982fec91ebb9561a4bc3dd18b09
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.88.0 — 2026-10-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Surveys.** Define a survey in app code — `Studio.define_survey "first-game"
|
|
12
|
+
do ... end` in `config/surveys/*.rb`, loaded on boot and each reload — with six
|
|
13
|
+
question types (`emoji_scale`, `rating`, `choice`, `multi_choice`,
|
|
14
|
+
`short_text`, `long_text`), each with `required:` and `help:`. The engine
|
|
15
|
+
serves it at `GET /surveys/:slug`: one question per screen with a progress
|
|
16
|
+
bar, tap-to-advance, number keys and Enter, autosave of every answer, resume
|
|
17
|
+
where the respondent left off, a thank-you screen with the survey's
|
|
18
|
+
`next_action`, `prefers-reduced-motion` support, and a single-form fallback
|
|
19
|
+
without JavaScript. Answers are stored in the new `studio_survey_responses`
|
|
20
|
+
table (`Studio::SurveyResponse`) with a snapshot of each question's label and
|
|
21
|
+
the chosen option's label, so editing a survey never rewrites old responses.
|
|
22
|
+
**Opt in** with `config.draw_survey_routes = true`; **run
|
|
23
|
+
`bin/rails studio_engine:install:migrations && bin/rails db:migrate`** first.
|
|
24
|
+
- **`config.survey_ref_resolver`** (`->(controller) { ref }`) attributes an email
|
|
25
|
+
arrival, and **`config.on_survey_completed`** (`->(response) { ... }`) runs once
|
|
26
|
+
when a response completes; a raising hook is logged, never shown. A survey
|
|
27
|
+
answers to a signed-in user, an attributed email arrival, or anyone when it
|
|
28
|
+
declares `allow_anonymous true`.
|
|
29
|
+
- **The survey results panel at `/admin/surveys`**, admin-only: started,
|
|
30
|
+
completed and completion rate per survey; per-question distribution bars and
|
|
31
|
+
averages, a scrolling list of text answers with the respondent's username or
|
|
32
|
+
"anonymous"; date-range and completed-only filters; CSV export. **Opt in**
|
|
33
|
+
with `config.draw_admin_survey_routes = true`. See `docs/SURVEYS.md`.
|
|
34
|
+
|
|
7
35
|
## 0.87.0 — 2026-10-04
|
|
8
36
|
|
|
9
37
|
### Changed
|
data/README.md
CHANGED
|
@@ -43,6 +43,7 @@ resolved.
|
|
|
43
43
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
44
44
|
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
45
45
|
- **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
|
|
46
|
+
- **Surveys**: define a survey in app code (`config/surveys/*.rb`, `Studio.define_survey`) with six question types — emoji scale, 1–5 rating, single and multiple choice, short and long text — and the engine serves it at `/surveys/:slug`: one question per screen, autosave, resume, keyboard shortcuts, and a no-JavaScript fallback. `config.on_survey_completed` and `config.survey_ref_resolver` let the app fire its own goals and attribute email arrivals. An admin panel at `/admin/surveys` breaks results down per question and exports CSV. Opt in with `config.draw_survey_routes = true` and `config.draw_admin_survey_routes = true`. See [`docs/SURVEYS.md`](docs/SURVEYS.md).
|
|
46
47
|
|
|
47
48
|
## Configuration
|
|
48
49
|
|
|
@@ -137,6 +138,8 @@ Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpo
|
|
|
137
138
|
|
|
138
139
|
Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one. An app that keeps its own booking page names it with `Studio.booking_path` instead ([`docs/BOOKING.md`](docs/BOOKING.md)).
|
|
139
140
|
|
|
141
|
+
Set `Studio.draw_survey_routes = true` to add the public survey pages (`/surveys/:slug`, helper `studio_survey_path`) and `Studio.draw_admin_survey_routes = true` to add the admin results panel (`/admin/surveys`, helpers `admin_surveys_path` / `admin_survey_path`) — both off by default because an app may already own those paths. Both need the `studio_survey_responses` table ([`docs/SURVEYS.md`](docs/SURVEYS.md)).
|
|
142
|
+
|
|
140
143
|
**Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
|
|
141
144
|
|
|
142
145
|
In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The survey results panel (docs/SURVEYS.md). Admin-only, drawn only when
|
|
5
|
+
# Studio.draw_admin_survey_routes is on.
|
|
6
|
+
#
|
|
7
|
+
# GET /admin/surveys every survey: started, completed, rate
|
|
8
|
+
# GET /admin/surveys/:slug per-question breakdown, filterable
|
|
9
|
+
# GET /admin/surveys/:slug/export the same filtered responses as CSV
|
|
10
|
+
#
|
|
11
|
+
# A slug with stored responses but no definition left in app code still lists
|
|
12
|
+
# and exports (its breakdown needs the definition, so its page says so).
|
|
13
|
+
class AdminSurveysController < ::ApplicationController
|
|
14
|
+
before_action :require_admin
|
|
15
|
+
before_action :require_table
|
|
16
|
+
|
|
17
|
+
def index
|
|
18
|
+
started = Studio::SurveyResponse.group(:survey_slug).count
|
|
19
|
+
completed = Studio::SurveyResponse.completed.group(:survey_slug).count
|
|
20
|
+
slugs = (Studio.surveys.map(&:slug) + started.keys).uniq.sort
|
|
21
|
+
@rows = slugs.map do |slug|
|
|
22
|
+
s = started.fetch(slug, 0)
|
|
23
|
+
c = completed.fetch(slug, 0)
|
|
24
|
+
{ slug: slug, survey: Studio.survey(slug), started: s, completed: c,
|
|
25
|
+
rate: Studio::Survey::Breakdown.percent(c, s) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
@slug = params[:slug].to_s
|
|
31
|
+
@survey = Studio.survey(@slug)
|
|
32
|
+
@filters = filters
|
|
33
|
+
scope = filtered_scope
|
|
34
|
+
@started = scope.count
|
|
35
|
+
@completed = scope.completed.count
|
|
36
|
+
return unless @survey
|
|
37
|
+
|
|
38
|
+
@breakdown = Studio::Survey::Breakdown.new(@survey, breakdown_rows(scope))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def export
|
|
42
|
+
slug = params[:slug].to_s
|
|
43
|
+
survey = Studio.survey(slug)
|
|
44
|
+
@filters = filters
|
|
45
|
+
@slug = slug
|
|
46
|
+
rows = filtered_scope.includes(:user).order(:id).map do |r|
|
|
47
|
+
{ response_id: r.id, survey_version: r.survey_version, status: r.completed? ? "completed" : "in_progress",
|
|
48
|
+
started_at: r.started_at, completed_at: r.completed_at, respondent: r.respondent_label,
|
|
49
|
+
user_id: r.user_id, email_ref: r.email_ref, user_agent_class: r.user_agent_class, answers: r.answers }
|
|
50
|
+
end
|
|
51
|
+
csv = Studio::Survey::Export.new(survey, rows).to_csv
|
|
52
|
+
send_data csv, type: "text/csv; charset=utf-8",
|
|
53
|
+
filename: "survey-#{slug.parameterize}-#{Time.current.strftime("%Y%m%d")}.csv"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def require_table
|
|
59
|
+
return if Studio::SurveyResponse.table_ready?
|
|
60
|
+
|
|
61
|
+
raise Studio::SurveyResponse::MissingTable,
|
|
62
|
+
"studio_survey_responses is not installed — run bin/rails studio_engine:install:migrations && bin/rails db:migrate"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def filters
|
|
66
|
+
{
|
|
67
|
+
from: parse_date(params[:from]),
|
|
68
|
+
to: parse_date(params[:to]),
|
|
69
|
+
completed: params[:completed] == "1"
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_date(value)
|
|
74
|
+
Date.iso8601(value.to_s)
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Dates filter on started_at, inclusive of both whole days.
|
|
80
|
+
def filtered_scope
|
|
81
|
+
scope = Studio::SurveyResponse.for_survey(@slug)
|
|
82
|
+
scope = scope.where(started_at: @filters[:from].beginning_of_day..) if @filters[:from]
|
|
83
|
+
scope = scope.where(started_at: ..@filters[:to].end_of_day) if @filters[:to]
|
|
84
|
+
scope = scope.completed if @filters[:completed]
|
|
85
|
+
scope
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def breakdown_rows(scope)
|
|
89
|
+
scope.includes(:user).order(:id).map do |r|
|
|
90
|
+
{ answers: r.answers, respondent: r.respondent_label, at: r.completed_at || r.updated_at }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The public survey pages (docs/SURVEYS.md). Drawn only when
|
|
5
|
+
# Studio.draw_survey_routes is on.
|
|
6
|
+
#
|
|
7
|
+
# GET /surveys/:slug the survey: one question per screen with JS,
|
|
8
|
+
# a single form without it
|
|
9
|
+
# PATCH /surveys/:slug/answers/:key autosave one answer (JSON)
|
|
10
|
+
# POST /surveys/:slug submit: store every answer, complete, thank
|
|
11
|
+
# GET /surveys/:slug/thanks the thank-you screen and next action
|
|
12
|
+
#
|
|
13
|
+
# A response row is created on the FIRST ANSWER, not on page view, so a link
|
|
14
|
+
# preview bot or a bounce never counts as "started".
|
|
15
|
+
class SurveysController < ::ApplicationController
|
|
16
|
+
skip_before_action :require_authentication, raise: false
|
|
17
|
+
|
|
18
|
+
before_action :load_survey
|
|
19
|
+
before_action :require_table
|
|
20
|
+
before_action :require_respondent
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
@response = locate_response
|
|
24
|
+
return redirect_to(studio_survey_thanks_path(@survey.slug), status: :see_other) if @response&.completed?
|
|
25
|
+
|
|
26
|
+
@errors = {}
|
|
27
|
+
render_survey
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def answer
|
|
31
|
+
question = @survey.question(params[:key])
|
|
32
|
+
return render(json: { error: "unknown question" }, status: :not_found) unless question
|
|
33
|
+
|
|
34
|
+
response = open_response!
|
|
35
|
+
return render(json: { error: "already complete" }, status: :conflict) unless response
|
|
36
|
+
|
|
37
|
+
ok, error = rescue_survey_write(response) { response.record_answer(question, params[:value]) }
|
|
38
|
+
if ok
|
|
39
|
+
render json: { saved: true, key: question.key, answered: response.answers.keys & @survey.keys }
|
|
40
|
+
else
|
|
41
|
+
render json: { saved: false, error: error }, status: :unprocessable_entity
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def submit
|
|
46
|
+
response = open_response!
|
|
47
|
+
return redirect_to(studio_survey_thanks_path(@survey.slug), status: :see_other) unless response
|
|
48
|
+
|
|
49
|
+
submitted = submitted_answers
|
|
50
|
+
@errors = {}
|
|
51
|
+
Studio::SurveyResponse.transaction do
|
|
52
|
+
@survey.questions.each do |question|
|
|
53
|
+
next unless submitted.key?(question.key)
|
|
54
|
+
|
|
55
|
+
ok, error = response.record_answer(question, submitted[question.key])
|
|
56
|
+
@errors[question.key] = error unless ok
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
response.missing_required(@survey).each { |key| @errors[key] ||= "This one is required." }
|
|
60
|
+
|
|
61
|
+
if @errors.empty? && response.complete!(@survey)
|
|
62
|
+
run_completed_hook(response)
|
|
63
|
+
redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
|
|
64
|
+
elsif response.completed?
|
|
65
|
+
# A racing submit completed it first; that one fired the hook.
|
|
66
|
+
redirect_to studio_survey_thanks_path(@survey.slug), status: :see_other
|
|
67
|
+
else
|
|
68
|
+
@response = response
|
|
69
|
+
render_survey(status: :unprocessable_entity)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def thanks
|
|
74
|
+
@response = locate_response
|
|
75
|
+
redirect_to(studio_survey_path(@survey.slug), status: :see_other) unless @response&.completed?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def load_survey
|
|
81
|
+
@survey = Studio.survey(params[:slug])
|
|
82
|
+
render :not_found, status: :not_found unless @survey
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def require_table
|
|
86
|
+
return if Studio::SurveyResponse.table_ready?
|
|
87
|
+
|
|
88
|
+
raise Studio::SurveyResponse::MissingTable,
|
|
89
|
+
"studio_survey_responses is not installed — run bin/rails studio_engine:install:migrations && bin/rails db:migrate"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Signed in, arrived by an attributed email link, or an anonymous survey.
|
|
93
|
+
def require_respondent
|
|
94
|
+
return if @survey.allow_anonymous? || survey_user || survey_ref.present?
|
|
95
|
+
|
|
96
|
+
render :sign_in_required, status: :unauthorized
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render_survey(status: :ok)
|
|
100
|
+
@resume_key = @errors.keys.first || @response&.resume_key(@survey)
|
|
101
|
+
@resume_index = @resume_key ? @survey.keys.index(@resume_key) : nil
|
|
102
|
+
render :show, status: status
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def survey_user
|
|
106
|
+
return @survey_user if defined?(@survey_user)
|
|
107
|
+
|
|
108
|
+
@survey_user = respond_to?(:current_user, true) ? current_user : nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def survey_ref
|
|
112
|
+
return @survey_ref if defined?(@survey_ref)
|
|
113
|
+
|
|
114
|
+
@survey_ref = begin
|
|
115
|
+
Studio.survey_ref_resolver&.call(self).to_s.strip.presence
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
log_survey_error(e)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def token_key = "studio_survey_#{@survey.slug}"
|
|
123
|
+
|
|
124
|
+
def session_token
|
|
125
|
+
session[token_key] ||= SecureRandom.urlsafe_base64(24)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def locate_response
|
|
129
|
+
Studio::SurveyResponse.locate(@survey, user: survey_user, token: session[token_key])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The visitor's open response, created on demand; nil if theirs is complete.
|
|
133
|
+
def open_response!
|
|
134
|
+
existing = locate_response
|
|
135
|
+
return nil if existing&.completed?
|
|
136
|
+
|
|
137
|
+
response = existing || Studio::SurveyResponse.open_for!(
|
|
138
|
+
@survey, user: survey_user, token: session_token, email_ref: survey_ref, user_agent: request.user_agent
|
|
139
|
+
)
|
|
140
|
+
response.update!(email_ref: survey_ref.first(255)) if response.email_ref.blank? && survey_ref.present?
|
|
141
|
+
response
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def submitted_answers
|
|
145
|
+
raw = params[:answers]
|
|
146
|
+
return {} unless raw.respond_to?(:permit)
|
|
147
|
+
|
|
148
|
+
permitted = @survey.questions.map { |q| q.multi? ? { q.key => [] } : q.key }
|
|
149
|
+
raw.permit(*permitted).to_h
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def rescue_survey_write(target)
|
|
153
|
+
yield
|
|
154
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
155
|
+
log_survey_error(e, target: target)
|
|
156
|
+
[false, "Couldn't save that answer. Please try again."]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_completed_hook(response)
|
|
160
|
+
Studio.on_survey_completed&.call(response)
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
log_survey_error(e, target: response)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def log_survey_error(error, target: nil)
|
|
166
|
+
log = defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!) ? ::ErrorLog.capture!(error) : nil
|
|
167
|
+
log.update(target: target) if log && target && log.respond_to?(:target=)
|
|
168
|
+
Rails.logger.error("[studio-survey] #{error.class}: #{error.message}")
|
|
169
|
+
rescue StandardError
|
|
170
|
+
Rails.logger.error("[studio-survey] #{error.class}: #{error.message}")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# One respondent's answers to one Studio::Survey (docs/SURVEYS.md).
|
|
5
|
+
#
|
|
6
|
+
# The survey is app code; this row is the only thing stored. Each answer is a
|
|
7
|
+
# self-describing snapshot — value, question label, type, and the option
|
|
8
|
+
# label(s) chosen — so a response stays readable after its survey changes.
|
|
9
|
+
class SurveyResponse < ApplicationRecord
|
|
10
|
+
self.table_name = "studio_survey_responses"
|
|
11
|
+
|
|
12
|
+
class MissingTable < StandardError; end
|
|
13
|
+
|
|
14
|
+
belongs_to :user, class_name: "::User", optional: true
|
|
15
|
+
|
|
16
|
+
validates :survey_slug, presence: true
|
|
17
|
+
validates :started_at, presence: true
|
|
18
|
+
|
|
19
|
+
scope :completed, -> { where.not(completed_at: nil) }
|
|
20
|
+
scope :in_progress, -> { where(completed_at: nil) }
|
|
21
|
+
scope :for_survey, ->(slug) { where(survey_slug: slug.to_s) }
|
|
22
|
+
|
|
23
|
+
before_validation { self.started_at ||= Time.current }
|
|
24
|
+
|
|
25
|
+
UA_BOT = /bot|crawl|spider|slurp|preview|facebookexternalhit|curl|wget|python|headless/i
|
|
26
|
+
UA_TABLET = /ipad|tablet|kindle|silk|(android(?!.*mobile))/i
|
|
27
|
+
UA_MOBILE = /mobi|iphone|ipod|android|blackberry|opera mini|iemobile/i
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def table_ready?
|
|
31
|
+
table_exists?
|
|
32
|
+
rescue ActiveRecord::ActiveRecordError, NameError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The coarse device class stored instead of the raw user agent.
|
|
37
|
+
def user_agent_class(user_agent)
|
|
38
|
+
ua = user_agent.to_s
|
|
39
|
+
return "unknown" if ua.strip.empty?
|
|
40
|
+
return "bot" if ua.match?(UA_BOT)
|
|
41
|
+
return "tablet" if ua.match?(UA_TABLET)
|
|
42
|
+
return "mobile" if ua.match?(UA_MOBILE)
|
|
43
|
+
|
|
44
|
+
"desktop"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The visitor's open response, or their latest completed one: by user
|
|
48
|
+
# first, then by the session token. An anonymous response found by token
|
|
49
|
+
# is claimed by the user who signs in mid-survey, and the claim drops the
|
|
50
|
+
# token: logout keeps the survey session key, so a token on a user's row
|
|
51
|
+
# would hand it to the next person to sign in on that browser.
|
|
52
|
+
def locate(survey, user: nil, token: nil)
|
|
53
|
+
scope = for_survey(survey.slug)
|
|
54
|
+
found = user && scope.where(user_id: user.id).order(completed_at: :desc, id: :desc).find_by(completed_at: nil)
|
|
55
|
+
found ||= token.present? && scope.where(session_token: token, user_id: nil).order(id: :desc).first
|
|
56
|
+
found ||= user && scope.where(user_id: user.id).order(id: :desc).first
|
|
57
|
+
return nil unless found
|
|
58
|
+
|
|
59
|
+
found.update!(user_id: user.id, session_token: nil) if user && found.user_id.nil? && found.open?
|
|
60
|
+
found
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Find the visitor's open response or start one. The partial unique
|
|
64
|
+
# indexes make a double-click race lose to the row that won.
|
|
65
|
+
def open_for!(survey, user: nil, token:, email_ref: nil, user_agent: nil)
|
|
66
|
+
existing = locate(survey, user: user, token: token)
|
|
67
|
+
return existing if existing&.open?
|
|
68
|
+
|
|
69
|
+
create!(survey_slug: survey.slug, survey_version: survey.version, user_id: user&.id,
|
|
70
|
+
session_token: (token unless user), email_ref: email_ref.to_s.strip.presence&.first(255),
|
|
71
|
+
user_agent_class: user_agent_class(user_agent), answers: {})
|
|
72
|
+
rescue ActiveRecord::RecordNotUnique
|
|
73
|
+
locate(survey, user: user, token: token) || raise
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def open? = completed_at.nil?
|
|
78
|
+
def completed? = completed_at.present?
|
|
79
|
+
|
|
80
|
+
def answer_entry(key) = (answers || {})[key.to_s]
|
|
81
|
+
def answer_value(key) = answer_entry(key)&.dig("value")
|
|
82
|
+
|
|
83
|
+
def answered?(question)
|
|
84
|
+
entry = answer_entry(question.key)
|
|
85
|
+
entry.is_a?(Hash) && !question.blank_value?(entry["value"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Stores one answer (or clears it, for a blank). Returns [ok, error]. Never
|
|
89
|
+
# touches a completed response: a finished survey is a record, not a draft.
|
|
90
|
+
def record_answer(question, raw)
|
|
91
|
+
return [false, "This survey is already complete."] if completed?
|
|
92
|
+
|
|
93
|
+
value, error = question.normalize(raw)
|
|
94
|
+
return [false, error] if error
|
|
95
|
+
|
|
96
|
+
next_answers = (answers || {}).dup
|
|
97
|
+
if value.nil?
|
|
98
|
+
next_answers.delete(question.key)
|
|
99
|
+
else
|
|
100
|
+
next_answers[question.key] = {
|
|
101
|
+
"value" => value, "label" => question.label, "type" => question.type.to_s,
|
|
102
|
+
"display" => question.display(value), "answered_at" => Time.current.utc.iso8601
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
self.answers = next_answers
|
|
106
|
+
self.current_key = question.key
|
|
107
|
+
save!
|
|
108
|
+
[true, nil]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def missing_required(survey)
|
|
112
|
+
survey.questions.select { |q| q.required? && !answered?(q) }.map(&:key)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The first unanswered question — where a returning respondent resumes.
|
|
116
|
+
def resume_key(survey)
|
|
117
|
+
survey.questions.find { |q| !answered?(q) }&.key || survey.questions.last.key
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Completes the response if every required question is answered. Returns
|
|
121
|
+
# true only when THIS call completed it (false if incomplete or already
|
|
122
|
+
# done). The flip is one conditional UPDATE, so two racing submits (a no-JS
|
|
123
|
+
# double click) cannot both win and fire on_survey_completed twice; the
|
|
124
|
+
# loser reloads and sees the winner's completed_at.
|
|
125
|
+
def complete!(survey)
|
|
126
|
+
return false if completed? || missing_required(survey).any?
|
|
127
|
+
|
|
128
|
+
now = Time.current
|
|
129
|
+
flipped = self.class.where(id: id, completed_at: nil)
|
|
130
|
+
.update_all(completed_at: now, survey_version: survey.version, updated_at: now)
|
|
131
|
+
unless flipped == 1
|
|
132
|
+
reload
|
|
133
|
+
return false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
self.completed_at = now
|
|
137
|
+
self.survey_version = survey.version
|
|
138
|
+
self.updated_at = now
|
|
139
|
+
clear_attribute_changes(%w[completed_at survey_version updated_at])
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# "username" for a signed-in respondent, "anonymous" otherwise.
|
|
144
|
+
def respondent_label
|
|
145
|
+
return "anonymous" unless user
|
|
146
|
+
|
|
147
|
+
user.try(:username).presence || user.try(:display_name).presence || "user ##{user.id}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%# /admin/surveys — every survey with its funnel (Studio::AdminSurveysController).
|
|
2
|
+
"Started" counts responses with at least one answer: a row is created on the
|
|
3
|
+
first answer, never on a page view. %>
|
|
4
|
+
<% content_for(:title, "Surveys") %>
|
|
5
|
+
<div class="max-w-4xl mx-auto px-4 py-6" data-admin-surveys>
|
|
6
|
+
<h1 class="text-2xl font-extrabold text-heading mb-1">Surveys</h1>
|
|
7
|
+
<p class="text-sm text-secondary mb-6">Defined in <code>config/surveys/</code>. A response counts as started at its first answer.</p>
|
|
8
|
+
|
|
9
|
+
<% if @rows.empty? %>
|
|
10
|
+
<div class="empty-state">
|
|
11
|
+
<p class="text-heading font-semibold mb-1">No surveys yet</p>
|
|
12
|
+
<p class="text-sm text-muted">Define one with <code>Studio.define_survey</code> — see the engine's docs/SURVEYS.md.</p>
|
|
13
|
+
</div>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="card overflow-x-auto">
|
|
16
|
+
<table class="w-full text-sm">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr class="text-left border-b border-subtle">
|
|
19
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium">Survey</th>
|
|
20
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium text-right">Started</th>
|
|
21
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium text-right">Completed</th>
|
|
22
|
+
<th scope="col" class="px-4 py-3 label-upper font-medium text-right">Completion</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
<% @rows.each do |row| %>
|
|
27
|
+
<tr class="border-b border-subtle last:border-0" data-admin-survey-row="<%= row[:slug] %>">
|
|
28
|
+
<td class="px-4 py-3">
|
|
29
|
+
<a href="<%= admin_survey_path(row[:slug]) %>" class="font-semibold text-heading hover:underline"><%= row[:survey]&.title || row[:slug] %></a>
|
|
30
|
+
<div class="text-xs text-muted font-mono">/surveys/<%= row[:slug] %><% unless row[:survey] %> · definition removed<% end %></div>
|
|
31
|
+
</td>
|
|
32
|
+
<td class="px-4 py-3 text-right tabular-nums text-body" data-stat="started"><%= row[:started] %></td>
|
|
33
|
+
<td class="px-4 py-3 text-right tabular-nums text-body" data-stat="completed"><%= row[:completed] %></td>
|
|
34
|
+
<td class="px-4 py-3 text-right tabular-nums font-semibold text-heading" data-stat="rate"><%= row[:started].zero? ? "—" : "#{row[:rate]}%" %></td>
|
|
35
|
+
</tr>
|
|
36
|
+
<% end %>
|
|
37
|
+
</tbody>
|
|
38
|
+
</table>
|
|
39
|
+
</div>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<%# /admin/surveys/:slug — one survey's results (Studio::AdminSurveysController).
|
|
2
|
+
Distribution bars for scale and choice questions (percent of those who
|
|
3
|
+
answered that question), a scrolling list for text answers, and filters
|
|
4
|
+
that the CSV export carries along. %>
|
|
5
|
+
<% title = @survey&.title || @slug %>
|
|
6
|
+
<% content_for(:title, "#{title} · Surveys") %>
|
|
7
|
+
<% filter_params = { from: @filters[:from]&.iso8601, to: @filters[:to]&.iso8601, completed: (@filters[:completed] ? "1" : nil) }.compact %>
|
|
8
|
+
<div class="max-w-4xl mx-auto px-4 py-6" data-admin-survey="<%= @slug %>">
|
|
9
|
+
<a href="<%= admin_surveys_path %>" class="text-sm text-secondary hover:underline">← All surveys</a>
|
|
10
|
+
<div class="flex flex-wrap items-end justify-between gap-3 mt-2 mb-6">
|
|
11
|
+
<div>
|
|
12
|
+
<h1 class="text-2xl font-extrabold text-heading"><%= title %></h1>
|
|
13
|
+
<p class="text-xs text-muted font-mono">/surveys/<%= @slug %><% if @survey %> · version <%= @survey.version %><% end %></p>
|
|
14
|
+
</div>
|
|
15
|
+
<a href="<%= admin_survey_export_path(@slug, **filter_params) %>" class="btn btn-neutral btn-sm" data-survey-export>Export CSV</a>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<form method="get" action="<%= admin_survey_path(@slug) %>" class="card p-4 mb-6 flex flex-wrap items-end gap-4" data-survey-filters>
|
|
19
|
+
<label class="flex flex-col gap-1 text-sm">
|
|
20
|
+
<span class="label-upper">From</span>
|
|
21
|
+
<input type="date" name="from" value="<%= @filters[:from]&.iso8601 %>" class="input-field py-2">
|
|
22
|
+
</label>
|
|
23
|
+
<label class="flex flex-col gap-1 text-sm">
|
|
24
|
+
<span class="label-upper">To</span>
|
|
25
|
+
<input type="date" name="to" value="<%= @filters[:to]&.iso8601 %>" class="input-field py-2">
|
|
26
|
+
</label>
|
|
27
|
+
<label class="flex items-center gap-2 text-sm text-body py-2">
|
|
28
|
+
<input type="checkbox" name="completed" value="1" <%= "checked" if @filters[:completed] %>>
|
|
29
|
+
Completed only
|
|
30
|
+
</label>
|
|
31
|
+
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
|
32
|
+
<% if filter_params.any? %><a href="<%= admin_survey_path(@slug) %>" class="text-sm text-secondary hover:underline py-2">Clear</a><% end %>
|
|
33
|
+
</form>
|
|
34
|
+
|
|
35
|
+
<div class="grid grid-cols-3 gap-3 mb-6">
|
|
36
|
+
<div class="card p-4 text-center">
|
|
37
|
+
<div class="label-upper mb-1">Started</div>
|
|
38
|
+
<div class="text-2xl font-bold text-heading tabular-nums" data-stat="started"><%= @started %></div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="card p-4 text-center">
|
|
41
|
+
<div class="label-upper mb-1">Completed</div>
|
|
42
|
+
<div class="text-2xl font-bold text-heading tabular-nums" data-stat="completed"><%= @completed %></div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="card p-4 text-center">
|
|
45
|
+
<div class="label-upper mb-1">Completion</div>
|
|
46
|
+
<div class="text-2xl font-bold text-heading tabular-nums" data-stat="rate"><%= @started.zero? ? "—" : "#{Studio::Survey::Breakdown.percent(@completed, @started)}%" %></div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<% if @breakdown.nil? %>
|
|
51
|
+
<div class="empty-state">
|
|
52
|
+
<p class="text-heading font-semibold mb-1">This survey's definition was removed</p>
|
|
53
|
+
<p class="text-sm text-muted">Its responses are still stored — the CSV export lists every answer under its original question key.</p>
|
|
54
|
+
</div>
|
|
55
|
+
<% else %>
|
|
56
|
+
<div class="flex flex-col gap-4">
|
|
57
|
+
<% @breakdown.results.each_with_index do |result, i| %>
|
|
58
|
+
<% q = result.question %>
|
|
59
|
+
<section class="card p-5" data-survey-question="<%= q.key %>">
|
|
60
|
+
<div class="flex flex-wrap items-baseline justify-between gap-2 mb-4">
|
|
61
|
+
<h2 class="text-base font-bold text-heading"><span class="text-muted font-medium mr-1"><%= i + 1 %>.</span><%= q.label %></h2>
|
|
62
|
+
<p class="text-xs text-muted">
|
|
63
|
+
<%= pluralize(result.answered, "answer") %><% if result.average %> · average <span class="font-semibold text-heading" data-survey-average><%= result.average %></span> / 5<% end %>
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<% if result.text? %>
|
|
68
|
+
<% if result.entries.empty? %>
|
|
69
|
+
<p class="text-sm text-muted">No answers yet.</p>
|
|
70
|
+
<% else %>
|
|
71
|
+
<ul class="flex flex-col gap-2 overflow-y-auto pr-1" style="max-height: 22rem;" data-survey-text-answers>
|
|
72
|
+
<% result.entries.each do |entry| %>
|
|
73
|
+
<li class="bg-inset rounded-lg px-3 py-2">
|
|
74
|
+
<p class="text-sm text-body whitespace-pre-line break-words"><%= entry.text %></p>
|
|
75
|
+
<p class="text-xs text-muted mt-1"><%= entry.respondent %><% if entry.at %> · <%= entry.at.strftime("%b %-d, %Y") %><% end %></p>
|
|
76
|
+
</li>
|
|
77
|
+
<% end %>
|
|
78
|
+
</ul>
|
|
79
|
+
<% end %>
|
|
80
|
+
<% else %>
|
|
81
|
+
<ul class="flex flex-col gap-2" data-survey-buckets>
|
|
82
|
+
<% result.buckets.each do |bucket| %>
|
|
83
|
+
<li class="grid items-center gap-3" style="grid-template-columns: minmax(6rem, 11rem) 1fr 4.5rem;" data-survey-bucket="<%= bucket.value %>">
|
|
84
|
+
<span class="text-sm text-body truncate" title="<%= bucket.label %>">
|
|
85
|
+
<% if bucket.face %><span aria-hidden="true" class="mr-1"><%= bucket.face %></span><% end %><%= bucket.label %><% end_label = { 1 => q.low_label, 5 => q.high_label }[bucket.value] if q.type == :rating %><% if end_label %> <span class="text-xs text-muted">· <%= end_label %></span><% end %><% if bucket.retired %> <span class="text-xs text-muted">(retired)</span><% end %>
|
|
86
|
+
</span>
|
|
87
|
+
<span class="h-3 rounded-full bg-inset overflow-hidden" role="img" aria-label="<%= bucket.percent %> percent">
|
|
88
|
+
<span class="block h-full rounded-full" style="width: <%= bucket.percent %>%; background: var(--color-cta);"></span>
|
|
89
|
+
</span>
|
|
90
|
+
<span class="text-sm tabular-nums text-right text-heading"><span data-survey-percent><%= bucket.percent %>%</span> <span class="text-xs text-muted">(<span data-survey-count><%= bucket.count %></span>)</span></span>
|
|
91
|
+
</li>
|
|
92
|
+
<% end %>
|
|
93
|
+
</ul>
|
|
94
|
+
<% end %>
|
|
95
|
+
</section>
|
|
96
|
+
<% end %>
|
|
97
|
+
</div>
|
|
98
|
+
<% end %>
|
|
99
|
+
</div>
|