product_tours 0.1.1 → 0.2.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 +149 -0
- data/CHANGELOG.md +50 -0
- data/README.md +7 -1
- data/app/controllers/concerns/product_tours/request_context.rb +73 -0
- data/app/controllers/product_tours/application_controller.rb +9 -61
- data/app/controllers/product_tours/dashboard_controller.rb +32 -0
- data/app/controllers/product_tours/posts_controller.rb +1 -3
- data/app/views/layouts/product_tours/application.html.erb +10 -9
- data/app/views/product_tours/posts/edit.html.erb +5 -3
- data/app/views/product_tours/posts/index.html.erb +81 -79
- data/app/views/product_tours/posts/new.html.erb +5 -3
- data/app/views/product_tours/posts/show.html.erb +4 -2
- data/app/views/product_tours/shared/_dashboard.html.erb +16 -0
- data/config/routes.rb +5 -1
- data/lib/generators/product_tours/install/install_generator.rb +2 -6
- data/lib/generators/product_tours/install/templates/create_product_tours_posts.rb.tt +1 -2
- data/lib/generators/product_tours/install/templates/initializer.rb +8 -0
- data/lib/generators/product_tours/migration_helpers.rb +35 -0
- data/lib/product_tours/configuration.rb +21 -1
- data/lib/product_tours/dashboard.css +203 -177
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.rb +5 -1
- data/lib/product_tours.rb +8 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd91edac94c4a143630f7ba27c3892e750fbce4ea6f4c59490389ceae46a3e63
|
|
4
|
+
data.tar.gz: ab9eac5f0d14a4548bd49b89ccb7ad195031db2967123be5637953f318a439b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b92ad9c92502b29411417e0c98764068df9dc1b9f6cd73c6751887d263124beee0261f1deddb4ed75bac5ae0b09b137cf20ae6e00eb83cda1f0d790979b0987a
|
|
7
|
+
data.tar.gz: 835576cf29da8b9210d42565494a3595fa6d76c7816b7e0aaef05be5a2cb2574324a5cd1e3524879998896d56a4b6bc63e66e48de2458986c538eee3671b360e
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Instructions for coding agents. Two audiences:
|
|
4
|
+
|
|
5
|
+
- **[Installing product_tours into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add product tours, onboarding guides, or video tutorials.
|
|
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 uploaded videos, Action Text only for rich descriptions — both optional and both degrade rather than raise.
|
|
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 product_tours)/AGENTS.md"`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installing into a Rails app
|
|
15
|
+
|
|
16
|
+
### 1. Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle add product_tours
|
|
20
|
+
bin/rails generate product_tours:install
|
|
21
|
+
bin/rails db:migrate
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The generator writes `config/initializers/product_tours.rb`, one migration (`product_tours_posts`), and `mount_product_tours at: "/product_tours"` into `config/routes.rb`. **In development the migration also inserts working demo tutorials**, so the modal has something to open before anyone has written content. Read the initializer it wrote — it is the source of truth over any summary of it, including this file.
|
|
25
|
+
|
|
26
|
+
### 2. Wire the three things the generator cannot
|
|
27
|
+
|
|
28
|
+
**a. The widget tag**, once, in the layout:
|
|
29
|
+
|
|
30
|
+
```erb
|
|
31
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
32
|
+
<%= product_tours_tag %>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The helper is injected into ActionView by the engine — no include, no import, no asset pipeline entry.
|
|
36
|
+
|
|
37
|
+
**b. A trigger**, wherever the tutorial is useful. This is the part that makes it different from a SaaS tour builder: nothing auto-attaches to DOM nodes, you put the button where it belongs.
|
|
38
|
+
|
|
39
|
+
```erb
|
|
40
|
+
<button data-product-tour="billing_setup">Watch the billing guide</button>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The value is a tutorial **key**, and the key must exist and be **published** or the trigger does nothing.
|
|
44
|
+
|
|
45
|
+
**c. `authorize_admin` — do this before deploying.** The dashboard at `/product_tours` defaults to **development only**. It fails closed, so shipping without this is not an open dashboard — it is a 403 reading "Forbidden. Set ProductTours.config.authorize_admin to grant access."
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> **`enabled` and `authorize_admin` receive the raw `request`, not a controller.** Writing `->(request) { current_user }` is the 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 middleware already set it.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Rails 8 built-in auth
|
|
55
|
+
config.authorize_admin = lambda do |request|
|
|
56
|
+
token = request.cookies["session_token"]
|
|
57
|
+
Session.find_signed(token)&.user&.admin? || false
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. Verify
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bin/rails routes | grep product_tours # engine mounted
|
|
65
|
+
bin/rails product_tours:seed_demo # refresh demo tutorials in every locale
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then in the running app: open `/product_tours`, confirm the demo tutorials are listed, and click a `data-product-tour` button on one of your own pages.
|
|
69
|
+
|
|
70
|
+
### Tutorials are content, not code
|
|
71
|
+
|
|
72
|
+
A tutorial is a row in `product_tours_posts`, written and published in the mounted dashboard. **Do not create tutorials from host-app migrations, seeds, or fixtures** — that is not how this gem is meant to be used, and it puts editorial content in schema history. If the app needs sample content, `bin/rails product_tours:seed_demo` is the supported path.
|
|
73
|
+
|
|
74
|
+
What a tutorial carries: a `key`, a title, an optional rich description, an optional video, and at most one primary action (a URL, or the key of the next tutorial — that link is what makes a walkthrough). Keys must match `/\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/` — lowercase, digits, and `. _ -` as separators. `Billing Setup` and `billingSetup` are invalid; `billing_setup` and `billing.step-1` are fine. `status` is `draft` or `published`, and the key is unique **per locale**, which is how translations work: one row per language for the same key, with locale fallback at resolve time.
|
|
75
|
+
|
|
76
|
+
### Video providers
|
|
77
|
+
|
|
78
|
+
YouTube, Vimeo, Loom, Tella, Voomly, a direct MP4/WebM URL, or an uploaded file (needs Active Storage). Uploaded videos stream through the engine at `/product_tours/media/:id` — not a public blob URL. Do not build your own blob links.
|
|
79
|
+
|
|
80
|
+
**The engine edits the app's Content Security Policy.** It appends the providers' embed hosts to `frame-src` (or to `default-src` when `frame-src` is unset) in an initializer that runs after the host's own. That is deliberate — an embed silently blocked by CSP is a bad first five minutes — but know it happens, and do not hand-add those `frame-src` entries yourself. If the app sets its CSP somewhere unusual (a middleware, a per-controller override), that is where a blocked embed will come from.
|
|
81
|
+
|
|
82
|
+
### Lifecycle events
|
|
83
|
+
|
|
84
|
+
Subscribe in an initializer; there is no callback config to set.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
ActiveSupport::Notifications.subscribe("product_tours.completed") do |*, payload|
|
|
88
|
+
# payload has the tutorial key and request context
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Names: `product_tours.viewed`, `product_tours.dismissed`, `product_tours.completed`, and `product_tours.unresolved_trigger` — the last one fires when a `data-product-tour` button names a key that does not resolve. Subscribe to it in development; it turns "my button does nothing" into a log line naming the key.
|
|
93
|
+
|
|
94
|
+
### Do not
|
|
95
|
+
|
|
96
|
+
- **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `product_tours_tag` renders what is needed and the engine serves the code. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
97
|
+
- **Do not create or edit tutorials in code** (see above).
|
|
98
|
+
- **Do not add provider hosts to `frame-src` by hand** — the engine already does it.
|
|
99
|
+
- **Do not serve uploaded videos by blob URL** — the gated media route exists so a leaked signed URL cannot hand over your content.
|
|
100
|
+
- **Do not install Active Storage or Action Text "to make it work"** unless the app actually wants uploads or rich text. Both are optional; the gem checks for them (`Post.video_upload_supported?`, `Post.description_supported?`) and simply offers less.
|
|
101
|
+
|
|
102
|
+
### Configuration
|
|
103
|
+
|
|
104
|
+
There are five options. That is the whole surface.
|
|
105
|
+
|
|
106
|
+
| Option | Default | What it does |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `authorize_admin` | development only | **Who can read and edit tutorials. Set before deploying.** |
|
|
109
|
+
| `enabled` | everyone | Per-request gate for the widget and its endpoints |
|
|
110
|
+
| `admin_layout` | `product_tours/application` | Render the dashboard inside your admin shell |
|
|
111
|
+
| `mount_path` | `"/product_tours"` | Keep in sync with `mount_product_tours at:` |
|
|
112
|
+
| `storage_service` | app default | Active Storage service for uploaded video (a `storage.yml` key) |
|
|
113
|
+
|
|
114
|
+
26 locales ship with the gem, RTL included.
|
|
115
|
+
|
|
116
|
+
### Common failure modes
|
|
117
|
+
|
|
118
|
+
| Symptom | Cause |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| The trigger button does nothing | No tutorial with that key, or it is still a draft, or `product_tours_tag` is missing from the layout. Subscribe to `product_tours.unresolved_trigger` to see which |
|
|
121
|
+
| `/product_tours` returns 403 "Set ProductTours.config.authorize_admin to grant access" | Exactly what it says: still at the development-only default |
|
|
122
|
+
| Key rejected on save | It must match `/\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/` — no capitals, no spaces |
|
|
123
|
+
| Video area blank for an embed | CSP. The engine appends provider hosts to `frame-src`; a policy set outside `config.content_security_policy` will not have them |
|
|
124
|
+
| No upload option on the form | Active Storage not installed |
|
|
125
|
+
| No rich-text editor for the description | Action Text not installed |
|
|
126
|
+
| Duplicate-key error when adding a translation | The key is unique per locale — add the translation from the tutorial page rather than creating a second record by hand |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Working on the gem itself
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
bundle exec rake test # minitest, dummy app under test/dummy
|
|
134
|
+
bundle exec rubocop # must be clean
|
|
135
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Layout: `app/` controllers, `Post`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours/install/` the one generator · `config/locales/` 26 locales · `test/` minitest with `test/dummy` as the host app.
|
|
139
|
+
|
|
140
|
+
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
141
|
+
|
|
142
|
+
- **Optional dependencies are checked, never assumed.** `has_rich_text` is declared only `if respond_to?`, `has_one_attached` only `if defined?(::ActiveStorage)`, and the model exposes `description_supported?` / `video_upload_supported?` so views can offer less instead of raising. An app with neither gem must boot and work.
|
|
143
|
+
- **Triggers are explicit.** The gem never guesses at DOM nodes or auto-starts a tour; a host puts `data-product-tour="key"` where it wants it. A trigger naming a key that does not resolve is instrumented, not silently swallowed.
|
|
144
|
+
- **The widget is plain JS served by the engine** — no build step, no framework, no CDN.
|
|
145
|
+
- **Uploaded media streams through the engine's gate**, never a public blob URL.
|
|
146
|
+
- **The CSP patch is additive.** It appends to existing sources and drops `'none'` rather than replacing a host's policy — do not let it start overwriting directives.
|
|
147
|
+
- **The dummy app pins `config.active_job.queue_adapter = :test`.** Do not remove it or let it drift back to the `:async` default. Attaching a video enqueues Active Storage's analysis job, and `:async` runs it on a background thread that checks out its own connection — writes no test transaction covers, landing in the middle of whatever runs next. That is a suite that fails order-dependently in a test which never created a row, and it is miserable to trace back.
|
|
148
|
+
- Every user-facing change bumps `lib/product_tours/version.rb` and adds a `CHANGELOG.md` entry (Keep a Changelog format) that says what it costs, not only what it adds.
|
|
149
|
+
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.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 and the dashboard rendered unstyled with its client-side behaviour dead.
|
|
8
|
+
They move into the views, so every layout gets them with nothing asked of the
|
|
9
|
+
host.
|
|
10
|
+
- **The dashboard stylesheet no longer claims selectors it does not own.** It
|
|
11
|
+
styled bare `*`, `html`, `body` and `a`, and its `.container`, `.card` and
|
|
12
|
+
`.tabs` are names other frameworks use too, so a host that did load it had its
|
|
13
|
+
own chrome restyled. Component rules now nest inside a `.pt-dashboard` wrapper
|
|
14
|
+
the views render, and every custom property is `--pt-` prefixed so it can
|
|
15
|
+
neither overwrite a host's nor be overwritten. The page-frame rules stay keyed
|
|
16
|
+
to the body classes only the gem's own layout sets.
|
|
17
|
+
- **Added `config.base_controller_class`.** Name the controller your own admin
|
|
18
|
+
inherits from and the dashboard adopts its layout, helpers, authentication and
|
|
19
|
+
request context — the things `admin_layout` cannot give you. It reparents the
|
|
20
|
+
dashboard only; the widget, tour-resolution and media endpoints stay on the
|
|
21
|
+
engine's public controller, so it can never demand a staff session from a
|
|
22
|
+
visitor. Default is unchanged.
|
|
23
|
+
- **Migrations follow the host's `primary_key_type`,** the same
|
|
24
|
+
`Rails.configuration.generators` lookup Rails' own Active Storage, Action Text
|
|
25
|
+
and Action Mailbox migrations do. A uuid-keyed app has a uuid
|
|
26
|
+
`active_storage_attachments.record_id`, so a bigint table here could never
|
|
27
|
+
hold a video: `attach` raised `NotNullViolation`. A host that set nothing gets
|
|
28
|
+
an identical migration to before.
|
|
29
|
+
- **Dropped the `id: /\d+/` constraint on the media route,** which was what
|
|
30
|
+
forced the table to be bigint. It was never load-bearing: every fixed-name
|
|
31
|
+
route is declared first.
|
|
32
|
+
- **Dropped the redundant `(locale)` index.** A B-tree serves any leftmost
|
|
33
|
+
prefix, so `(locale, key)` already covered it; it only cost write time and
|
|
34
|
+
disk. Existing installs keep theirs until they drop it:
|
|
35
|
+
`remove_index :product_tours_posts, :locale`.
|
|
36
|
+
- A `BackboneTest` now fails the build on any of the above regressing.
|
|
37
|
+
|
|
3
38
|
## [Unreleased]
|
|
4
39
|
|
|
40
|
+
## [0.1.2] - 2026-08-04
|
|
41
|
+
|
|
42
|
+
- Added `AGENTS.md`: install and integration instructions written for coding
|
|
43
|
+
agents — that tutorials are content managed in the dashboard rather than
|
|
44
|
+
created from migrations, the key format, the request-shaped config lambdas,
|
|
45
|
+
that the engine appends provider hosts to the app's `frame-src`, and the
|
|
46
|
+
`product_tours.unresolved_trigger` notification that turns "my button does
|
|
47
|
+
nothing" into a log line. It ships inside the gem, so
|
|
48
|
+
`cat "$(bundle show product_tours)/AGENTS.md"` works from a host app.
|
|
49
|
+
- The dummy app pins `queue_adapter = :test` for the test suite. Attaching a
|
|
50
|
+
video enqueues Active Storage's analysis job, and the default `:async` adapter
|
|
51
|
+
runs it on a background thread with its own database connection — writes no
|
|
52
|
+
test transaction covers, which is how a suite starts failing order-dependently
|
|
53
|
+
in a test that never created a row. No effect on the gem itself.
|
|
54
|
+
|
|
5
55
|
## [0.1.1] - 2026-08-04
|
|
6
56
|
|
|
7
57
|
- Reworked the README into an installation-first, skimmable product guide that
|
data/README.md
CHANGED
|
@@ -54,6 +54,11 @@ small set of working tutorials so you can try the modal immediately.
|
|
|
54
54
|
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only for uploaded videos · Action
|
|
55
55
|
Text only for rich descriptions.
|
|
56
56
|
|
|
57
|
+
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
58
|
+
steps in the order an agent needs them, plus the gates it tends to get wrong and
|
|
59
|
+
the things it should not do. It ships inside the gem, so
|
|
60
|
+
`cat "$(bundle show product_tours)/AGENTS.md"` works from any app that bundles it.
|
|
61
|
+
|
|
57
62
|
## What you get
|
|
58
63
|
|
|
59
64
|
| | |
|
|
@@ -221,7 +226,8 @@ Everything is optional — a development install works with zero config. In
|
|
|
221
226
|
| --- | --- | --- |
|
|
222
227
|
| `enabled` | everyone | Who can resolve and open published tutorials |
|
|
223
228
|
| `authorize_admin` | development only | **Who can manage content at the mount path** |
|
|
224
|
-
| `
|
|
229
|
+
| `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
230
|
+
| `admin_layout` | gem layout | Just the shell, if you don't want the whole controller |
|
|
225
231
|
| `storage_service` | app default | Named Active Storage service for uploaded videos |
|
|
226
232
|
| `mount_path` | `/product_tours` | Keep in sync only when mounting the engine manually |
|
|
227
233
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module ProductTours
|
|
6
|
+
# Who is asking, which locale they are in, and the gates that answer both.
|
|
7
|
+
#
|
|
8
|
+
# A concern rather than inherited behaviour because the engine has two
|
|
9
|
+
# controller roots: the public endpoints hang off ActionController::Base, and
|
|
10
|
+
# the dashboard hangs off whatever the host set as `base_controller_class`.
|
|
11
|
+
module RequestContext
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def product_tours_admin_layout
|
|
17
|
+
ProductTours.config.admin_layout
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def require_admin
|
|
21
|
+
return if ProductTours.admin?(request)
|
|
22
|
+
|
|
23
|
+
render plain: 'Forbidden. Set ProductTours.config.authorize_admin to grant access.', status: :forbidden
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def current_product_tours_locale
|
|
27
|
+
requested_locale = params[:locale].to_s
|
|
28
|
+
return requested_locale if I18n.available_locales.map(&:to_s).include?(requested_locale)
|
|
29
|
+
|
|
30
|
+
ProductTours.locale(request)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def find_post_by_locale(scope, key)
|
|
34
|
+
locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
|
|
35
|
+
posts = scope.where(locale: locales, key: key).index_by(&:locale)
|
|
36
|
+
locales.filter_map { |locale| posts[locale] }.first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clean_page_url(value)
|
|
40
|
+
uri = URI.parse(value.to_s)
|
|
41
|
+
return unless uri.is_a?(URI::HTTP) && uri.host.present?
|
|
42
|
+
|
|
43
|
+
uri.query = nil
|
|
44
|
+
uri.fragment = nil
|
|
45
|
+
uri.to_s
|
|
46
|
+
rescue URI::InvalidURIError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unresolved_payload(key, reason)
|
|
51
|
+
{
|
|
52
|
+
key: key.to_s,
|
|
53
|
+
locale: current_product_tours_locale,
|
|
54
|
+
reason: reason.to_s,
|
|
55
|
+
page_url: clean_page_url(params[:page_url])
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_unresolved_trigger(key, reason)
|
|
60
|
+
payload = unresolved_payload(key, reason)
|
|
61
|
+
error = ProductTours::UnresolvedTriggerError.new(payload)
|
|
62
|
+
raise error if Rails.env.development? || Rails.env.test?
|
|
63
|
+
|
|
64
|
+
if defined?(Rails.error) && Rails.error.respond_to?(:report)
|
|
65
|
+
Rails.error.report(error, handled: true, context: payload)
|
|
66
|
+
else
|
|
67
|
+
Rails.logger.error("product_tours: #{error.message} #{payload.inspect}")
|
|
68
|
+
end
|
|
69
|
+
ActiveSupport::Notifications.instrument('product_tours.unresolved_trigger', payload)
|
|
70
|
+
render json: { error: 'unresolved_trigger', reason: reason.to_s }, status: :not_found
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -1,68 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'uri'
|
|
4
|
-
|
|
5
3
|
module ProductTours
|
|
4
|
+
# Root of the engine's PUBLIC surface: widget.js, tour resolution, signals and
|
|
5
|
+
# media. These stay on a plain ActionController::Base deliberately — a visitor
|
|
6
|
+
# loading a tour must not be routed through a host's admin controller, which
|
|
7
|
+
# would demand a staff session for the widget.
|
|
8
|
+
#
|
|
9
|
+
# The dashboard's root is DashboardController, and that is where
|
|
10
|
+
# `config.base_controller_class` applies.
|
|
6
11
|
class ApplicationController < ActionController::Base
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
def product_tours_admin_layout
|
|
12
|
-
ProductTours.config.admin_layout
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def require_admin
|
|
16
|
-
return if ProductTours.admin?(request)
|
|
17
|
-
|
|
18
|
-
render plain: 'Forbidden. Set ProductTours.config.authorize_admin to grant access.', status: :forbidden
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def current_product_tours_locale
|
|
22
|
-
requested_locale = params[:locale].to_s
|
|
23
|
-
return requested_locale if I18n.available_locales.map(&:to_s).include?(requested_locale)
|
|
12
|
+
include RequestContext
|
|
24
13
|
|
|
25
|
-
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def find_post_by_locale(scope, key)
|
|
29
|
-
locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
|
|
30
|
-
posts = scope.where(locale: locales, key: key).index_by(&:locale)
|
|
31
|
-
locales.filter_map { |locale| posts[locale] }.first
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def clean_page_url(value)
|
|
35
|
-
uri = URI.parse(value.to_s)
|
|
36
|
-
return unless uri.is_a?(URI::HTTP) && uri.host.present?
|
|
37
|
-
|
|
38
|
-
uri.query = nil
|
|
39
|
-
uri.fragment = nil
|
|
40
|
-
uri.to_s
|
|
41
|
-
rescue URI::InvalidURIError
|
|
42
|
-
nil
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def unresolved_payload(key, reason)
|
|
46
|
-
{
|
|
47
|
-
key: key.to_s,
|
|
48
|
-
locale: current_product_tours_locale,
|
|
49
|
-
reason: reason.to_s,
|
|
50
|
-
page_url: clean_page_url(params[:page_url])
|
|
51
|
-
}
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def handle_unresolved_trigger(key, reason)
|
|
55
|
-
payload = unresolved_payload(key, reason)
|
|
56
|
-
error = ProductTours::UnresolvedTriggerError.new(payload)
|
|
57
|
-
raise error if Rails.env.development? || Rails.env.test?
|
|
58
|
-
|
|
59
|
-
if defined?(Rails.error) && Rails.error.respond_to?(:report)
|
|
60
|
-
Rails.error.report(error, handled: true, context: payload)
|
|
61
|
-
else
|
|
62
|
-
Rails.logger.error("product_tours: #{error.message} #{payload.inspect}")
|
|
63
|
-
end
|
|
64
|
-
ActiveSupport::Notifications.instrument('product_tours.unresolved_trigger', payload)
|
|
65
|
-
render json: { error: 'unresolved_trigger', reason: reason.to_s }, status: :not_found
|
|
66
|
-
end
|
|
14
|
+
protect_from_forgery with: :exception
|
|
67
15
|
end
|
|
68
16
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
# Root of the STAFF surface: the post editor and its list.
|
|
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, and
|
|
10
|
+
# whatever request context your before_actions establish. `config.admin_layout`
|
|
11
|
+
# only ever solved the first of those.
|
|
12
|
+
#
|
|
13
|
+
# Only the dashboard hangs off it. The widget's endpoints stay on
|
|
14
|
+
# ApplicationController, so wiring an admin base controller here can never
|
|
15
|
+
# demand a staff session from a visitor being shown a tour.
|
|
16
|
+
class DashboardController < ProductTours.base_controller
|
|
17
|
+
include RequestContext
|
|
18
|
+
|
|
19
|
+
# A host base controller brings its own layout, and declaring one here would
|
|
20
|
+
# override it. So the gem only claims the layout when it owns the decision:
|
|
21
|
+
# no host base controller, or a host that named an `admin_layout` explicitly.
|
|
22
|
+
layout :product_tours_admin_layout unless superclass != ActionController::Base &&
|
|
23
|
+
ProductTours.config.admin_layout ==
|
|
24
|
+
Configuration::DEFAULT_ADMIN_LAYOUT
|
|
25
|
+
|
|
26
|
+
before_action :require_admin
|
|
27
|
+
|
|
28
|
+
# A host base controller has configured CSRF already; declaring it twice
|
|
29
|
+
# would run the check twice.
|
|
30
|
+
protect_from_forgery with: :exception if superclass == ActionController::Base
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
|
-
class PostsController <
|
|
4
|
+
class PostsController < DashboardController
|
|
5
5
|
PER_PAGE = 50
|
|
6
6
|
|
|
7
|
-
layout :product_tours_admin_layout
|
|
8
|
-
before_action :require_admin
|
|
9
7
|
before_action :set_post,
|
|
10
8
|
only: %i[show edit update destroy refresh_video_metadata add_translation publish unpublish]
|
|
11
9
|
before_action :load_linkable_posts, only: %i[new create edit update]
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<%= csp_meta_tag %>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"data-turbo-track": "reload", defer: true, nonce: true %>
|
|
8
|
+
<%# The dashboard's stylesheet and script are declared by the views
|
|
9
|
+
(product_tours/shared/_dashboard), not here, so they survive a host
|
|
10
|
+
replacing this layout via config.admin_layout. %>
|
|
12
11
|
</head>
|
|
13
|
-
<body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
14
|
-
<div class="
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
<body class="pt-page <%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
13
|
+
<div class="pt-dashboard">
|
|
14
|
+
<div class="container">
|
|
15
|
+
<% if notice.present? %><div class="flash notice"><%= notice %></div><% end %>
|
|
16
|
+
<% if alert.present? %><div class="flash alert"><%= alert %></div><% end %>
|
|
17
|
+
<%= yield %>
|
|
18
|
+
</div>
|
|
18
19
|
</div>
|
|
19
20
|
</body>
|
|
20
21
|
</html>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<% content_for :body_class, "pt-form-page" %>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
3
|
+
<%= render layout: 'product_tours/shared/dashboard' do %>
|
|
4
|
+
<div class="breadcrumb"><%= link_to t("product_tours.dashboard.title", default: "Product tours"), posts_path %> / <%= link_to @post.title, post_path(@post) %> / <%= t("product_tours.dashboard.edit", default: "Edit") %></div>
|
|
5
|
+
<h1><%= t("product_tours.dashboard.edit_post", default: "Edit tutorial") %></h1>
|
|
6
|
+
<%= render "form", post: @post %>
|
|
7
|
+
<% end %>
|