testimonials 0.7.9 → 0.8.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/AGENTS.md +210 -0
- data/CHANGELOG.md +68 -0
- data/README.md +30 -4
- data/app/controllers/concerns/testimonials/request_context.rb +75 -0
- data/app/controllers/testimonials/application_controller.rb +10 -56
- data/app/controllers/testimonials/dashboard_controller.rb +40 -0
- data/app/controllers/testimonials/events_controller.rb +9 -0
- data/app/controllers/testimonials/nps_responses_controller.rb +1 -4
- data/app/controllers/testimonials/submissions_controller.rb +169 -0
- data/app/controllers/testimonials/testimonials_controller.rb +6 -166
- data/app/helpers/testimonials/widget_helper.rb +4 -0
- data/app/models/testimonials/prompt_event.rb +12 -0
- data/app/views/layouts/testimonials/application.html.erb +5 -8
- data/app/views/testimonials/nps_responses/index.html.erb +154 -152
- data/app/views/testimonials/nps_responses/show.html.erb +7 -5
- data/app/views/testimonials/shared/_dashboard.html.erb +18 -0
- data/app/views/testimonials/testimonials/index.html.erb +110 -108
- data/app/views/testimonials/testimonials/show.html.erb +7 -5
- data/config/routes.rb +18 -5
- data/examples/README.md +17 -0
- data/examples/badge.html.erb +13 -0
- data/examples/json_ld.html.erb +28 -0
- data/examples/static_site.md +43 -0
- data/examples/testimonial_card.html.erb +17 -0
- data/examples/wall_of_love.html.erb +43 -0
- data/lib/generators/testimonials/install/install_generator.rb +22 -10
- data/lib/generators/testimonials/install/templates/create_testimonials_tables.rb.tt +7 -5
- data/lib/generators/testimonials/install/templates/initializer.rb.tt +25 -2
- data/lib/generators/testimonials/migration_helpers.rb +35 -0
- data/lib/generators/testimonials/nps/nps_generator.rb +2 -6
- data/lib/generators/testimonials/nps/templates/create_testimonials_nps_responses.rb.tt +1 -1
- data/lib/generators/testimonials/prompt_events/prompt_events_generator.rb +35 -0
- data/lib/generators/testimonials/prompt_events/templates/create_testimonials_prompt_events.rb.tt +17 -0
- data/lib/generators/testimonials/tenant/templates/add_tenant_to_testimonials.rb.tt +11 -8
- data/lib/generators/testimonials/tenant/tenant_generator.rb +2 -6
- data/lib/testimonials/configuration.rb +34 -1
- data/lib/testimonials/dashboard.css +278 -221
- data/lib/testimonials/prompt_helper.rb +4 -0
- data/lib/testimonials/seeds.rb +3 -2
- data/lib/testimonials/version.rb +1 -1
- data/lib/testimonials/widget.js +4 -1
- data/lib/testimonials/widget.rb +3 -0
- data/lib/testimonials.rb +8 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1472a2172dce3259de44e1af6de8a7c6466d0589a6afeb037900cb154b08e5b9
|
|
4
|
+
data.tar.gz: a928753201311457628c7e38adf8164227d329d5bb753395a267c916dbdeaa08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c312e4bdff8a87463bbcbdd842a4bbe711ac2557ead095ce86f70918f70eba937ec08f00a6477c969ed1bcbfa1f6ca654c31c414c040b40fa64852f14a250d8
|
|
7
|
+
data.tar.gz: 272c88fc2b1bad4baf46849be45c2b5b9c6e2ce969cd2f0285c7cd04121e805e716026c17d4c9b8312792e85a5b35f3ad8c6d43de6876774f412c99cfe479cbf
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Instructions for coding agents. Two audiences:
|
|
4
|
+
|
|
5
|
+
- **[Installing testimonials into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add testimonials, reviews, or NPS.
|
|
6
|
+
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
|
+
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for video and avatar uploads.
|
|
9
|
+
|
|
10
|
+
If you are in a host app and this file is not in front of you, it ships inside the gem: `cat "$(bundle show testimonials)/AGENTS.md"`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installing into a Rails app
|
|
15
|
+
|
|
16
|
+
### 1. Decide two things first — they are install-time flags
|
|
17
|
+
|
|
18
|
+
Both features can be added later, but skipping them now means smaller schema and less code reaching the database. Ask the user only if the answer isn't already obvious from their request.
|
|
19
|
+
|
|
20
|
+
| Question | If no |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| Do they want NPS (the 0–10 "how likely are you to recommend" survey)? | `--skip-nps` |
|
|
23
|
+
| Should the widget ever **open itself** — `testimonial_prompt!` at a success moment — rather than only on a click? | `--skip-prompt-events` |
|
|
24
|
+
|
|
25
|
+
Default to a full install when unsure. "Just let users leave reviews from a button in the menu" is the common case for `--skip-prompt-events`.
|
|
26
|
+
|
|
27
|
+
### 2. Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bundle add testimonials
|
|
31
|
+
bin/rails generate testimonials:install # + any --skip-* flags from step 1
|
|
32
|
+
bin/rails db:migrate
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The generator writes `config/initializers/testimonials.rb`, one migration, and `mount_testimonials at: "/testimonials"` into `config/routes.rb`. Read the initializer it wrote — every option is documented there in comments, and it is the source of truth over any summary of it, including this file.
|
|
36
|
+
|
|
37
|
+
Every `config.…` line below belongs inside the `Testimonials.configure do |config|` block in that initializer. Uncomment and edit in place rather than appending a second `configure` block.
|
|
38
|
+
|
|
39
|
+
### 3. Wire the three things the generator cannot
|
|
40
|
+
|
|
41
|
+
**a. The widget tag.** Nothing renders until this is on the page:
|
|
42
|
+
|
|
43
|
+
```erb
|
|
44
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
45
|
+
<%= testimonials_tag %>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The helper is injected into ActionView by the engine — no include, no import, no asset pipeline entry. It renders nothing until the widget is opened, and it must be present on any page where the widget can open.
|
|
49
|
+
|
|
50
|
+
**b. `authorize_admin` — do this before deploying.** The dashboard at `/testimonials` defaults to **development only**. It fails closed, so shipping without this is not an open door — it is a 403 telling you to set it.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**c. Attribution**, if the app has users. Optional, but without it every submission is a guest.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
config.current_user = ->(request) { request.env["warden"]&.user } # Devise/Warden
|
|
60
|
+
config.user_display = ->(user) { { name: user.name, email: user.email } }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **Every gate receives the raw `request`, not a controller.** `current_user`, `authorize_admin`, `enabled` and `tenant` are lambdas over `ActionDispatch::Request`. Writing `->(request) { current_user }` is the single most common mistake here — that method does not exist in this scope. Resolve the user *from the request*: Warden env, a signed cookie, `Current.user` if the app sets it in a middleware/`before_action` that has already run.
|
|
64
|
+
|
|
65
|
+
Rails 8 built-in auth:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
config.current_user = lambda do |request|
|
|
69
|
+
token = request.cookies["session_token"]
|
|
70
|
+
Session.find_signed(token)&.user if token
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 4. Verify
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bin/rails routes | grep testimonials # engine mounted
|
|
78
|
+
bin/rails testimonials:seed_demo # optional sample data, idempotent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then, in the running app: load any page and confirm `data-testimonials-config` appears in the HTML source, and that `/testimonials` renders the dashboard in development. The widget stays invisible until opened — an empty-looking page is correct.
|
|
82
|
+
|
|
83
|
+
### Opening the widget
|
|
84
|
+
|
|
85
|
+
| Way | Code | Throttled? |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| Your own button | `<%= testimonials_button %>`, or any element with `data-testimonial-prompt` (`="nps"` for NPS) | No |
|
|
88
|
+
| JavaScript | `window.Testimonials.open()` / `window.Testimonials.openNps()` | No |
|
|
89
|
+
| From a controller, at a success moment | `testimonial_prompt!` / `testimonial_prompt!(:nps)` | Yes |
|
|
90
|
+
|
|
91
|
+
`testimonial_prompt!` is available in every controller (the engine includes it). It sets a flash and the widget auto-opens on the next rendered page **if** the throttle allows — already submitted, recently dismissed, or prompted `max_prompts` times means it stays shut. Call it liberally; that is the design.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
def create
|
|
95
|
+
# ...
|
|
96
|
+
testimonial_prompt! if current_user.invoices.count == 10
|
|
97
|
+
redirect_to invoices_path
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**On a `--skip-prompt-events` install `testimonial_prompt!` is a no-op** — with no history there is nothing to throttle with, and a prompt nothing can throttle would reopen on every page. Use an explicit opener instead, or add the ledger (see below).
|
|
102
|
+
|
|
103
|
+
### Rendering what was collected
|
|
104
|
+
|
|
105
|
+
The gem ships **no display UI** on purpose, and its own views are for the dashboard and the public pages only. Render approved testimonials with the host app's own markup:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Testimonials::Testimonial.publishable.featured_first.limit(6) # approved + publicly consented
|
|
109
|
+
Testimonials::Testimonial.publishable.for_tenant(key) # multi-tenant
|
|
110
|
+
Testimonials::NpsResponse.score # −100..100
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`examples/` in the gem has copy-paste starting points: `wall_of_love.html.erb`, `testimonial_card.html.erb`, `badge.html.erb`, `json_ld.html.erb` (schema.org), `static_site.md`. Prefer adapting one over inventing a layout.
|
|
114
|
+
|
|
115
|
+
There is also a read API — `GET /testimonials/api/testimonials` (filters: `featured=1`, `min_rating=4`, `kind=video`, `limit=12`) and `GET /testimonials/api/stats`. Admin-only unless `config.public_api = true`. Emails and author ids are never serialized.
|
|
116
|
+
|
|
117
|
+
### Adding a skipped feature later
|
|
118
|
+
|
|
119
|
+
Order matters: **migrate first, then flip the flag.** The flag is what the code checks, so setting it before the table exists is the one broken state.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
bin/rails generate testimonials:nps # then db:migrate, then config.nps = true
|
|
123
|
+
bin/rails generate testimonials:prompt_events # then db:migrate, then config.prompt_events = true
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Both land on exactly the schema a full install produces, tenant column and indexes included.
|
|
127
|
+
|
|
128
|
+
### Multi-tenancy
|
|
129
|
+
|
|
130
|
+
One resolver returning an **opaque key** — GlobalID, id, subdomain, slug. The gem never takes a foreign key into host models:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Optional sugar on a host model (`has_testimonials` is available on every Active Record class already):
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class Organization < ApplicationRecord
|
|
140
|
+
has_testimonials # keyed by to_gid.to_s — must match config.tenant
|
|
141
|
+
end
|
|
142
|
+
organization.testimonials.approved
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`bin/rails generate testimonials:tenant` exists **only** to add the `tenant` column to installs made before it existed. A fresh install already has it, and running that generator will fail on a duplicate column. Do not run it as part of a new install.
|
|
146
|
+
|
|
147
|
+
### Do not
|
|
148
|
+
|
|
149
|
+
- **Do not hand-write the migration or the initializer.** Use the generators; the templates track the schema.
|
|
150
|
+
- **Do not copy `widget.js` into `app/javascript`, or add a `<script>` tag for it.** `testimonials_tag` renders both tags itself, and the engine serves the code at `/testimonials/widget.js` with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
151
|
+
- **Do not edit views inside the gem.** To put the dashboard inside an existing admin, set `config.base_controller_class = "Admin::BaseController"` (it inherits that controller's layout, helpers, authentication and request context) or, for the shell alone, `config.admin_layout = "admin/application"`. The dashboard's stylesheet and script are declared by its views, so both work with nothing else wired up.
|
|
152
|
+
- **Do not check whether a table exists to decide if a feature is on.** `Testimonials.config.nps` and `Testimonials.config.prompt_events` are the source of truth, deliberately — nothing in the gem introspects the schema at runtime.
|
|
153
|
+
- **Do not set config outside the initializer.** `rate_limit` in particular is read once when the controller class loads; assigning config in a controller or per-request is a global mutation across the process.
|
|
154
|
+
- **Do not add `testimonials_tag` more than once per page,** and do not put it in a partial that some pages skip while still calling `testimonial_prompt!` — the prompt is consumed from the flash by whatever renders next.
|
|
155
|
+
|
|
156
|
+
### Configuration worth knowing
|
|
157
|
+
|
|
158
|
+
Everything is optional; a fresh install works with zero config. Full list with comments is in the generated initializer.
|
|
159
|
+
|
|
160
|
+
| Option | Default | Note |
|
|
161
|
+
| --- | --- | --- |
|
|
162
|
+
| `authorize_admin` | development only | **Set before deploying.** |
|
|
163
|
+
| `current_user`, `user_display`, `tenant`, `enabled` | no-ops | Lambdas over the raw request. |
|
|
164
|
+
| `nps` | `true` | `false` = no prompt, no public NPS page, no dashboard tab, no `nps_score`. |
|
|
165
|
+
| `prompt_events` | `true` | `false` = no prompt history, and therefore no auto-prompts at all. |
|
|
166
|
+
| `base_controller_class` | `ActionController::Base` | Controller the DASHBOARD inherits. Public endpoints never do, so an admin base controller here cannot gate the widget. |
|
|
167
|
+
| `reprompt_after`, `max_prompts` | `90.days`, `3` | Throttle for auto-prompts only. |
|
|
168
|
+
| `video`, `avatars` | `true` | Need Active Storage; they self-disable when it isn't loaded. |
|
|
169
|
+
| `public_collection` | `true` | `/testimonials/new` and `/testimonials/nps/new`. Shareable links. |
|
|
170
|
+
| `public_api` | `false` | Serves the read API without auth, CORS `*`. |
|
|
171
|
+
| `on_submit`, `on_detractor` | no-ops | Run inline after save — keep fast or enqueue a job. |
|
|
172
|
+
| `mount_path` | `"/testimonials"` | Must match `mount_testimonials at:` in routes. |
|
|
173
|
+
| `rate_limit` | `{ to: 5, within: 60 }` | Rails 7.2+; ignored on 7.1. `nil` disables. |
|
|
174
|
+
|
|
175
|
+
Turbo Drive and nonce-based CSP work out of the box; the widget config rides in a `type="application/json"` block (data, not code) and the code is a same-origin `src` script. 26 locales ship with the gem, including localized guiding questions.
|
|
176
|
+
|
|
177
|
+
### Common failure modes
|
|
178
|
+
|
|
179
|
+
| Symptom | Cause |
|
|
180
|
+
| --- | --- |
|
|
181
|
+
| `/testimonials` returns 403 "Set Testimonials.config.authorize_admin to grant access" | Exactly what it says: still at the development-only default. |
|
|
182
|
+
| Widget never appears | `testimonials_tag` missing from the rendered layout, or `config.enabled` returning false. |
|
|
183
|
+
| `testimonial_prompt!` never opens anything | `config.prompt_events` is false, or the user is throttled (submitted / dismissed recently / hit `max_prompts`). |
|
|
184
|
+
| `no such table: testimonials_nps_responses` (or `..._prompt_events`) | A flag was flipped to `true` before running the matching generator and `db:migrate`. |
|
|
185
|
+
| `/testimonials/nps_responses` 404s | `config.nps` is false. The rows are still there; flip it back to read them. |
|
|
186
|
+
| Video recording missing | Active Storage not installed, or `config.video = false`. |
|
|
187
|
+
| `NameError` for a host helper in the dashboard | `isolate_namespace` scopes `helper` to the engine. Use `config.base_controller_class` so the dashboard inherits your helpers, rather than `admin_layout` alone. |
|
|
188
|
+
| `NotNullViolation` attaching a video on a uuid-keyed app | The tables were generated bigint. Set `config.generators` `primary_key_type` before installing, or migrate the tables to uuid. |
|
|
189
|
+
| `undefined local variable current_user` in the initializer | A gate lambda treated its argument as a controller. It is a `request`. |
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Working on the gem itself
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
bundle exec rake test # minitest, dummy app under test/dummy
|
|
197
|
+
bundle exec rubocop # must be clean
|
|
198
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Layout: `app/` controllers, models, dashboard views · `lib/testimonials/` config, widget JS/CSS, seeds, engine · `lib/generators/testimonials/` install, nps, prompt_events, tenant · `config/locales/` 26 locales · `test/` minitest, `test/dummy` the host app · `examples/` display snippets for hosts.
|
|
202
|
+
|
|
203
|
+
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
204
|
+
|
|
205
|
+
- **Optional features are guarded by a config flag, never by schema introspection.** `--skip-nps` and `--skip-prompt-events` leave tables out; nothing may ask the database whether a table exists, and nothing may touch the database at boot. Guard at the entry points (one `before_action`, one model class method) so the flag is checked once, not at six call sites.
|
|
206
|
+
- **Every install shape gets a test that drops the table for real** (`test/integration/skip_nps_test.rb`, `skip_prompt_events_test.rb`), so a slipped guard raises `no such table` instead of passing quietly. Generator tests pin each migration and initializer shape and check the ERB still compiles as Ruby.
|
|
207
|
+
- **The widget is plain ES5-style JS in `lib/testimonials/widget.js`**, served by the engine, no build step, no framework. It reads its config from a JSON block on every `turbo:load`.
|
|
208
|
+
- **Display stays headless.** New display UI belongs in `examples/`, not in `app/views`.
|
|
209
|
+
- Every user-facing change bumps `lib/testimonials/version.rb` and adds a `CHANGELOG.md` entry that says what it costs, not only what it adds.
|
|
210
|
+
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet and
|
|
6
|
+
script were declared in the gem's layout, so replacing that layout dropped
|
|
7
|
+
both: the dashboard rendered unstyled with its delete confirmations and
|
|
8
|
+
auto-submitting filter dead. They move into the views (a shared
|
|
9
|
+
`testimonials/shared/_dashboard` partial), so every layout gets them with
|
|
10
|
+
nothing asked of the host.
|
|
11
|
+
- **The dashboard stylesheet no longer claims selectors it does not own.** It
|
|
12
|
+
styled bare `body`, `a`, `table` and `*`, and its `.card`, `.badge`, `.tabs`
|
|
13
|
+
and `.container` are names Bootstrap and daisyUI use too, so a host that did
|
|
14
|
+
load it had its sidebar and topbar restyled. Component rules now nest inside a
|
|
15
|
+
`.tml-dashboard` wrapper the views render, every custom property is `--tml-`
|
|
16
|
+
prefixed, and `.container`/`.nav` became `.tml-page`/`.tml-nav`. The
|
|
17
|
+
full-viewport rules stay keyed to the `tm-index`/`tm-nps-index`/`tm-show` body
|
|
18
|
+
classes that only the gem's own layout sets, so inside a host admin the
|
|
19
|
+
dashboard scrolls with the host's page instead of fighting it for the
|
|
20
|
+
viewport. Two tests fail the build if a selector or property escapes again.
|
|
21
|
+
- **Added `config.base_controller_class`.** Name the controller your own admin
|
|
22
|
+
inherits from and the dashboard adopts its layout, helpers, authentication and
|
|
23
|
+
request context — the things `admin_layout` cannot give you, and which hosts
|
|
24
|
+
were hand-wiring as a shim layout plus a concern to populate `Current`. Same
|
|
25
|
+
hook pgbus, avo and mission_control-jobs use. Default is unchanged.
|
|
26
|
+
Layout precedence with it: a host base controller keeps its own layout, unless the host also named an `admin_layout` explicitly.
|
|
27
|
+
- **Migrations follow the host's `primary_key_type`,** the same
|
|
28
|
+
`Rails.configuration.generators` lookup Rails' own Active Storage, Action Text
|
|
29
|
+
and Action Mailbox migrations do. A uuid-keyed app has a uuid
|
|
30
|
+
`active_storage_attachments.record_id`, so bigint tables here could never hold
|
|
31
|
+
a video or avatar — `attach` raised `NotNullViolation`. A host that set
|
|
32
|
+
nothing gets no `id:` option and an identical migration to before.
|
|
33
|
+
- **Dropped the `id: /\d+/` route constraints,** which were what forced the
|
|
34
|
+
tables to be bigint in the first place: uuid tables would have 404d show,
|
|
35
|
+
update, destroy and all three media routes. The constraint was never
|
|
36
|
+
load-bearing, since every fixed-name route is declared before the flat
|
|
37
|
+
`/:id` routes.
|
|
38
|
+
- **`create` moved to `Testimonials::SubmissionsController`** and `POST` to the
|
|
39
|
+
mount path routes there. One controller used to serve both the widget's write
|
|
40
|
+
endpoint and the triage actions, which meant `base_controller_class` would
|
|
41
|
+
have put staff authentication in front of every member leaving a review. If
|
|
42
|
+
you referenced `Testimonials::TestimonialsController#create`, that is the
|
|
43
|
+
breaking change in this release; the URL is unchanged.
|
|
44
|
+
- The shared request context (`current_author`, `current_tenant`,
|
|
45
|
+
`tenant_scope`, the gates) is now a `Testimonials::RequestContext` concern,
|
|
46
|
+
since the engine has two controller roots.
|
|
47
|
+
|
|
48
|
+
## 0.7.10
|
|
49
|
+
|
|
50
|
+
- The prompt-history ledger is now optional too. `bin/rails generate
|
|
51
|
+
testimonials:install --skip-prompt-events` leaves out the
|
|
52
|
+
`testimonials_prompt_events` table and writes `config.prompt_events = false`,
|
|
53
|
+
for apps that open the widget from their own button. `bin/rails generate
|
|
54
|
+
testimonials:prompt_events` adds the table later, like `testimonials:nps`.
|
|
55
|
+
- With the flag off **nothing auto-opens**: `testimonial_prompt!` is a no-op,
|
|
56
|
+
because the history exists only to throttle auto-prompts and a prompt nothing
|
|
57
|
+
can throttle would reopen on every page. Explicit opens
|
|
58
|
+
(`data-testimonial-prompt`, `window.Testimonials.open()`), both public pages,
|
|
59
|
+
the dashboard and every submission path work unchanged.
|
|
60
|
+
- `POST /testimonials/events` is refused when the flag is off, and the widget
|
|
61
|
+
stops posting shown/dismissed at all (`promptEvents.enabled` in its config).
|
|
62
|
+
- `testimonials:seed_demo` skips the demo prompt history when the ledger is
|
|
63
|
+
off, and the `testimonials:tenant` migration skips either optional table when
|
|
64
|
+
it isn't there.
|
|
65
|
+
- Adds `AGENTS.md`: install and integration instructions written for coding
|
|
66
|
+
agents, covering the request-shaped config lambdas, both skip flags, and the
|
|
67
|
+
mistakes agents actually make. It ships inside the gem, so
|
|
68
|
+
`cat "$(bundle show testimonials)/AGENTS.md"` works from a host app —
|
|
69
|
+
`examples/` now ships for the same reason.
|
|
70
|
+
|
|
3
71
|
## 0.7.9
|
|
4
72
|
|
|
5
73
|
- NPS is now optional at install time. `bin/rails generate testimonials:install
|
data/README.md
CHANGED
|
@@ -33,14 +33,23 @@ bin/rails db:migrate
|
|
|
33
33
|
The generator writes the initializer, the migration, and mounts the engine at
|
|
34
34
|
`/testimonials`. Nothing renders until the widget is opened.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Two parts are optional at install time, and skipping either leaves out its
|
|
37
|
+
table:
|
|
37
38
|
|
|
38
39
|
```bash
|
|
39
|
-
bin/rails generate testimonials:install --skip-nps
|
|
40
|
+
bin/rails generate testimonials:install --skip-nps # no NPS at all
|
|
41
|
+
bin/rails generate testimonials:install --skip-prompt-events # no auto-prompts
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
`--skip-nps` drops the 0–10 survey: no table, no dashboard tab, no prompt.
|
|
45
|
+
`--skip-prompt-events` drops the prompt history — for apps that open the widget
|
|
46
|
+
from their own button and never call `testimonial_prompt!`, since that history
|
|
47
|
+
exists only to throttle auto-prompts. See
|
|
48
|
+
[Prompting users](#prompting-users) for what changes.
|
|
49
|
+
|
|
50
|
+
Changed your mind later: `bin/rails generate testimonials:nps` or
|
|
51
|
+
`bin/rails generate testimonials:prompt_events`, migrate, and set the matching
|
|
52
|
+
flag (`config.nps`, `config.prompt_events`) to `true`.
|
|
44
53
|
|
|
45
54
|
Optional demo data:
|
|
46
55
|
|
|
@@ -59,6 +68,11 @@ refreshes those records instead of duplicating them.
|
|
|
59
68
|
|
|
60
69
|
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want video/avatar uploads.
|
|
61
70
|
|
|
71
|
+
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
72
|
+
steps in the order an agent needs them, plus the gates it tends to get wrong
|
|
73
|
+
and the things it should not do. It ships inside the gem, so
|
|
74
|
+
`cat "$(bundle show testimonials)/AGENTS.md"` works from any app that bundles it.
|
|
75
|
+
|
|
62
76
|
## What you get
|
|
63
77
|
|
|
64
78
|
| | |
|
|
@@ -132,6 +146,15 @@ The widget auto-opens on the next rendered page **if** the throttle allows:
|
|
|
132
146
|
`data-testimonial-prompt` (or `="nps"`), or `window.Testimonials.open()` /
|
|
133
147
|
`window.Testimonials.openNps()`.
|
|
134
148
|
|
|
149
|
+
That history lives in `testimonials_prompt_events`, one row per shown,
|
|
150
|
+
dismissed or submitted, and it is optional. `config.prompt_events = false` —
|
|
151
|
+
what `--skip-prompt-events` writes at install — keeps no history at all, and so
|
|
152
|
+
**nothing auto-opens**: `testimonial_prompt!` becomes a no-op, because a prompt
|
|
153
|
+
nothing can throttle would reopen on every page. Explicit opens, the public
|
|
154
|
+
pages, and everything above them are untouched, so an app that opens the widget
|
|
155
|
+
from its own button loses nothing. Want auto-prompts back:
|
|
156
|
+
`bin/rails generate testimonials:prompt_events`, migrate, flip the flag.
|
|
157
|
+
|
|
135
158
|
## Configure
|
|
136
159
|
|
|
137
160
|
Everything is optional — a fresh install works with zero config. In
|
|
@@ -140,6 +163,8 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
140
163
|
| Option | Default | What it does |
|
|
141
164
|
| --- | --- | --- |
|
|
142
165
|
| `authorize_admin` | development only | **Who can read the dashboard.** Override before deploying |
|
|
166
|
+
| `base_controller_class` | `ActionController::Base` | The controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
167
|
+
| `admin_layout` | the gem's own | Just the shell, if you don't want the whole controller |
|
|
143
168
|
| `app_name` | Rails app name | Shown as "Enjoying %{app}?" and in the questions |
|
|
144
169
|
| `enabled` | everyone | Who gets the widget. `false` hides it and rejects posts |
|
|
145
170
|
| `current_user` | `nil` | Attribute a submission to a user. Receives the request |
|
|
@@ -155,6 +180,7 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
155
180
|
| `storage_service` | app default | Active Storage service for uploads (a `storage.yml` key) |
|
|
156
181
|
| `reprompt_after` | `90.days` | Cooldown after a dismissal |
|
|
157
182
|
| `max_prompts` | `3` | Lifetime auto-prompt cap per user |
|
|
183
|
+
| `prompt_events` | `true` | The prompt history the throttle reads. `false` = no history, no auto-prompts |
|
|
158
184
|
| `public_collection` | `true` | The shareable `/testimonials/new` and `/testimonials/nps/new` pages |
|
|
159
185
|
| `public_api` | `false` | Serve the read API without auth (CORS `*`) |
|
|
160
186
|
| `nps` | `true` | The 0–10 NPS flow |
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Who is asking, which tenant they are in, and the gates that answer both.
|
|
5
|
+
#
|
|
6
|
+
# A concern rather than inherited behaviour because the engine has two
|
|
7
|
+
# controller roots: the public endpoints hang off ActionController::Base, and
|
|
8
|
+
# the dashboard hangs off whatever the host set as `base_controller_class`.
|
|
9
|
+
# Both need everything here.
|
|
10
|
+
module RequestContext
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def testimonials_admin_layout
|
|
16
|
+
Testimonials.config.admin_layout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def current_author
|
|
20
|
+
return @current_author if defined?(@current_author)
|
|
21
|
+
|
|
22
|
+
@current_author = Testimonials.config.current_user.call(request)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def current_author_id
|
|
26
|
+
current_author.respond_to?(:id) ? current_author.id : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The tenant for this request (nil = the single global collection). Every
|
|
30
|
+
# read and write in the engine scopes to it, so a resolved-tenant admin
|
|
31
|
+
# only ever sees and writes their own tenant's records.
|
|
32
|
+
def current_tenant
|
|
33
|
+
return @current_tenant if defined?(@current_tenant)
|
|
34
|
+
|
|
35
|
+
@current_tenant = Testimonials.tenant(request)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every dashboard query and the create endpoint's one-per-user lookup start
|
|
39
|
+
# here, so neither can reach across tenants.
|
|
40
|
+
def tenant_scope
|
|
41
|
+
Testimonial.for_tenant(current_tenant)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def require_enabled
|
|
45
|
+
head :forbidden unless Testimonials.enabled?(request)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render_rate_limited
|
|
49
|
+
message = I18n.t('testimonials.error_rate_limited',
|
|
50
|
+
default: 'Too many submissions. Please wait a moment and try again.')
|
|
51
|
+
render json: { errors: [message] }, status: :too_many_requests
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Server-side gate for the dashboard. Default: development only.
|
|
55
|
+
def require_admin
|
|
56
|
+
return if Testimonials.admin?(request)
|
|
57
|
+
|
|
58
|
+
render plain: 'Forbidden. Set Testimonials.config.authorize_admin to grant access.',
|
|
59
|
+
status: :forbidden
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Guests get a permanent random token so the throttle ledger can
|
|
63
|
+
# remember them across visits. Signed-in users are keyed by author_id
|
|
64
|
+
# instead and never receive the cookie.
|
|
65
|
+
def ensure_visitor_token
|
|
66
|
+
return if current_author
|
|
67
|
+
|
|
68
|
+
cookies[:testimonials_vid].presence || begin
|
|
69
|
+
token = SecureRandom.base58(24)
|
|
70
|
+
cookies.permanent[:testimonials_vid] = { value: token, httponly: true, same_site: :lax }
|
|
71
|
+
token
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -1,63 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Testimonials
|
|
4
|
+
# Root of the engine's PUBLIC surface: widget.js, the submission endpoints,
|
|
5
|
+
# the prompt-event ledger, the public collection pages, media, and the read
|
|
6
|
+
# API. These stay on a plain ActionController::Base deliberately — a member
|
|
7
|
+
# posting a testimonial must not be routed through a host's admin controller,
|
|
8
|
+
# which would demand a staff session for the widget.
|
|
9
|
+
#
|
|
10
|
+
# The dashboard's root is DashboardController, and that is where
|
|
11
|
+
# `config.base_controller_class` applies.
|
|
4
12
|
class ApplicationController < ActionController::Base
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
def testimonials_admin_layout
|
|
10
|
-
Testimonials.config.admin_layout
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def current_author
|
|
14
|
-
return @current_author if defined?(@current_author)
|
|
15
|
-
|
|
16
|
-
@current_author = Testimonials.config.current_user.call(request)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def current_author_id
|
|
20
|
-
current_author.respond_to?(:id) ? current_author.id : nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# The tenant for this request (nil = the single global collection). Every
|
|
24
|
-
# read and write in the engine scopes to it, so a resolved-tenant admin
|
|
25
|
-
# only ever sees and writes their own tenant's records.
|
|
26
|
-
def current_tenant
|
|
27
|
-
return @current_tenant if defined?(@current_tenant)
|
|
13
|
+
include RequestContext
|
|
28
14
|
|
|
29
|
-
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def require_enabled
|
|
33
|
-
head :forbidden unless Testimonials.enabled?(request)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def render_rate_limited
|
|
37
|
-
message = I18n.t('testimonials.error_rate_limited',
|
|
38
|
-
default: 'Too many submissions. Please wait a moment and try again.')
|
|
39
|
-
render json: { errors: [message] }, status: :too_many_requests
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Server-side gate for the dashboard. Default: development only.
|
|
43
|
-
def require_admin
|
|
44
|
-
return if Testimonials.admin?(request)
|
|
45
|
-
|
|
46
|
-
render plain: 'Forbidden. Set Testimonials.config.authorize_admin to grant access.',
|
|
47
|
-
status: :forbidden
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Guests get a permanent random token so the throttle ledger can
|
|
51
|
-
# remember them across visits. Signed-in users are keyed by author_id
|
|
52
|
-
# instead and never receive the cookie.
|
|
53
|
-
def ensure_visitor_token
|
|
54
|
-
return if current_author
|
|
55
|
-
|
|
56
|
-
cookies[:testimonials_vid].presence || begin
|
|
57
|
-
token = SecureRandom.base58(24)
|
|
58
|
-
cookies.permanent[:testimonials_vid] = { value: token, httponly: true, same_site: :lax }
|
|
59
|
-
token
|
|
60
|
-
end
|
|
61
|
-
end
|
|
15
|
+
protect_from_forgery with: :exception
|
|
62
16
|
end
|
|
63
17
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Testimonials
|
|
4
|
+
# Root of the STAFF surface: the testimonial queue and the NPS pages.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
|
+
# ActionController::Base, which is why `authorize_admin` exists. Point it at
|
|
8
|
+
# the controller your own admin already inherits from and the dashboard picks
|
|
9
|
+
# up that stack wholesale: your layout, your helpers, your authentication,
|
|
10
|
+
# and whatever request context your before_actions establish (a `Current`
|
|
11
|
+
# attribute the layout reads, say). `config.admin_layout` only ever solved
|
|
12
|
+
# the first of those.
|
|
13
|
+
#
|
|
14
|
+
# Only the dashboard hangs off it. The widget's endpoints stay on
|
|
15
|
+
# ApplicationController, so wiring an admin base controller here can never
|
|
16
|
+
# demand a staff session from a member leaving a review.
|
|
17
|
+
class DashboardController < Testimonials.base_controller
|
|
18
|
+
include RequestContext
|
|
19
|
+
|
|
20
|
+
# A host base controller brings its own layout, and declaring one here would
|
|
21
|
+
# override it — the dashboard would render in the gem's standalone shell
|
|
22
|
+
# inside an app that just told us where its admin lives. So the gem only
|
|
23
|
+
# claims the layout when it owns the decision: no host base controller, or a
|
|
24
|
+
# host that named an `admin_layout` explicitly.
|
|
25
|
+
layout :testimonials_admin_layout unless superclass != ActionController::Base &&
|
|
26
|
+
Testimonials.config.admin_layout ==
|
|
27
|
+
Configuration::DEFAULT_ADMIN_LAYOUT
|
|
28
|
+
|
|
29
|
+
before_action :require_admin
|
|
30
|
+
|
|
31
|
+
# `authorize_admin` is a gate of last resort, and it is the only one when
|
|
32
|
+
# the host supplied no base controller. When they did, its default is
|
|
33
|
+
# development-only, so a host whose base controller already authenticates
|
|
34
|
+
# staff should widen or replace it.
|
|
35
|
+
#
|
|
36
|
+
# CSRF likewise: a host base controller has configured it already, and
|
|
37
|
+
# declaring it twice would run the check twice.
|
|
38
|
+
protect_from_forgery with: :exception if superclass == ActionController::Base
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -10,6 +10,9 @@ module Testimonials
|
|
|
10
10
|
CLIENT_ACTIONS = %w[shown dismissed].freeze
|
|
11
11
|
|
|
12
12
|
before_action :require_enabled
|
|
13
|
+
# An install run with --skip-prompt-events has no ledger to write to, and
|
|
14
|
+
# the widget it serves knows not to post here in the first place.
|
|
15
|
+
before_action :require_prompt_events
|
|
13
16
|
|
|
14
17
|
def create
|
|
15
18
|
kind = params[:kind].to_s
|
|
@@ -21,5 +24,11 @@ module Testimonials
|
|
|
21
24
|
author_id: current_author_id, visitor_token: ensure_visitor_token)
|
|
22
25
|
head :no_content
|
|
23
26
|
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def require_prompt_events
|
|
31
|
+
head :forbidden unless Testimonials.config.prompt_events
|
|
32
|
+
end
|
|
24
33
|
end
|
|
25
34
|
end
|
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Testimonials
|
|
4
4
|
# The NPS side of the dashboard: overall score plus the response list.
|
|
5
|
-
class NpsResponsesController <
|
|
5
|
+
class NpsResponsesController < DashboardController
|
|
6
6
|
PER_PAGE = 50
|
|
7
7
|
|
|
8
|
-
layout :testimonials_admin_layout
|
|
9
|
-
|
|
10
|
-
before_action :require_admin
|
|
11
8
|
# An install run with --skip-nps has no table behind these pages, and the
|
|
12
9
|
# nav already hides the tab whenever config.nps is off.
|
|
13
10
|
before_action :require_nps
|