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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bbc8755793805b68de161f826264fd1efeb09d776c3d9ae13e85dadbd9857e8
4
- data.tar.gz: dee97815aa8142790ab29a60d0f6f3d47a7933d38282ff83a42eca12a2bd0fe8
3
+ metadata.gz: 6b3ee598ec8e5e5e5e67dddab875252edaea6f4a49101a311504d5d2d2b5f87c
4
+ data.tar.gz: d7d9e57238759a532fb25a5d0cc0a6bde9b9385e0d301febe01fc092d3bfe418
5
5
  SHA512:
6
- metadata.gz: '09b4187c7b39c246ebe4e82fe99aa0cd5f55e537bd272af920f8bebc5aad2da715c690c32258924cb32230afab05cfd8b84b060972810d6b6f26c361d06b9e65'
7
- data.tar.gz: 160aab501244d3ee7ebaa7c52a27854973e2cd454d9af6d3e4b0773186cb5ee5354540a27899e14efec0a59cbe8ae18193f85f0ff23becc203a099f358ff2393
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
  [![CI](https://github.com/yshmarov/ideasbugs/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/ideasbugs/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
7
7
 
8
- In-app product feedback collection for Rails. (Formerly published as
9
- `feedback_engine`see the [CHANGELOG](CHANGELOG.md) for the rename notes.)
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
+ dashboardbug 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="demo-widget-dark.png">
19
- <img alt="The feedback widget: type, section, message, and screenshots" src="demo-widget-light.png" width="420">
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="demo-dashboard-dark.png">
24
- <img alt="The built-in triage dashboard: status tabs, filters, search" src="demo-dashboard-light.png" width="820">
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
- Both the widget and the dashboard follow the viewer's system appearance —
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
- > The widget reads the CSRF token from `<meta name="csrf-token">`, which
107
- > `csrf_meta_tags` in your layout already provides in a standard Rails app.
108
-
109
- Boot the app and look for the **Feedback** button in the bottom-right corner.
110
- Submissions appear at [`/feedback`](http://localhost:3000/feedback) (dashboard
111
- access defaults to development only — see below).
112
-
113
- ## Configuration
114
-
115
- Everything is optional; the defaults work out of the box.
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
- # config/initializers/ideasbugs.rb
119
- Ideasbugs.configure do |config|
120
- # Who can send feedback. Return false to hide the widget and reject
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
- # Per-IP throttle for the submission endpoint (Rails 7.2+; ignored on 7.1).
148
- # Keyword arguments for Rails' rate limiter; nil disables throttling.
149
- config.rate_limit = { to: 10, within: 1.minute }
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
- # Show the floating feedback button. Set false and trigger the form from
152
- # your own UI instead (see below).
153
- config.show_button = true
118
+ # Anything else based on the request
119
+ config.authorize_admin = ->(request) { Flipper.enabled?(:feedback_admin) }
120
+ ```
154
121
 
155
- # Fixed button text; leave nil to use the localized default.
156
- config.button_label = nil
122
+ <details>
123
+ <summary><b>Other ways to protect the dashboard</b></summary>
157
124
 
158
- # Keep in sync with the `mount` in config/routes.rb.
159
- config.mount_path = "/feedback"
125
+ Wrap the mount in a routing constraint — the lambda still applies on top:
160
126
 
161
- # Called with each saved feedback — notify Slack, send an email, etc.
162
- config.on_submit = ->(feedback) { FeedbackMailer.with(feedback:).new_feedback.deliver_later }
127
+ ```ruby
128
+ authenticate :user, ->(user) { user.admin? } do
129
+ mount Ideasbugs::Engine => "/feedback"
163
130
  end
164
131
  ```
165
132
 
166
- `current_user` (and any admin gate) receives the raw request, so it works
167
- with whatever auth you have:
133
+ </details>
168
134
 
169
- ```ruby
170
- # Devise / Warden:
171
- config.current_user = ->(request) { request.env["warden"]&.user }
135
+ <details>
136
+ <summary><b>Notifications on new feedback</b></summary>
172
137
 
173
- # Rails 8 built-in auth (bin/rails generate authentication):
174
- config.current_user = lambda do |request|
175
- token = request.cookies["session_token"]
176
- Session.find_signed(token)&.user if token
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
- ### Opening the form from your own UI
146
+ </details>
181
147
 
182
- Prefer a nav item over the floating button? Add `data-ideasbugs-open` to
183
- any element and (optionally) hide the button:
148
+ ## Trigger it from your own UI
184
149
 
185
- ```ruby
186
- config.show_button = false # optional
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
- renders nothing when `config.enabled` rejects the request, so an unguarded
191
- trigger would be a dead button for those users. The gem ships a
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
- ### Loading the widget only where it's used
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
- only trigger lives on one page, render the tag on that page instead — for
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
- <%# app/views/layouts/application.html.erb %>
170
+ <%# layouts/application.html.erb %>
209
171
  <head>
210
- ...
211
172
  <%= yield :head %>
212
173
  </head>
213
174
 
214
- <%# app/views/users/show.html.erb — the page with the trigger %>
175
+ <%# users/show.html.erb — the page with the trigger %>
215
176
  <% content_for :head, ideasbugs_tag %>
216
177
  ```
217
178
 
218
- This is Turbo-safe: the script installs its document-level listeners once per
219
- session and re-renders on `turbo:load` (see [Turbo](#turbo)), whether it
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
- ```ruby
232
- # Devise:
233
- config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
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
- ### Screenshots
186
+ </details>
249
187
 
250
- Uploads use Active Storage: `bin/rails active_storage:install` if you haven't
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
- ### Notifications
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
- `config.on_submit` runs inline after each save — keep it fast or hand off to a
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`, `in_review`,
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/tenant its own board — its own widget submissions and its
312
- own triage dashboard — with one resolver:
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** a GlobalID, an id, a subdomain, a slug. The gem
319
- never takes a foreign key into your models and never needs to know what a
320
- Customer is; it just stamps the key on each submission and scopes the dashboard
321
- (and screenshot access) to it. `nil` (the default) is a single global board
322
- single-tenant apps need none of this and keep working unchanged.
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: your `authorize_admin` says *who* is an admin, the
325
- `tenant` resolver says *which* tenant they're in, so a customer's admin sees
326
- only their own board — and a cross-tenant id 404s.
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
- Optional model sugar — a veneer over the string key, not a new coupling:
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 # keyed by to_gid.to_s (match config.tenant)
236
+ has_feedback # keyed by to_gid.to_s (match config.tenant)
333
237
  end
334
238
 
335
- customer.feedback.open # a normal Active Record relation
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. Key by something other than GlobalID? Pass a matching resolver to both:
340
- `has_feedback(key: ->(c){ c.subdomain })` and the same in `config.tenant`.
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
- **Upgrading an existing install:** the `tenant` column is additive and
343
- nullable. Run `bin/rails generate ideasbugs:tenant && bin/rails db:migrate`
344
- (a fresh install already includes it). Existing rows keep a `nil` tenant — the
345
- global board — so nothing changes until you set `config.tenant`.
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
- - Submission and dashboard access are both gated **on the server** for every
350
- request; the dashboard denies everything outside development until you
351
- configure `authorize_admin`.
352
- - Screenshots are **streamed through the dashboard's own gate**, never linked
353
- as public Active Storage blob URLs a screenshot can contain anything a
354
- user's screen showed, so it is only reachable by someone who passes
355
- `authorize_admin`, regardless of how your app configures blob access.
356
- - The submission endpoint is **rate-limited per IP** (10/minute by default,
357
- via Rails' built-in limiter on Rails 7.2+), so one user or bot can't flood
358
- your database with megabytes of uploads.
359
- - Screenshot count, size, and content type (images only) are validated
360
- server-side, regardless of what the client claims.
361
- - The widget code carries the request's Content-Security-Policy nonce (the
362
- same one `ActionDispatch` emits), so it runs under a nonce-based
363
- `script-src` policy with no configuration. The runtime config ships as a
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 # or: bundle install
379
- bundle exec rake test # unit + integration
380
- bundle exec rake test:system # browser tests (headless Chrome)
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 under `test/dummy`; the widget itself is
385
- covered by Capybara system tests in a real browser. CI runs the suite across
386
- Rails 7.1 / 7.2 / 8.0 / 8.1 and Ruby 3.2–3.4 (per-version Gemfiles live in
387
- `gemfiles/`).
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
- Released under the [MIT License](MIT-LICENSE).
327
+ MIT. Formerly published as `feedback_engine` — see the
328
+ [CHANGELOG](CHANGELOG.md) for rename notes.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ideasbugs
4
- VERSION = '0.6.1'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -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
- var first = dialog.querySelector("select, textarea, input");
135
- if (first) first.focus();
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]");
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ideasbugs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov