ideasbugs 0.6.1 → 0.7.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 +14 -0
- data/README.md +215 -278
- data/lib/ideasbugs/version.rb +1 -1
- data/lib/ideasbugs/widget.js +53 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b3ee598ec8e5e5e5e67dddab875252edaea6f4a49101a311504d5d2d2b5f87c
|
|
4
|
+
data.tar.gz: d7d9e57238759a532fb25a5d0cc0a6bde9b9385e0d301febe01fc092d3bfe418
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c51ddb0f3cf65ff0ee80370e8290d979380826d1d52b9a0b38f741e94535dcce2c9d0799c981dbd932aa57d24b3cd5a64385e0d3ebdf1402e8016cc5ea9f6764
|
|
7
|
+
data.tar.gz: fecbfe047b325ee7d1e0f04336b23351e7191b06269ad8b6602557ec13151084a40cf16daa2d7e3a6491f2ad5904ccc73224e50628cf880f2ba090cc98a50212
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-07-27)
|
|
4
|
+
|
|
5
|
+
- **Mobile keyboard-safe dialog.** On phones the full-screen dialog
|
|
6
|
+
(`height:100dvh`) no longer hides its actions row behind the on-screen
|
|
7
|
+
keyboard. iOS Safari raises the keyboard without resizing fixed `100dvh`
|
|
8
|
+
elements; the widget now pins `#idb-dialog` to the visible viewport via the
|
|
9
|
+
`visualViewport` API while the dialog is open and the `(max-width:480px)`
|
|
10
|
+
media query matches. Desktop and browsers without `visualViewport` are
|
|
11
|
+
unchanged (both are guarded no-ops). Shared UX fix across the gem family.
|
|
12
|
+
- The type "Type" select is no longer auto-focused when the form opens.
|
|
13
|
+
Focusing a `<select>` on iOS immediately pops the native picker, which looked
|
|
14
|
+
broken on mount; the message textarea is focused instead. Tab still reaches
|
|
15
|
+
the select and the focus trap is unchanged, so keyboard access is preserved.
|
|
16
|
+
|
|
3
17
|
## 0.6.1 (2026-07-26)
|
|
4
18
|
|
|
5
19
|
- The widget's injected stylesheet now refreshes when its content changes
|
data/README.md
CHANGED
|
@@ -5,76 +5,28 @@
|
|
|
5
5
|
[](https://github.com/yshmarov/ideasbugs/actions/workflows/ci.yml)
|
|
6
6
|
[](MIT-LICENSE)
|
|
7
7
|
|
|
8
|
-
In-app product feedback
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`ideasbugs` adds a **"Send feedback"** widget to your app — bug reports,
|
|
12
|
-
feature requests, general comments, with optional screenshots — and stores
|
|
13
|
-
every submission in your own database. A minimal built-in dashboard lets you
|
|
14
|
-
browse and triage what users send. No third-party service, no data leaves your
|
|
15
|
-
app.
|
|
8
|
+
**In-app product feedback for Rails.** A "Send feedback" widget plus a triage
|
|
9
|
+
dashboard — bug reports, feature requests, screenshots — all in your own
|
|
10
|
+
database. Self-hosted alternative to Canny.
|
|
16
11
|
|
|
12
|
+
<table>
|
|
13
|
+
<tr>
|
|
14
|
+
<td width="36%" valign="top">
|
|
17
15
|
<picture>
|
|
18
|
-
<source media="(prefers-color-scheme: dark)" srcset="
|
|
19
|
-
<img alt="The feedback widget: type, section, message,
|
|
16
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/widget-dark.png">
|
|
17
|
+
<img alt="The feedback widget: type, section, message, screenshots" src="docs/screenshots/widget-light.png">
|
|
20
18
|
</picture>
|
|
21
|
-
|
|
19
|
+
</td>
|
|
20
|
+
<td width="64%" valign="top">
|
|
22
21
|
<picture>
|
|
23
|
-
<source media="(prefers-color-scheme: dark)" srcset="
|
|
24
|
-
<img alt="The
|
|
22
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/dashboard-dark.png">
|
|
23
|
+
<img alt="The triage dashboard: status tabs, filters, search" src="docs/screenshots/dashboard-light.png">
|
|
25
24
|
</picture>
|
|
25
|
+
</td>
|
|
26
|
+
</tr>
|
|
27
|
+
</table>
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
these screenshots are the same pages in light and dark mode.
|
|
29
|
-
|
|
30
|
-
## Why not Canny (or another feedback SaaS)?
|
|
31
|
-
|
|
32
|
-
Hosted feedback tools are great until you notice the trade: your users'
|
|
33
|
-
feedback lives in someone else's database, behind someone else's login, at a
|
|
34
|
-
per-seat price. `ideasbugs` keeps the core loop — collect, triage,
|
|
35
|
-
resolve — inside your app: **your database, your users, your auth, one gem.**
|
|
36
|
-
Attribution plugs into whatever authentication you already have, gating is a
|
|
37
|
-
lambda, and submissions are ordinary ActiveRecord rows you can query, export,
|
|
38
|
-
or wire into Slack. (A public voting board à la Canny is on the
|
|
39
|
-
[roadmap](https://github.com/yshmarov/ideasbugs/issues/1).)
|
|
40
|
-
|
|
41
|
-
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself.
|
|
42
|
-
No Tailwind, no Stimulus, no importmap, no build step. The dashboard renders
|
|
43
|
-
its own styles too.
|
|
44
|
-
- **One line in your layout.** `<%= ideasbugs_tag %>` and you're
|
|
45
|
-
collecting feedback.
|
|
46
|
-
- **Trigger it your way.** Use the built-in floating button, or hide it and
|
|
47
|
-
open the form from any element with a `data-ideasbugs-open` attribute.
|
|
48
|
-
- **Screenshots included.** Users can attach up to 3 images (via Active
|
|
49
|
-
Storage) — limits are configurable and enforced server-side.
|
|
50
|
-
- **Pluggable gating and attribution.** You decide who can send feedback, who
|
|
51
|
-
can read it, and how a submission is attributed to a user.
|
|
52
|
-
- **Localized.** The widget follows your app's `I18n.locale`; translations ship
|
|
53
|
-
for English plus 25 more languages — Arabic, Bengali, Bulgarian, Chinese
|
|
54
|
-
(Simplified), Croatian, Dutch, French, German, Greek, Hindi, Indonesian,
|
|
55
|
-
Italian, Japanese, Korean, Luxembourgish, Polish, Portuguese, Romanian,
|
|
56
|
-
Russian, Spanish, Thai, Turkish, Ukrainian, Urdu and Vietnamese — with
|
|
57
|
-
English fallbacks for everything else. RTL locales render mirrored.
|
|
58
|
-
|
|
59
|
-
## How it works
|
|
60
|
-
|
|
61
|
-
1. `ideasbugs_tag` renders a floating **Feedback** button (bottom-right).
|
|
62
|
-
2. Clicking it opens a small self-styled modal: type (bug / feature / other),
|
|
63
|
-
an optional section select, a message, and optional screenshots.
|
|
64
|
-
3. The form `POST`s to the mounted engine with the page URL, the user agent,
|
|
65
|
-
and (if you configure it) the current user — stored in the
|
|
66
|
-
`ideasbugs_feedbacks` table.
|
|
67
|
-
4. You browse and triage submissions at the mount path (`/feedback`): status
|
|
68
|
-
tabs (open → in review → resolved), type filter, search, screenshots
|
|
69
|
-
inline.
|
|
70
|
-
|
|
71
|
-
## Requirements
|
|
72
|
-
|
|
73
|
-
- Ruby >= 3.2
|
|
74
|
-
- Rails >= 7.1
|
|
75
|
-
- Active Storage (only if you want screenshot uploads)
|
|
76
|
-
|
|
77
|
-
## Installation
|
|
29
|
+
## Install
|
|
78
30
|
|
|
79
31
|
```ruby
|
|
80
32
|
# Gemfile
|
|
@@ -87,110 +39,120 @@ bin/rails generate ideasbugs:install
|
|
|
87
39
|
bin/rails db:migrate
|
|
88
40
|
```
|
|
89
41
|
|
|
90
|
-
The generator:
|
|
91
|
-
|
|
92
|
-
- writes `config/initializers/ideasbugs.rb`,
|
|
93
|
-
- creates the `ideasbugs_feedbacks` migration,
|
|
94
|
-
- mounts the engine in `config/routes.rb`:
|
|
95
|
-
|
|
96
|
-
```ruby
|
|
97
|
-
mount Ideasbugs::Engine => "/feedback"
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
Then add the widget to your layout, right before `</body>`:
|
|
101
|
-
|
|
102
42
|
```erb
|
|
43
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
103
44
|
<%= ideasbugs_tag %>
|
|
104
45
|
```
|
|
105
46
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
47
|
+
That's it. A floating **Feedback** button appears bottom-right; submissions
|
|
48
|
+
land at `/feedback`.
|
|
49
|
+
|
|
50
|
+
> [!IMPORTANT]
|
|
51
|
+
> The dashboard defaults to **development only**. Set `authorize_admin` before
|
|
52
|
+
> you deploy — see [Configure](#configure).
|
|
53
|
+
|
|
54
|
+
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want screenshots ·
|
|
55
|
+
CSRF token comes from `csrf_meta_tags`, already in a standard Rails layout.
|
|
56
|
+
|
|
57
|
+
## What you get
|
|
58
|
+
|
|
59
|
+
| | |
|
|
60
|
+
| ------------ | ------------------------------------------------------------------------ |
|
|
61
|
+
| **Widget** | Type (bug / feature / other), optional section, message, up to 3 screenshots |
|
|
62
|
+
| **Dashboard**| Status tabs (open → in review → resolved), type filter, search, inline screenshots |
|
|
63
|
+
| **Storage** | `ideasbugs_feedbacks` — ordinary Active Record rows you can query and export |
|
|
64
|
+
| **Deps** | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
|
|
65
|
+
| **Auth** | Lambdas over the raw request — Devise, Rails 8 auth, Basic auth, anything |
|
|
66
|
+
| **i18n** | 26 languages, RTL mirrored, English fallback |
|
|
67
|
+
| **Theme** | Follows system light/dark. No config |
|
|
68
|
+
| **Turbo** | Works with Turbo Drive out of the box |
|
|
69
|
+
| **CSP** | Carries the request nonce. Works under strict `script-src` |
|
|
70
|
+
|
|
71
|
+
## Why not Canny
|
|
72
|
+
|
|
73
|
+
| | `ideasbugs` | Hosted feedback SaaS |
|
|
74
|
+
| ------------------------ | ------------------------- | ---------------------- |
|
|
75
|
+
| Cost | Free, MIT | Monthly, often per-seat |
|
|
76
|
+
| Where feedback lives | Your database | The vendor's |
|
|
77
|
+
| Who users log in as | Already signed in to your app | A second account |
|
|
78
|
+
| Attribution | Your `current_user` | Email they type |
|
|
79
|
+
| Querying / exporting | Plain Active Record | Their API |
|
|
80
|
+
| Public voting board | [On the roadmap](https://github.com/yshmarov/ideasbugs/issues/1) | ✅ |
|
|
81
|
+
|
|
82
|
+
## Configure
|
|
83
|
+
|
|
84
|
+
Everything is optional — a fresh install works with zero config. In
|
|
85
|
+
`config/initializers/ideasbugs.rb`:
|
|
86
|
+
|
|
87
|
+
| Option | Default | What it does |
|
|
88
|
+
| --------------------- | --------------------------- | --------------------------------------------------- |
|
|
89
|
+
| `authorize_admin` | development only | **Who can read the dashboard.** Override before deploying |
|
|
90
|
+
| `enabled` | everyone | Who can send feedback. `false` hides the widget and rejects posts |
|
|
91
|
+
| `current_user` | `nil` | Attribute a submission to a user. Receives the request |
|
|
92
|
+
| `author_label` | the user's `email` | Short label stored and shown in the dashboard |
|
|
93
|
+
| `tenant` | `nil` | One board per tenant — see [Multi-tenancy](#multi-tenancy) |
|
|
94
|
+
| `kinds` | `%w[bug feature other]` | Feedback types. Labels via `ideasbugs.kinds.<kind>` |
|
|
95
|
+
| `sections` | `[]` | App areas shown as a select. Empty hides it |
|
|
96
|
+
| `screenshots` | `true` | Allow uploads (needs Active Storage) |
|
|
97
|
+
| `max_screenshots` | `3` | Enforced server-side |
|
|
98
|
+
| `max_screenshot_size` | `5.megabytes` | Enforced server-side |
|
|
99
|
+
| `rate_limit` | `{ to: 10, within: 1.minute }` | Per-IP throttle (Rails 7.2+). `nil` disables |
|
|
100
|
+
| `show_button` | `true` | The floating button. `false` to use your own trigger |
|
|
101
|
+
| `button_label` | `nil` | Fixed button text. `nil` uses the localized default |
|
|
102
|
+
| `mount_path` | `"/feedback"` | Keep in sync with `mount` in `routes.rb` |
|
|
103
|
+
| `on_submit` | no-op | Runs inline after each save — Slack, email, tickets |
|
|
104
|
+
|
|
105
|
+
Gates receive the **raw request**, so they work with any auth:
|
|
116
106
|
|
|
117
107
|
```ruby
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
# submissions for this request. Defaults to everyone.
|
|
122
|
-
config.enabled = ->(request) { true }
|
|
123
|
-
|
|
124
|
-
# Who can browse and triage feedback at the mount path.
|
|
125
|
-
# DEFAULTS TO DEVELOPMENT ONLY — override before deploying.
|
|
126
|
-
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
127
|
-
|
|
128
|
-
# Attribute feedback to a user (optional). Return an object responding to
|
|
129
|
-
# #id, or nil. Receives the request.
|
|
130
|
-
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
131
|
-
|
|
132
|
-
# Label stored for the author and shown in the dashboard.
|
|
133
|
-
config.author_label = ->(user) { user.try(:email) }
|
|
134
|
-
|
|
135
|
-
# Feedback types users can pick from. Labels come from I18n
|
|
136
|
-
# (ideasbugs.kinds.<kind>).
|
|
137
|
-
config.kinds = %w[bug feature other]
|
|
138
|
-
|
|
139
|
-
# App areas shown as a select in the widget. Empty list hides the select.
|
|
140
|
-
config.sections = ["Dashboard", "Billing", "Settings"]
|
|
141
|
-
|
|
142
|
-
# Screenshot uploads (requires Active Storage).
|
|
143
|
-
config.screenshots = true
|
|
144
|
-
config.max_screenshots = 3
|
|
145
|
-
config.max_screenshot_size = 5.megabytes
|
|
108
|
+
# Devise / Warden
|
|
109
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
110
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
146
111
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
112
|
+
# Rails 8 built-in auth (bin/rails generate authentication)
|
|
113
|
+
config.current_user = lambda do |request|
|
|
114
|
+
token = request.cookies["session_token"]
|
|
115
|
+
Session.find_signed(token)&.user if token
|
|
116
|
+
end
|
|
150
117
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
118
|
+
# Anything else based on the request
|
|
119
|
+
config.authorize_admin = ->(request) { Flipper.enabled?(:feedback_admin) }
|
|
120
|
+
```
|
|
154
121
|
|
|
155
|
-
|
|
156
|
-
|
|
122
|
+
<details>
|
|
123
|
+
<summary><b>Other ways to protect the dashboard</b></summary>
|
|
157
124
|
|
|
158
|
-
|
|
159
|
-
config.mount_path = "/feedback"
|
|
125
|
+
Wrap the mount in a routing constraint — the lambda still applies on top:
|
|
160
126
|
|
|
161
|
-
|
|
162
|
-
|
|
127
|
+
```ruby
|
|
128
|
+
authenticate :user, ->(user) { user.admin? } do
|
|
129
|
+
mount Ideasbugs::Engine => "/feedback"
|
|
163
130
|
end
|
|
164
131
|
```
|
|
165
132
|
|
|
166
|
-
|
|
167
|
-
with whatever auth you have:
|
|
133
|
+
</details>
|
|
168
134
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
135
|
+
<details>
|
|
136
|
+
<summary><b>Notifications on new feedback</b></summary>
|
|
172
137
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
138
|
+
`on_submit` runs inline after each save — keep it fast or hand off to a job:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
config.on_submit = ->(feedback) do
|
|
142
|
+
SlackNotifier.post("New #{feedback.kind}: #{feedback.message.truncate(100)}")
|
|
177
143
|
end
|
|
178
144
|
```
|
|
179
145
|
|
|
180
|
-
|
|
146
|
+
</details>
|
|
181
147
|
|
|
182
|
-
|
|
183
|
-
any element and (optionally) hide the button:
|
|
148
|
+
## Trigger it from your own UI
|
|
184
149
|
|
|
185
|
-
|
|
186
|
-
config.show_button = false
|
|
187
|
-
```
|
|
150
|
+
Prefer a nav item over the floating button? Add `data-ideasbugs-open` to any
|
|
151
|
+
element, and optionally set `config.show_button = false`.
|
|
188
152
|
|
|
189
|
-
Gate the trigger with the same check the widget uses — `ideasbugs_tag`
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
`ideasbugs.title` label ("Send bug/feature request") in every bundled language, so
|
|
193
|
-
a localized trigger needs no keys of your own:
|
|
153
|
+
Gate the trigger with the same check the widget uses — `ideasbugs_tag` renders
|
|
154
|
+
nothing when `enabled` rejects the request, so an unguarded trigger is a dead
|
|
155
|
+
button for those users. `ideasbugs.title` ships in every bundled language:
|
|
194
156
|
|
|
195
157
|
```erb
|
|
196
158
|
<% if Ideasbugs.enabled?(request) %>
|
|
@@ -198,100 +160,39 @@ a localized trigger needs no keys of your own:
|
|
|
198
160
|
<% end %>
|
|
199
161
|
```
|
|
200
162
|
|
|
201
|
-
|
|
163
|
+
<details>
|
|
164
|
+
<summary><b>Loading the widget on one page only</b></summary>
|
|
202
165
|
|
|
203
|
-
`ideasbugs_tag` in the layout puts the widget on every page. If your
|
|
204
|
-
|
|
205
|
-
example into the layout's `<head>` via `content_for`:
|
|
166
|
+
`ideasbugs_tag` in the layout puts the widget on every page. If your only
|
|
167
|
+
trigger lives on one page, render the tag there instead:
|
|
206
168
|
|
|
207
169
|
```erb
|
|
208
|
-
<%#
|
|
170
|
+
<%# layouts/application.html.erb %>
|
|
209
171
|
<head>
|
|
210
|
-
...
|
|
211
172
|
<%= yield :head %>
|
|
212
173
|
</head>
|
|
213
174
|
|
|
214
|
-
<%#
|
|
175
|
+
<%# users/show.html.erb — the page with the trigger %>
|
|
215
176
|
<% content_for :head, ideasbugs_tag %>
|
|
216
177
|
```
|
|
217
178
|
|
|
218
|
-
|
|
219
|
-
session and re-renders on `turbo:load
|
|
220
|
-
arrives on the first page load or a later visit. The trade-off: a
|
|
221
|
-
`data-ideasbugs-open` element on a page that never rendered the tag
|
|
222
|
-
silently does nothing — if triggers may appear on several pages, keep the tag
|
|
223
|
-
in the layout.
|
|
224
|
-
|
|
225
|
-
### Protecting the dashboard
|
|
226
|
-
|
|
227
|
-
The dashboard (index/show/triage) is gated by `config.authorize_admin`, which
|
|
228
|
-
defaults to **development only** so a fresh install can never leak feedback in
|
|
229
|
-
production. Grant access however your app resolves admins:
|
|
179
|
+
Turbo-safe either way: the script installs its document-level listeners once
|
|
180
|
+
per session and re-renders on `turbo:load`.
|
|
230
181
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
# Basic auth, feature flag, IP allowlist — anything based on the request:
|
|
236
|
-
config.authorize_admin = ->(request) { Flipper.enabled?(:feedback_admin) }
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
You can also wrap the mount in your own routing constraint (the lambda still
|
|
240
|
-
applies on top):
|
|
241
|
-
|
|
242
|
-
```ruby
|
|
243
|
-
authenticate :user, ->(user) { user.admin? } do
|
|
244
|
-
mount Ideasbugs::Engine => "/feedback"
|
|
245
|
-
end
|
|
246
|
-
```
|
|
182
|
+
**Trade-off:** a `data-ideasbugs-open` element on a page that never rendered
|
|
183
|
+
the tag silently does nothing. If triggers appear on several pages, keep the
|
|
184
|
+
tag in the layout.
|
|
247
185
|
|
|
248
|
-
|
|
186
|
+
</details>
|
|
249
187
|
|
|
250
|
-
|
|
251
|
-
already. Limits (`max_screenshots`, `max_screenshot_size`) are enforced
|
|
252
|
-
server-side and shown as a hint in the widget. If Active Storage isn't loaded,
|
|
253
|
-
the upload control simply doesn't render and uploads are rejected.
|
|
188
|
+
## Screenshots
|
|
254
189
|
|
|
255
|
-
|
|
190
|
+
Uploads use Active Storage — run `bin/rails active_storage:install` if you
|
|
191
|
+
haven't. Count, size and content type are enforced server-side. If Active
|
|
192
|
+
Storage isn't loaded, the upload control doesn't render and uploads are
|
|
193
|
+
rejected.
|
|
256
194
|
|
|
257
|
-
|
|
258
|
-
job:
|
|
259
|
-
|
|
260
|
-
```ruby
|
|
261
|
-
config.on_submit = ->(feedback) do
|
|
262
|
-
SlackNotifier.post("New #{feedback.kind}: #{feedback.message.truncate(100)}")
|
|
263
|
-
end
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
### Localizing the widget
|
|
267
|
-
|
|
268
|
-
Every string resolves through Rails I18n under the `ideasbugs.*` scope
|
|
269
|
-
and follows the current `I18n.locale`. Missing keys fall back to English. To
|
|
270
|
-
add a language or reword the bundled copy, define the keys in your own locale
|
|
271
|
-
files (yours win over the gem's):
|
|
272
|
-
|
|
273
|
-
```yaml
|
|
274
|
-
# config/locales/nl.yml
|
|
275
|
-
nl:
|
|
276
|
-
ideasbugs:
|
|
277
|
-
button: "Feedback"
|
|
278
|
-
title: "Feedback versturen"
|
|
279
|
-
kinds:
|
|
280
|
-
bug: "Fout melden"
|
|
281
|
-
feature: "Functie aanvragen"
|
|
282
|
-
other: "Overig"
|
|
283
|
-
# …see config/locales/ideasbugs.en.yml for the full key list
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
Custom kinds get their labels the same way (`ideasbugs.kinds.<kind>`),
|
|
287
|
-
falling back to `kind.humanize`.
|
|
288
|
-
|
|
289
|
-
### Light / dark / system appearance
|
|
290
|
-
|
|
291
|
-
Both the widget and the dashboard follow the operating-system appearance via
|
|
292
|
-
`prefers-color-scheme` — no configuration needed.
|
|
293
|
-
|
|
294
|
-
## Working with feedback in code
|
|
195
|
+
## The data
|
|
295
196
|
|
|
296
197
|
Submissions are ordinary records:
|
|
297
198
|
|
|
@@ -301,91 +202,127 @@ Ideasbugs::Feedback.where(status: "open").newest_first.each do |feedback|
|
|
|
301
202
|
end
|
|
302
203
|
```
|
|
303
204
|
|
|
304
|
-
Each row stores `kind`, `section`, `message`, `status` (`open
|
|
205
|
+
Each row stores `kind`, `section`, `message`, `status` (`open` / `in_review` /
|
|
305
206
|
`resolved`), `page_url`, `user_agent`, an optional `tenant`, and optional
|
|
306
207
|
`author_id` / `author_label`. Screenshots are Active Storage attachments
|
|
307
208
|
(`feedback.screenshots`).
|
|
308
209
|
|
|
309
210
|
## Multi-tenancy
|
|
310
211
|
|
|
311
|
-
Give each customer
|
|
312
|
-
|
|
212
|
+
Give each customer its own board — its own submissions, its own dashboard —
|
|
213
|
+
with one resolver:
|
|
313
214
|
|
|
314
215
|
```ruby
|
|
315
216
|
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
|
|
316
217
|
```
|
|
317
218
|
|
|
318
|
-
Return an **opaque key
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
single-tenant apps need none of this
|
|
219
|
+
Return an **opaque key**: a GlobalID, an id, a subdomain, a slug. The gem never
|
|
220
|
+
takes a foreign key into your models and never needs to know what a Customer
|
|
221
|
+
is. It stamps the key on each submission and scopes the dashboard (and
|
|
222
|
+
screenshot access) to it. `nil` — the default — is a single global board, so
|
|
223
|
+
single-tenant apps need none of this.
|
|
323
224
|
|
|
324
|
-
Authorization composes:
|
|
325
|
-
|
|
326
|
-
|
|
225
|
+
Authorization composes: `authorize_admin` says *who* is an admin, `tenant` says
|
|
226
|
+
*which* tenant they're in. A customer's admin sees only their own board, and a
|
|
227
|
+
cross-tenant id 404s.
|
|
327
228
|
|
|
328
|
-
|
|
229
|
+
<details>
|
|
230
|
+
<summary><b>Optional model sugar, and upgrading an existing install</b></summary>
|
|
231
|
+
|
|
232
|
+
A veneer over the string key, not a new coupling:
|
|
329
233
|
|
|
330
234
|
```ruby
|
|
331
235
|
class Customer < ApplicationRecord
|
|
332
|
-
has_feedback
|
|
236
|
+
has_feedback # keyed by to_gid.to_s (match config.tenant)
|
|
333
237
|
end
|
|
334
238
|
|
|
335
|
-
customer.feedback.open
|
|
239
|
+
customer.feedback.open # a normal Active Record relation
|
|
336
240
|
```
|
|
337
241
|
|
|
338
242
|
Apps that skip the concern still get full multi-tenancy from the resolver
|
|
339
|
-
alone.
|
|
340
|
-
`has_feedback(key: ->(c){ c.subdomain })` and the same in
|
|
243
|
+
alone. Keying by something other than GlobalID? Pass a matching resolver to
|
|
244
|
+
both: `has_feedback(key: ->(c){ c.subdomain })` and the same in
|
|
245
|
+
`config.tenant`.
|
|
246
|
+
|
|
247
|
+
**Upgrading:** the `tenant` column is additive and nullable. Run
|
|
248
|
+
`bin/rails generate ideasbugs:tenant && bin/rails db:migrate` (a fresh install
|
|
249
|
+
already includes it). Existing rows keep a `nil` tenant — the global board — so
|
|
250
|
+
nothing changes until you set `config.tenant`.
|
|
251
|
+
|
|
252
|
+
</details>
|
|
253
|
+
|
|
254
|
+
## Localization
|
|
255
|
+
|
|
256
|
+
Every string resolves through Rails I18n under `ideasbugs.*` and follows the
|
|
257
|
+
current `I18n.locale`. 26 languages ship; missing keys fall back to English;
|
|
258
|
+
RTL locales render mirrored.
|
|
259
|
+
|
|
260
|
+
<details>
|
|
261
|
+
<summary><b>Bundled languages, and overriding the copy</b></summary>
|
|
262
|
+
|
|
263
|
+
Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch, English,
|
|
264
|
+
French, German, Greek, Hindi, Indonesian, Italian, Japanese, Korean,
|
|
265
|
+
Luxembourgish, Polish, Portuguese, Romanian, Russian, Spanish, Thai, Turkish,
|
|
266
|
+
Ukrainian, Urdu, Vietnamese.
|
|
267
|
+
|
|
268
|
+
Define the keys in your own locale files — yours win over the gem's:
|
|
269
|
+
|
|
270
|
+
```yaml
|
|
271
|
+
# config/locales/nl.yml
|
|
272
|
+
nl:
|
|
273
|
+
ideasbugs:
|
|
274
|
+
button: "Feedback"
|
|
275
|
+
title: "Feedback versturen"
|
|
276
|
+
kinds:
|
|
277
|
+
bug: "Fout melden"
|
|
278
|
+
feature: "Functie aanvragen"
|
|
279
|
+
other: "Overig"
|
|
280
|
+
# …see config/locales/ideasbugs.en.yml for the full key list
|
|
281
|
+
```
|
|
341
282
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
283
|
+
Custom kinds get labels the same way (`ideasbugs.kinds.<kind>`), falling back
|
|
284
|
+
to `kind.humanize`.
|
|
285
|
+
|
|
286
|
+
</details>
|
|
346
287
|
|
|
347
288
|
## Security
|
|
348
289
|
|
|
349
|
-
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
`<script type="application/json">` block (data, not code), so it needs no
|
|
365
|
-
nonce and stays correct across Turbo visits.
|
|
366
|
-
- Author attribution is stored as loose fields (no foreign key into your user
|
|
367
|
-
table), so the gem never couples to your user model.
|
|
368
|
-
|
|
369
|
-
## Turbo
|
|
370
|
-
|
|
371
|
-
Works with Turbo Drive out of the box. Turbo replaces `<body>` on every visit,
|
|
372
|
-
which would take the floating button with it, so the widget registers its
|
|
373
|
-
document-level listeners once and re-renders on `turbo:load`.
|
|
290
|
+
- **Both gates run server-side**, on every request. The dashboard denies
|
|
291
|
+
everything outside development until you configure `authorize_admin`.
|
|
292
|
+
- **Screenshots stream through the dashboard's own gate** — never public Active
|
|
293
|
+
Storage blob URLs. A screenshot can contain anything the user's screen
|
|
294
|
+
showed, so it's reachable only by someone who passes `authorize_admin`,
|
|
295
|
+
whatever your app's blob settings are.
|
|
296
|
+
- **Rate-limited per IP** (10/min by default, Rails 7.2+), so one bot can't
|
|
297
|
+
flood your database with uploads.
|
|
298
|
+
- **Uploads validated server-side** — count, size, and images-only content
|
|
299
|
+
type — regardless of what the client claims.
|
|
300
|
+
- **CSP nonce carried** from `ActionDispatch`. Runtime config ships as
|
|
301
|
+
`<script type="application/json">` (data, not code), so it needs no nonce and
|
|
302
|
+
survives Turbo visits.
|
|
303
|
+
- **No foreign key into your user table.** Attribution is loose fields, so the
|
|
304
|
+
gem never couples to your user model.
|
|
374
305
|
|
|
375
306
|
## Development
|
|
376
307
|
|
|
377
308
|
```bash
|
|
378
|
-
bin/setup
|
|
379
|
-
bundle exec rake test
|
|
380
|
-
bundle exec rake test:system
|
|
309
|
+
bin/setup
|
|
310
|
+
bundle exec rake test # unit + integration
|
|
311
|
+
bundle exec rake test:system # browser tests (headless Chrome)
|
|
381
312
|
bundle exec rubocop
|
|
382
313
|
```
|
|
383
314
|
|
|
384
|
-
Tests run against a dummy Rails app
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
315
|
+
Tests run against a dummy Rails app in `test/dummy`; the widget is covered by
|
|
316
|
+
Capybara system tests in a real browser. CI runs Rails 7.1 / 7.2 / 8.0 / 8.1
|
|
317
|
+
against Ruby 3.2–3.4 (per-version Gemfiles in `gemfiles/`).
|
|
318
|
+
|
|
319
|
+
## Also by the same author
|
|
320
|
+
|
|
321
|
+
- [testimonials](https://github.com/yshmarov/testimonials) — testimonials,
|
|
322
|
+
reviews and NPS for Rails. Its NPS detractor hook feeds straight into this gem.
|
|
323
|
+
- [SupeRails](https://superails.com) — Rails screencasts.
|
|
388
324
|
|
|
389
325
|
## License
|
|
390
326
|
|
|
391
|
-
|
|
327
|
+
MIT. Formerly published as `feedback_engine` — see the
|
|
328
|
+
[CHANGELOG](CHANGELOG.md) for rename notes.
|
data/lib/ideasbugs/version.rb
CHANGED
data/lib/ideasbugs/widget.js
CHANGED
|
@@ -131,18 +131,69 @@
|
|
|
131
131
|
// Keep Tab (and Shift+Tab) cycling inside the dialog while it is open.
|
|
132
132
|
overlay.addEventListener("keydown", trapFocus);
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
// Pin the full-screen mobile dialog above the on-screen keyboard.
|
|
135
|
+
bindViewport();
|
|
136
|
+
|
|
137
|
+
// Focus the message textarea, not the first field. The first field is the
|
|
138
|
+
// type <select>, and focusing a <select> on iOS immediately pops the native
|
|
139
|
+
// picker — which looks broken on mount. Tab still reaches the select and the
|
|
140
|
+
// focus trap still works, so keyboard access is unchanged.
|
|
141
|
+
var focusTarget = dialog.querySelector("textarea[name=message]") ||
|
|
142
|
+
dialog.querySelector("textarea, input, select");
|
|
143
|
+
if (focusTarget) focusTarget.focus();
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
function closeForm() {
|
|
139
147
|
if (!overlay) return;
|
|
148
|
+
unbindViewport();
|
|
140
149
|
overlay.remove();
|
|
141
150
|
overlay = null;
|
|
142
151
|
unlockScroll();
|
|
143
152
|
if (lastFocused && lastFocused.focus) lastFocused.focus();
|
|
144
153
|
}
|
|
145
154
|
|
|
155
|
+
// --- mobile viewport pinning ------------------------------------------------
|
|
156
|
+
|
|
157
|
+
// The mobile dialog is full-screen at height:100dvh. iOS Safari raises the
|
|
158
|
+
// keyboard WITHOUT resizing fixed 100dvh elements, so the actions row hides
|
|
159
|
+
// behind it. While the dialog is open AND the mobile media query matches, we
|
|
160
|
+
// pin #idb-dialog to the visible viewport via the visualViewport API. Older
|
|
161
|
+
// browsers (no visualViewport) and desktop (media-query guarded) are no-ops.
|
|
162
|
+
var MOBILE_QUERY = "(max-width:480px)";
|
|
163
|
+
|
|
164
|
+
function syncViewport() {
|
|
165
|
+
var dialog = overlay && overlay.querySelector("#idb-dialog");
|
|
166
|
+
if (!dialog) return;
|
|
167
|
+
var vv = window.visualViewport;
|
|
168
|
+
if (vv && window.matchMedia(MOBILE_QUERY).matches) {
|
|
169
|
+
dialog.style.height = vv.height + "px";
|
|
170
|
+
dialog.style.top = vv.offsetTop + "px";
|
|
171
|
+
} else {
|
|
172
|
+
// Desktop / non-mobile: never leave stale inline sizing behind.
|
|
173
|
+
dialog.style.height = "";
|
|
174
|
+
dialog.style.top = "";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function bindViewport() {
|
|
179
|
+
if (!window.visualViewport) return;
|
|
180
|
+
window.visualViewport.addEventListener("resize", syncViewport);
|
|
181
|
+
window.visualViewport.addEventListener("scroll", syncViewport);
|
|
182
|
+
syncViewport(); // apply once on open
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function unbindViewport() {
|
|
186
|
+
if (window.visualViewport) {
|
|
187
|
+
window.visualViewport.removeEventListener("resize", syncViewport);
|
|
188
|
+
window.visualViewport.removeEventListener("scroll", syncViewport);
|
|
189
|
+
}
|
|
190
|
+
var dialog = overlay && overlay.querySelector("#idb-dialog");
|
|
191
|
+
if (dialog) {
|
|
192
|
+
dialog.style.height = "";
|
|
193
|
+
dialog.style.top = "";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
146
197
|
function trapFocus(event) {
|
|
147
198
|
if (event.key !== "Tab" || !overlay) return;
|
|
148
199
|
var focusable = overlay.querySelectorAll("button, select, textarea, input, a[href]");
|