lsa_tdx_feedback 1.0.4 → 2.0.1
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 +67 -0
- data/Gemfile.lock +2 -2
- data/README.md +124 -14
- data/app/assets/javascripts/lsa_tdx_feedback.js +45 -33
- data/app/assets/stylesheets/lsa_tdx_feedback.css +159 -62
- data/app/controllers/lsa_tdx_feedback/feedback_controller.rb +65 -26
- data/app/views/lsa_tdx_feedback/shared/_feedback_modal.html.erb +38 -30
- data/config/locales/en.yml +30 -0
- data/lib/lsa_tdx_feedback/application_controller_extensions.rb +2 -11
- data/lib/lsa_tdx_feedback/configuration.rb +12 -1
- data/lib/lsa_tdx_feedback/version.rb +1 -1
- data/lib/lsa_tdx_feedback/view_helpers.rb +13 -6
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69d0dae8aaebab74ea26a463495be295ec35369e9ec579cdfe7ee008837fcbb3
|
|
4
|
+
data.tar.gz: de81da8ce4c00ed435be260b49d7cd0bd447d963dae02c1d8094ece55fa9bd2d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b2dd1661fc85b467a0c656ac2a6024a2735fbb81c8c2c7df90c33579e43d8b1da3595417ea52b1c86b8ac7bf96adcfc8762db8fe7b6b41cc3894b5b3dff0210
|
|
7
|
+
data.tar.gz: d6bdfeb8dc93fc60fc41c03895303bef1a906a839508154e41d9bd910911bbe386b2f958c611adb00a91bb4d787a390df9fcaafc4fea5963eb0fce7aa645299a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.1] - 2026-07-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Duplicate floating trigger button**: the modal partial rendered two
|
|
14
|
+
`#lsa-tdx-feedback-trigger` buttons (an i18n button outside the `trigger:`
|
|
15
|
+
guard, plus the old hardcoded button inside it). The JS click handler bound
|
|
16
|
+
only to the first; the second sat on top and swallowed clicks, so the modal
|
|
17
|
+
never opened and produced no console or server errors. Also meant
|
|
18
|
+
`trigger: false` still left a button on the page. There is now a single
|
|
19
|
+
i18n button inside the `trigger:` guard.
|
|
20
|
+
|
|
21
|
+
## [2.0.0] - 2026-07-20
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **`NameError` on controllers that don't define `current_user`**: `set_lsa_tdx_feedback_data`
|
|
25
|
+
referenced `current_user` unconditionally inside a debug log line, so it raised on any
|
|
26
|
+
controller without that method — ViewComponent's preview controller,
|
|
27
|
+
`Rails::HealthController`, ActiveStorage's controllers, and so on. Because the extension is
|
|
28
|
+
mixed into `ActionController::Base` via `on_load :action_controller`, this broke those
|
|
29
|
+
requests in every host application.
|
|
30
|
+
- Removed the leftover per-request debug logging, which also drops six `Rails.logger.info`
|
|
31
|
+
calls emitted on every request of every controller.
|
|
32
|
+
- The documented `current_user_email_for_feedback` override point is unchanged, and it was
|
|
33
|
+
already guarded — email prefill still works exactly as before.
|
|
34
|
+
- Added specs covering both cases (controller with and without `current_user`).
|
|
35
|
+
|
|
10
36
|
### Added
|
|
37
|
+
- **I18n for the modal**: every visible string in the modal now comes from I18n,
|
|
38
|
+
scoped under `lsa_tdx_feedback.*` (English defaults ship in
|
|
39
|
+
`config/locales/en.yml`, auto-loaded by the engine). Host apps can translate to
|
|
40
|
+
other locales — or reword any string — by defining the same scoped keys in
|
|
41
|
+
their own locale files, which load after the gem's and win. Keys are namespaced
|
|
42
|
+
so they never collide with a host app's own I18n. (The JavaScript's runtime
|
|
43
|
+
status/validation messages are not yet localized — a follow-up.)
|
|
44
|
+
- **Opt-out for the floating trigger button**: `lsa_tdx_feedback_modal(trigger: false)`
|
|
45
|
+
(and `lsa_tdx_feedback(trigger: false)`) render the modal WITHOUT the built-in
|
|
46
|
+
fixed bottom-right button, so a host can open it from its own control via
|
|
47
|
+
`window.LsaTdxFeedback.showModal()` — e.g. when the fixed button collides with
|
|
48
|
+
other fixed page chrome (cookie banners, toasts). Defaults to `true`; callers
|
|
49
|
+
that don't pass `trigger:` are unaffected.
|
|
50
|
+
- **Delivery fallback (`config.fallback`)**: an optional callable invoked with the
|
|
51
|
+
feedback data when a ticket can't be filed — TDX not configured, or the API call
|
|
52
|
+
raises — so feedback is never lost. The controller files a TDX ticket when TDX is
|
|
53
|
+
configured and the call succeeds, otherwise hands the data to your fallback (e.g.
|
|
54
|
+
emailing an admin, enqueuing a job). Defaults to `nil`, which preserves the
|
|
55
|
+
original behavior exactly: no fallback, error response on failure.
|
|
11
56
|
- **Documentation**: Added Rails 8 authentication setup instructions to README
|
|
12
57
|
- Step-by-step guide for generating Rails 8.1.1's built-in authentication system
|
|
13
58
|
- Instructions for creating `current_user` helper method to match Devise pattern
|
|
14
59
|
- Examples for protecting actions and excluding authentication for specific controllers
|
|
15
60
|
- Documentation on using `allow_unauthenticated_access` for public pages
|
|
16
61
|
|
|
62
|
+
### Changed
|
|
63
|
+
- **Modal is now a native `<dialog>`**: it renders in the browser's top layer
|
|
64
|
+
(above all page content, no z-index needed) with a built-in focus trap and
|
|
65
|
+
native Escape handling, so it no longer competes with host page chrome or
|
|
66
|
+
relies on a hand-rolled backdrop/focus/escape. The JS now saves and restores
|
|
67
|
+
the host's `document.body` overflow on open/close instead of clearing it
|
|
68
|
+
(which could clobber another component's scroll lock).
|
|
69
|
+
- **WCAG 2.2 AAA defaults + themeable tokens**: the stylesheet now targets AAA
|
|
70
|
+
(7:1 text contrast, 44px minimum touch targets) in light and dark, and its
|
|
71
|
+
colors/sizes are exposed as `--lsa-tdx-feedback-*` CSS custom properties for
|
|
72
|
+
rebranding without copying the stylesheet. A `prefers-color-scheme: dark`
|
|
73
|
+
variant is included, and the modal's field colors are pinned so a theme-aware
|
|
74
|
+
host app can't bleed low-contrast text into them. This changes the modal's
|
|
75
|
+
default appearance (notably a darker, AAA-contrast accent).
|
|
76
|
+
|
|
77
|
+
### Breaking
|
|
78
|
+
- The modal's outer element changed from `<div>` to `<dialog>`, and the
|
|
79
|
+
`.lsa-tdx-feedback-modal-backdrop` div was removed (the backdrop is now the
|
|
80
|
+
`::backdrop` pseudo-element). If you override the modal partial, or style the
|
|
81
|
+
old backdrop div, update accordingly. Custom colors/sizes previously set by
|
|
82
|
+
editing the stylesheet should move to the `--lsa-tdx-feedback-*` properties.
|
|
83
|
+
|
|
17
84
|
## [1.0.4] - 2026-02-09
|
|
18
85
|
|
|
19
86
|
### Changed
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
lsa_tdx_feedback (
|
|
4
|
+
lsa_tdx_feedback (2.0.1)
|
|
5
5
|
httparty (~> 0.24.0)
|
|
6
6
|
rails (>= 6.0)
|
|
7
7
|
redis (>= 4.0)
|
|
@@ -87,7 +87,7 @@ GEM
|
|
|
87
87
|
benchmark (0.4.1)
|
|
88
88
|
bigdecimal (4.0.1)
|
|
89
89
|
builder (3.3.0)
|
|
90
|
-
concurrent-ruby (1.3.
|
|
90
|
+
concurrent-ruby (1.3.7)
|
|
91
91
|
connection_pool (2.5.4)
|
|
92
92
|
crack (1.0.0)
|
|
93
93
|
bigdecimal
|
data/README.md
CHANGED
|
@@ -148,6 +148,26 @@ This avoids FOUC (flash of unstyled content) and ensures the script loads after
|
|
|
148
148
|
- **Optimal loading (recommended)**: Use the separate helpers so CSS loads in `<head>` and JS loads just before `</body>`. This avoids FOUC and keeps asset order predictable.
|
|
149
149
|
- **Convenience**: Use `lsa_tdx_feedback` (all‑in‑one) for quick setup. It injects CSS/JS plus the modal where it’s called, which can delay CSS and is less ideal for performance.
|
|
150
150
|
|
|
151
|
+
#### Bring your own trigger
|
|
152
|
+
|
|
153
|
+
By default the modal ships a fixed bottom‑right floating button. To render the
|
|
154
|
+
modal **without** it — and open it from your own control (a nav link, a footer
|
|
155
|
+
button) — pass `trigger: false` and call `window.LsaTdxFeedback.showModal()`:
|
|
156
|
+
|
|
157
|
+
```erb
|
|
158
|
+
<%= lsa_tdx_feedback_modal(trigger: false) %>
|
|
159
|
+
|
|
160
|
+
<button type="button" id="my-feedback-link">Send feedback</button>
|
|
161
|
+
<script>
|
|
162
|
+
document.getElementById('my-feedback-link')
|
|
163
|
+
.addEventListener('click', () => window.LsaTdxFeedback.showModal());
|
|
164
|
+
</script>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
This is handy when the fixed button overlaps other fixed page chrome (a cookie
|
|
168
|
+
banner, toasts), or when you want feedback reachable from a specific place in
|
|
169
|
+
your own UI. `lsa_tdx_feedback(trigger: false)` works the same way.
|
|
170
|
+
|
|
151
171
|
#### Recommended placement (optimal)
|
|
152
172
|
|
|
153
173
|
```erb
|
|
@@ -238,6 +258,73 @@ class ApplicationController < ActionController::Base
|
|
|
238
258
|
end
|
|
239
259
|
```
|
|
240
260
|
|
|
261
|
+
### Translating or customizing the modal text
|
|
262
|
+
|
|
263
|
+
The modal's visible strings come from I18n, scoped under `lsa_tdx_feedback.*`
|
|
264
|
+
(English defaults ship with the gem and are auto-loaded). To translate to another
|
|
265
|
+
locale — or to reword any string — define the same keys in your app's locale
|
|
266
|
+
files; they load after the gem's, so they win:
|
|
267
|
+
|
|
268
|
+
```yaml
|
|
269
|
+
# config/locales/fr.yml
|
|
270
|
+
fr:
|
|
271
|
+
lsa_tdx_feedback:
|
|
272
|
+
modal:
|
|
273
|
+
title: "Envoyer un commentaire"
|
|
274
|
+
submit: "Envoyer"
|
|
275
|
+
# ...override only what you need
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
See `config/locales/en.yml` in the gem for the full list of keys. (The
|
|
279
|
+
JavaScript's runtime messages — submission success and client-side validation
|
|
280
|
+
text — are not yet localized.)
|
|
281
|
+
### Delivery fallback (never lose feedback)
|
|
282
|
+
|
|
283
|
+
By default, if TDX isn't configured — or a ticket can't be created — the modal
|
|
284
|
+
shows an error. Set `config.fallback` to a callable and the gem hands it the
|
|
285
|
+
feedback instead, so you can email an admin, enqueue a job, or log it, and the
|
|
286
|
+
user still gets a success response:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# config/initializers/lsa_tdx_feedback.rb
|
|
290
|
+
LsaTdxFeedback.configure do |config|
|
|
291
|
+
# ...your TDX config...
|
|
292
|
+
|
|
293
|
+
config.fallback = ->(feedback_data) do
|
|
294
|
+
FeedbackMailer.submission(feedback_data).deliver_later
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The callable receives the same `feedback_data` hash the ticket would have used
|
|
300
|
+
(`:feedback`, `:email`, `:category`, `:url`, `:user_agent`, `:additional_info`,
|
|
301
|
+
`:title`, `:priority_id`). When `config.fallback` is `nil` (the default), behavior
|
|
302
|
+
is unchanged. A TDX ticket is still filed when TDX is configured and the call
|
|
303
|
+
succeeds — the fallback runs only when it can't.
|
|
304
|
+
|
|
305
|
+
### Theming (colors, dark mode, accessibility)
|
|
306
|
+
|
|
307
|
+
The modal is a native `<dialog>` — it renders in the browser's top layer (above
|
|
308
|
+
your page chrome, with a built-in focus trap and Escape handling), so it needs
|
|
309
|
+
no z-index coordination with the rest of your app.
|
|
310
|
+
|
|
311
|
+
Its styling ships **WCAG 2.2 AAA** defaults (7:1 text contrast, 44px touch
|
|
312
|
+
targets) in both light and dark, and exposes colors and sizes as CSS custom
|
|
313
|
+
properties. To rebrand, override any of them in a stylesheet loaded after the
|
|
314
|
+
gem's:
|
|
315
|
+
|
|
316
|
+
```css
|
|
317
|
+
:root {
|
|
318
|
+
--lsa-tdx-feedback-accent: #6d28d9; /* your brand color */
|
|
319
|
+
--lsa-tdx-feedback-accent-hover: #5b21b6;
|
|
320
|
+
--lsa-tdx-feedback-surface: #ffffff;
|
|
321
|
+
/* full list in app/assets/stylesheets/lsa_tdx_feedback.css */
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A `prefers-color-scheme: dark` variant is built in, and the modal's field colors
|
|
326
|
+
are pinned so a theme-aware host app can't bleed low-contrast text into them.
|
|
327
|
+
|
|
241
328
|
## Rails 8 Authentication Setup
|
|
242
329
|
|
|
243
330
|
If you're using Rails 8.1.1's built-in authentication system, here's how to set it up and create a `current_user` helper method similar to Devise.
|
|
@@ -304,22 +391,19 @@ This makes `current_user` available in all controllers and views, including Rail
|
|
|
304
391
|
|
|
305
392
|
### Step 3A: Create an initializer (config/initializers/lsa_tdx_feedback.rb) that skips authentication for the feedback controller, since feedback forms should be public
|
|
306
393
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
LsaTdxFeedback::FeedbackController.class_eval do
|
|
313
|
-
# Skip authentication for feedback submissions
|
|
314
|
-
# Use raise: false to prevent errors if the before_action doesn't exist
|
|
315
|
-
skip_before_action :require_authentication, raise: false
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
rescue => e
|
|
319
|
-
Rails.logger.warn("Could not configure LsaTdxFeedback: #{e.message}") if defined?(Rails.logger)
|
|
320
|
-
end
|
|
394
|
+
`LsaTdxFeedback::FeedbackController` inherits from the host app's `ApplicationController`. If that controller runs a global authentication `before_action`, anonymous submissions are rejected before the action executes, producing a `401 Unauthorized` on submit:
|
|
395
|
+
|
|
396
|
+
```text
|
|
397
|
+
Processing by LsaTdxFeedback::FeedbackController#create as */*
|
|
398
|
+
Completed 401 Unauthorized in 8ms (ActiveRecord: 0.0ms)
|
|
321
399
|
```
|
|
322
400
|
|
|
401
|
+
The 0‑query, sub‑10ms completion is the tell: the request was blocked by an authentication callback, not by the TDX API. Skip that callback for the feedback controller only.
|
|
402
|
+
|
|
403
|
+
#### Rails 8 built-in authentication
|
|
404
|
+
|
|
405
|
+
The Rails 8 generator names the callback `:require_authentication`:
|
|
406
|
+
|
|
323
407
|
and update request_authentication in the Authentication concern ( app/controllers/concerns/authentication.rb ) to use main_app.new_session_path so it works when called from engine controllers.
|
|
324
408
|
|
|
325
409
|
```ruby
|
|
@@ -329,6 +413,32 @@ and update request_authentication in the Authentication concern ( app/controller
|
|
|
329
413
|
...
|
|
330
414
|
```
|
|
331
415
|
|
|
416
|
+
#### Devise
|
|
417
|
+
|
|
418
|
+
Devise apps use `:authenticate_user!`. Skip that callback instead:
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
# config/initializers/lsa_tdx_feedback.rb
|
|
422
|
+
# Allow anonymous (signed-out) visitors to submit feedback.
|
|
423
|
+
# The feedback modal is rendered on public pages, so skipping the host app's
|
|
424
|
+
# Devise authentication callback prevents 401 Unauthorized responses on submit.
|
|
425
|
+
Rails.application.config.to_prepare do
|
|
426
|
+
LsaTdxFeedback::FeedbackController.class_eval do
|
|
427
|
+
skip_before_action :authenticate_user!, raise: false
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
For both setups, `to_prepare` re‑applies the change across code reloads in development, and `raise: false` keeps the app booting even if the callback name differs or changes in a future release.
|
|
433
|
+
|
|
434
|
+
#### The email field is informational, not an identity
|
|
435
|
+
|
|
436
|
+
The form requires an email **client‑side** before it allows submission, so the controller always receives a contact address. That value is **not authoritative** — any email can be entered, and it is recorded on the TDX ticket as‑is. For signed‑in users the email is auto‑populated for convenience (via `current_user_email_for_feedback`), but the authorization decision does **not** depend on it.
|
|
437
|
+
|
|
438
|
+
#### What still protects the endpoint
|
|
439
|
+
|
|
440
|
+
- **CSRF protection remains in effect.** The controller keeps `protect_from_forgery with: :exception`, so submissions must originate from your rendered pages with a valid CSRF token. This is the primary guard against scripted/anonymous abuse now that authentication is skipped.
|
|
441
|
+
- **No server‑side email validation.** The controller permits `:email` and passes it straight through to the TDX ticket; it does **not** validate presence or format (that enforcement lives only in the browser). If you want a backend safety net so a crafted request can't create a ticket with a blank or malformed email, add that validation in the gem or in a controller override.
|
|
332
442
|
|
|
333
443
|
### Step 4: Protect Actions (Optional)
|
|
334
444
|
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
triggerClick: null,
|
|
14
14
|
closeClick: null,
|
|
15
15
|
cancelClick: null,
|
|
16
|
-
|
|
16
|
+
dialogClick: null,
|
|
17
17
|
formSubmit: null,
|
|
18
|
-
|
|
18
|
+
dialogClose: null
|
|
19
19
|
},
|
|
20
20
|
|
|
21
21
|
init: function() {
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
if (this.modal) {
|
|
54
54
|
var closeBtn = this.modal.querySelector('.lsa-tdx-feedback-close-btn');
|
|
55
55
|
var cancelBtn = this.modal.querySelector('.lsa-tdx-feedback-cancel-btn');
|
|
56
|
-
var backdrop = this.modal.querySelector('.lsa-tdx-feedback-modal-backdrop');
|
|
57
56
|
|
|
58
57
|
if (closeBtn && this.handlers.closeClick) {
|
|
59
58
|
closeBtn.removeEventListener('click', this.handlers.closeClick);
|
|
@@ -61,8 +60,11 @@
|
|
|
61
60
|
if (cancelBtn && this.handlers.cancelClick) {
|
|
62
61
|
cancelBtn.removeEventListener('click', this.handlers.cancelClick);
|
|
63
62
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
63
|
+
if (this.handlers.dialogClick) {
|
|
64
|
+
this.modal.removeEventListener('click', this.handlers.dialogClick);
|
|
65
|
+
}
|
|
66
|
+
if (this.handlers.dialogClose) {
|
|
67
|
+
this.modal.removeEventListener('close', this.handlers.dialogClose);
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -70,18 +72,14 @@
|
|
|
70
72
|
this.form.removeEventListener('submit', this.handlers.formSubmit);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
if (this.handlers.escapeKey) {
|
|
74
|
-
document.removeEventListener('keydown', this.handlers.escapeKey);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
75
|
// Clear handler references
|
|
78
76
|
this.handlers = {
|
|
79
77
|
triggerClick: null,
|
|
80
78
|
closeClick: null,
|
|
81
79
|
cancelClick: null,
|
|
82
|
-
|
|
80
|
+
dialogClick: null,
|
|
83
81
|
formSubmit: null,
|
|
84
|
-
|
|
82
|
+
dialogClose: null
|
|
85
83
|
};
|
|
86
84
|
},
|
|
87
85
|
|
|
@@ -110,7 +108,6 @@
|
|
|
110
108
|
// Close buttons
|
|
111
109
|
var closeBtn = this.modal.querySelector('.lsa-tdx-feedback-close-btn');
|
|
112
110
|
var cancelBtn = this.modal.querySelector('.lsa-tdx-feedback-cancel-btn');
|
|
113
|
-
var backdrop = this.modal.querySelector('.lsa-tdx-feedback-modal-backdrop');
|
|
114
111
|
|
|
115
112
|
if (closeBtn) {
|
|
116
113
|
this.handlers.closeClick = function(e) {
|
|
@@ -128,14 +125,23 @@
|
|
|
128
125
|
cancelBtn.addEventListener('click', this.handlers.cancelClick);
|
|
129
126
|
}
|
|
130
127
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
128
|
+
// Backdrop click: on a native <dialog>, a click whose target is the
|
|
129
|
+
// dialog element itself (not its content) lands on the ::backdrop.
|
|
130
|
+
this.handlers.dialogClick = function(e) {
|
|
131
|
+
if (e.target === self.modal) {
|
|
132
|
+
self.hideModal();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
this.modal.addEventListener('click', this.handlers.dialogClick);
|
|
136
|
+
|
|
137
|
+
// Runs on every close — button, backdrop, or the dialog's native Escape
|
|
138
|
+
// handling — so cleanup happens once, wherever the close came from.
|
|
139
|
+
this.handlers.dialogClose = function() {
|
|
140
|
+
document.body.style.overflow = self._previousBodyOverflow || '';
|
|
141
|
+
self.resetForm();
|
|
142
|
+
self.hideMessage();
|
|
143
|
+
};
|
|
144
|
+
this.modal.addEventListener('close', this.handlers.dialogClose);
|
|
139
145
|
|
|
140
146
|
// Form submission
|
|
141
147
|
this.handlers.formSubmit = function(e) {
|
|
@@ -143,20 +149,23 @@
|
|
|
143
149
|
self.submitFeedback();
|
|
144
150
|
};
|
|
145
151
|
this.form.addEventListener('submit', this.handlers.formSubmit);
|
|
146
|
-
|
|
147
|
-
// Escape key to close modal
|
|
148
|
-
this.handlers.escapeKey = function(e) {
|
|
149
|
-
if (e.key === 'Escape' && self.modal && self.modal.style.display !== 'none') {
|
|
150
|
-
self.hideModal();
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
document.addEventListener('keydown', this.handlers.escapeKey);
|
|
154
152
|
},
|
|
155
153
|
|
|
156
154
|
showModal: function() {
|
|
157
155
|
if (!this.modal) return;
|
|
158
156
|
|
|
159
|
-
|
|
157
|
+
// Native modal dialog: top layer + page inert + built-in focus trap.
|
|
158
|
+
if (typeof this.modal.showModal === 'function') {
|
|
159
|
+
this.modal.showModal();
|
|
160
|
+
} else {
|
|
161
|
+
// Older browsers without <dialog>: fall back to a visible dialog.
|
|
162
|
+
this.modal.setAttribute('open', '');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Save the host's body overflow so we can RESTORE it on close rather
|
|
166
|
+
// than blindly clearing it (which would clobber a scroll lock another
|
|
167
|
+
// component may have set).
|
|
168
|
+
this._previousBodyOverflow = document.body.style.overflow;
|
|
160
169
|
document.body.style.overflow = 'hidden';
|
|
161
170
|
|
|
162
171
|
// Focus on first input
|
|
@@ -173,10 +182,13 @@
|
|
|
173
182
|
hideModal: function() {
|
|
174
183
|
if (!this.modal) return;
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
// The 'close' event handler restores body scroll + resets the form.
|
|
186
|
+
if (typeof this.modal.close === 'function') {
|
|
187
|
+
this.modal.close();
|
|
188
|
+
} else {
|
|
189
|
+
this.modal.removeAttribute('open');
|
|
190
|
+
if (this.handlers.dialogClose) this.handlers.dialogClose();
|
|
191
|
+
}
|
|
180
192
|
},
|
|
181
193
|
|
|
182
194
|
resetForm: function() {
|
|
@@ -1,37 +1,100 @@
|
|
|
1
1
|
/* LsaTdxFeedback Styles - Self-contained and namespaced */
|
|
2
2
|
|
|
3
|
+
/*
|
|
4
|
+
* Themeable tokens. Override any of these in your own stylesheet (loaded after
|
|
5
|
+
* this one) to rebrand the modal without copying it. Defaults target WCAG 2.2
|
|
6
|
+
* AAA: 7:1 text contrast and 44px minimum touch targets, in light and dark.
|
|
7
|
+
*/
|
|
8
|
+
:root {
|
|
9
|
+
--lsa-tdx-feedback-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
10
|
+
|
|
11
|
+
--lsa-tdx-feedback-surface: #ffffff;
|
|
12
|
+
--lsa-tdx-feedback-text: #1a1a1a;
|
|
13
|
+
--lsa-tdx-feedback-text-muted: #33383f; /* >= 7:1 on the surface */
|
|
14
|
+
--lsa-tdx-feedback-border: #ced4da;
|
|
15
|
+
|
|
16
|
+
--lsa-tdx-feedback-field-bg: #ffffff;
|
|
17
|
+
--lsa-tdx-feedback-field-text: #1a1a1a;
|
|
18
|
+
--lsa-tdx-feedback-placeholder: #4b5563; /* >= 7:1 on the field */
|
|
19
|
+
|
|
20
|
+
--lsa-tdx-feedback-accent: #1e3a8a; /* white text on this is >= 7:1 */
|
|
21
|
+
--lsa-tdx-feedback-accent-hover: #172e6e;
|
|
22
|
+
--lsa-tdx-feedback-on-accent: #ffffff;
|
|
23
|
+
--lsa-tdx-feedback-focus-ring: rgba(30, 58, 138, 0.35);
|
|
24
|
+
|
|
25
|
+
--lsa-tdx-feedback-backdrop: rgba(0, 0, 0, 0.5);
|
|
26
|
+
--lsa-tdx-feedback-target-min: 44px; /* WCAG 2.5.5 */
|
|
27
|
+
|
|
28
|
+
--lsa-tdx-feedback-success-bg: #d4edda;
|
|
29
|
+
--lsa-tdx-feedback-success-text: #0f5132; /* >= 7:1 on success-bg */
|
|
30
|
+
--lsa-tdx-feedback-success-border: #badbcc;
|
|
31
|
+
--lsa-tdx-feedback-error-bg: #f8d7da;
|
|
32
|
+
--lsa-tdx-feedback-error-text: #842029; /* >= 7:1 on error-bg */
|
|
33
|
+
--lsa-tdx-feedback-error-border: #f5c2c7;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@media (prefers-color-scheme: dark) {
|
|
37
|
+
:root {
|
|
38
|
+
--lsa-tdx-feedback-surface: #1e232b;
|
|
39
|
+
--lsa-tdx-feedback-text: #f1f5f9;
|
|
40
|
+
--lsa-tdx-feedback-text-muted: #c4ccd6;
|
|
41
|
+
--lsa-tdx-feedback-border: #333b45;
|
|
42
|
+
|
|
43
|
+
--lsa-tdx-feedback-field-bg: #12161c;
|
|
44
|
+
--lsa-tdx-feedback-field-text: #f1f5f9;
|
|
45
|
+
--lsa-tdx-feedback-placeholder: #b3bcc9;
|
|
46
|
+
|
|
47
|
+
/* accent + white text stay AAA on the dark surface */
|
|
48
|
+
|
|
49
|
+
--lsa-tdx-feedback-success-bg: #0f2e1d;
|
|
50
|
+
--lsa-tdx-feedback-success-text: #a7f3d0;
|
|
51
|
+
--lsa-tdx-feedback-success-border: #1c4a30;
|
|
52
|
+
--lsa-tdx-feedback-error-bg: #3b1418;
|
|
53
|
+
--lsa-tdx-feedback-error-text: #fecaca;
|
|
54
|
+
--lsa-tdx-feedback-error-border: #5b1d23;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
3
58
|
/* Trigger Button */
|
|
4
59
|
.lsa-tdx-feedback-trigger-btn {
|
|
5
60
|
position: fixed;
|
|
6
61
|
bottom: 20px;
|
|
7
62
|
right: 20px;
|
|
8
|
-
|
|
9
|
-
|
|
63
|
+
min-width: var(--lsa-tdx-feedback-target-min);
|
|
64
|
+
min-height: var(--lsa-tdx-feedback-target-min);
|
|
65
|
+
background: var(--lsa-tdx-feedback-accent);
|
|
66
|
+
color: var(--lsa-tdx-feedback-on-accent);
|
|
10
67
|
border: none;
|
|
11
68
|
border-radius: 25px;
|
|
12
69
|
padding: 12px 20px;
|
|
13
70
|
font-size: 14px;
|
|
14
71
|
font-weight: 600;
|
|
15
|
-
font-family: -
|
|
72
|
+
font-family: var(--lsa-tdx-feedback-font);
|
|
16
73
|
cursor: pointer;
|
|
17
|
-
box-shadow: 0 4px 12px rgba(0,
|
|
74
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
|
18
75
|
transition: all 0.2s ease;
|
|
19
76
|
z-index: 9998;
|
|
20
77
|
display: flex;
|
|
21
78
|
align-items: center;
|
|
79
|
+
justify-content: center;
|
|
22
80
|
gap: 8px;
|
|
23
81
|
}
|
|
24
82
|
|
|
25
83
|
.lsa-tdx-feedback-trigger-btn:hover {
|
|
26
|
-
background:
|
|
84
|
+
background: var(--lsa-tdx-feedback-accent-hover);
|
|
27
85
|
transform: translateY(-2px);
|
|
28
|
-
box-shadow: 0 6px 20px rgba(0,
|
|
86
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
|
29
87
|
}
|
|
30
88
|
|
|
31
89
|
.lsa-tdx-feedback-trigger-btn:active {
|
|
32
90
|
transform: translateY(0);
|
|
33
91
|
}
|
|
34
92
|
|
|
93
|
+
.lsa-tdx-feedback-trigger-btn:focus-visible {
|
|
94
|
+
outline: 3px solid var(--lsa-tdx-feedback-accent);
|
|
95
|
+
outline-offset: 2px;
|
|
96
|
+
}
|
|
97
|
+
|
|
35
98
|
.lsa-tdx-feedback-trigger-text {
|
|
36
99
|
display: inline;
|
|
37
100
|
}
|
|
@@ -44,45 +107,41 @@
|
|
|
44
107
|
font-size: 13px;
|
|
45
108
|
}
|
|
46
109
|
|
|
110
|
+
/* min-width/min-height keep the 44px floor even with the label hidden */
|
|
47
111
|
.lsa-tdx-feedback-trigger-text {
|
|
48
112
|
display: none;
|
|
49
113
|
}
|
|
50
114
|
}
|
|
51
115
|
|
|
52
|
-
/*
|
|
116
|
+
/*
|
|
117
|
+
* Modal — a native <dialog>. The browser promotes it to the top layer (renders
|
|
118
|
+
* above all page content, no z-index needed) and makes the rest of the page
|
|
119
|
+
* inert. The dialog element itself is the visible panel; the backdrop is the
|
|
120
|
+
* ::backdrop pseudo-element.
|
|
121
|
+
*/
|
|
53
122
|
.lsa-tdx-feedback-modal {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
left: 0;
|
|
57
|
-
width: 100%;
|
|
58
|
-
height: 100%;
|
|
59
|
-
z-index: 9999;
|
|
60
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
.lsa-tdx-feedback-modal-backdrop {
|
|
64
|
-
position: absolute;
|
|
65
|
-
top: 0;
|
|
66
|
-
left: 0;
|
|
67
|
-
width: 100%;
|
|
68
|
-
height: 100%;
|
|
69
|
-
background: rgba(0, 0, 0, 0.5);
|
|
70
|
-
backdrop-filter: blur(4px);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.lsa-tdx-feedback-modal-content {
|
|
74
|
-
position: relative;
|
|
75
|
-
background: white;
|
|
123
|
+
padding: 0;
|
|
124
|
+
border: none;
|
|
76
125
|
border-radius: 12px;
|
|
77
|
-
|
|
126
|
+
background: var(--lsa-tdx-feedback-surface);
|
|
127
|
+
color: var(--lsa-tdx-feedback-text);
|
|
128
|
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
|
78
129
|
max-width: 500px;
|
|
79
130
|
width: 90%;
|
|
80
131
|
max-height: 90vh;
|
|
81
132
|
overflow-y: auto;
|
|
82
|
-
|
|
133
|
+
font-family: var(--lsa-tdx-feedback-font);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.lsa-tdx-feedback-modal[open] {
|
|
83
137
|
animation: lsa-tdx-feedback-modal-enter 0.3s ease-out;
|
|
84
138
|
}
|
|
85
139
|
|
|
140
|
+
.lsa-tdx-feedback-modal::backdrop {
|
|
141
|
+
background: var(--lsa-tdx-feedback-backdrop);
|
|
142
|
+
backdrop-filter: blur(4px);
|
|
143
|
+
}
|
|
144
|
+
|
|
86
145
|
@keyframes lsa-tdx-feedback-modal-enter {
|
|
87
146
|
from {
|
|
88
147
|
opacity: 0;
|
|
@@ -94,9 +153,20 @@
|
|
|
94
153
|
}
|
|
95
154
|
}
|
|
96
155
|
|
|
156
|
+
@media (prefers-reduced-motion: reduce) {
|
|
157
|
+
.lsa-tdx-feedback-modal[open] {
|
|
158
|
+
animation: none;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Content is now a plain wrapper — the dialog element carries the panel look. */
|
|
163
|
+
.lsa-tdx-feedback-modal-content {
|
|
164
|
+
position: relative;
|
|
165
|
+
}
|
|
166
|
+
|
|
97
167
|
.lsa-tdx-feedback-modal-header {
|
|
98
168
|
padding: 24px 24px 16px;
|
|
99
|
-
border-bottom: 1px solid
|
|
169
|
+
border-bottom: 1px solid var(--lsa-tdx-feedback-border);
|
|
100
170
|
display: flex;
|
|
101
171
|
justify-content: space-between;
|
|
102
172
|
align-items: center;
|
|
@@ -106,18 +176,19 @@
|
|
|
106
176
|
margin: 0;
|
|
107
177
|
font-size: 20px;
|
|
108
178
|
font-weight: 600;
|
|
109
|
-
color:
|
|
179
|
+
color: var(--lsa-tdx-feedback-text);
|
|
110
180
|
}
|
|
111
181
|
|
|
112
182
|
.lsa-tdx-feedback-close-btn {
|
|
113
183
|
background: none;
|
|
114
184
|
border: none;
|
|
115
185
|
font-size: 24px;
|
|
116
|
-
|
|
186
|
+
line-height: 1;
|
|
187
|
+
color: var(--lsa-tdx-feedback-text-muted);
|
|
117
188
|
cursor: pointer;
|
|
118
189
|
padding: 0;
|
|
119
|
-
width:
|
|
120
|
-
height:
|
|
190
|
+
min-width: var(--lsa-tdx-feedback-target-min);
|
|
191
|
+
min-height: var(--lsa-tdx-feedback-target-min);
|
|
121
192
|
display: flex;
|
|
122
193
|
align-items: center;
|
|
123
194
|
justify-content: center;
|
|
@@ -126,8 +197,13 @@
|
|
|
126
197
|
}
|
|
127
198
|
|
|
128
199
|
.lsa-tdx-feedback-close-btn:hover {
|
|
129
|
-
background:
|
|
130
|
-
color:
|
|
200
|
+
background: var(--lsa-tdx-feedback-border);
|
|
201
|
+
color: var(--lsa-tdx-feedback-text);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.lsa-tdx-feedback-close-btn:focus-visible {
|
|
205
|
+
outline: 3px solid var(--lsa-tdx-feedback-accent);
|
|
206
|
+
outline-offset: 2px;
|
|
131
207
|
}
|
|
132
208
|
|
|
133
209
|
.lsa-tdx-feedback-modal-body {
|
|
@@ -142,7 +218,7 @@
|
|
|
142
218
|
display: block;
|
|
143
219
|
margin-bottom: 6px;
|
|
144
220
|
font-weight: 500;
|
|
145
|
-
color:
|
|
221
|
+
color: var(--lsa-tdx-feedback-text);
|
|
146
222
|
font-size: 14px;
|
|
147
223
|
}
|
|
148
224
|
|
|
@@ -151,7 +227,12 @@
|
|
|
151
227
|
.lsa-tdx-feedback-form-group textarea {
|
|
152
228
|
width: 100%;
|
|
153
229
|
padding: 10px 12px;
|
|
154
|
-
|
|
230
|
+
min-height: var(--lsa-tdx-feedback-target-min);
|
|
231
|
+
/* Pin field colors so a theme-aware host app's global form styles can't
|
|
232
|
+
bleed light-on-light (or dark-on-dark) into the modal. */
|
|
233
|
+
background-color: var(--lsa-tdx-feedback-field-bg);
|
|
234
|
+
color: var(--lsa-tdx-feedback-field-text);
|
|
235
|
+
border: 1px solid var(--lsa-tdx-feedback-border);
|
|
155
236
|
border-radius: 6px;
|
|
156
237
|
font-size: 14px;
|
|
157
238
|
font-family: inherit;
|
|
@@ -159,12 +240,17 @@
|
|
|
159
240
|
box-sizing: border-box;
|
|
160
241
|
}
|
|
161
242
|
|
|
243
|
+
.lsa-tdx-feedback-form-group input::placeholder,
|
|
244
|
+
.lsa-tdx-feedback-form-group textarea::placeholder {
|
|
245
|
+
color: var(--lsa-tdx-feedback-placeholder);
|
|
246
|
+
}
|
|
247
|
+
|
|
162
248
|
.lsa-tdx-feedback-form-group input:focus,
|
|
163
249
|
.lsa-tdx-feedback-form-group select:focus,
|
|
164
250
|
.lsa-tdx-feedback-form-group textarea:focus {
|
|
165
251
|
outline: none;
|
|
166
|
-
border-color:
|
|
167
|
-
box-shadow: 0 0 0 3px
|
|
252
|
+
border-color: var(--lsa-tdx-feedback-accent);
|
|
253
|
+
box-shadow: 0 0 0 3px var(--lsa-tdx-feedback-focus-ring);
|
|
168
254
|
}
|
|
169
255
|
|
|
170
256
|
.lsa-tdx-feedback-form-group textarea {
|
|
@@ -176,7 +262,7 @@
|
|
|
176
262
|
display: block;
|
|
177
263
|
margin-top: 4px;
|
|
178
264
|
font-size: 12px;
|
|
179
|
-
color:
|
|
265
|
+
color: var(--lsa-tdx-feedback-text-muted);
|
|
180
266
|
}
|
|
181
267
|
|
|
182
268
|
.lsa-tdx-feedback-modal-footer {
|
|
@@ -188,6 +274,7 @@
|
|
|
188
274
|
|
|
189
275
|
.lsa-tdx-feedback-btn {
|
|
190
276
|
padding: 10px 20px;
|
|
277
|
+
min-height: var(--lsa-tdx-feedback-target-min);
|
|
191
278
|
border-radius: 6px;
|
|
192
279
|
font-size: 14px;
|
|
193
280
|
font-weight: 500;
|
|
@@ -196,33 +283,39 @@
|
|
|
196
283
|
border: 1px solid transparent;
|
|
197
284
|
display: inline-flex;
|
|
198
285
|
align-items: center;
|
|
286
|
+
justify-content: center;
|
|
199
287
|
gap: 6px;
|
|
200
288
|
font-family: inherit;
|
|
201
289
|
}
|
|
202
290
|
|
|
291
|
+
.lsa-tdx-feedback-btn:focus-visible {
|
|
292
|
+
outline: 3px solid var(--lsa-tdx-feedback-accent);
|
|
293
|
+
outline-offset: 2px;
|
|
294
|
+
}
|
|
295
|
+
|
|
203
296
|
.lsa-tdx-feedback-btn-primary {
|
|
204
|
-
background:
|
|
205
|
-
color:
|
|
297
|
+
background: var(--lsa-tdx-feedback-accent);
|
|
298
|
+
color: var(--lsa-tdx-feedback-on-accent);
|
|
206
299
|
}
|
|
207
300
|
|
|
208
301
|
.lsa-tdx-feedback-btn-primary:hover:not(:disabled) {
|
|
209
|
-
background:
|
|
302
|
+
background: var(--lsa-tdx-feedback-accent-hover);
|
|
210
303
|
}
|
|
211
304
|
|
|
212
305
|
.lsa-tdx-feedback-btn-primary:disabled {
|
|
213
|
-
|
|
306
|
+
opacity: 0.65;
|
|
214
307
|
cursor: not-allowed;
|
|
215
308
|
}
|
|
216
309
|
|
|
217
310
|
.lsa-tdx-feedback-btn-secondary {
|
|
218
|
-
background:
|
|
219
|
-
color:
|
|
220
|
-
border-color:
|
|
311
|
+
background: var(--lsa-tdx-feedback-surface);
|
|
312
|
+
color: var(--lsa-tdx-feedback-text-muted);
|
|
313
|
+
border-color: var(--lsa-tdx-feedback-border);
|
|
221
314
|
}
|
|
222
315
|
|
|
223
316
|
.lsa-tdx-feedback-btn-secondary:hover {
|
|
224
|
-
background:
|
|
225
|
-
color:
|
|
317
|
+
background: var(--lsa-tdx-feedback-border);
|
|
318
|
+
color: var(--lsa-tdx-feedback-text);
|
|
226
319
|
}
|
|
227
320
|
|
|
228
321
|
.lsa-tdx-feedback-loading-spinner {
|
|
@@ -234,6 +327,12 @@
|
|
|
234
327
|
to { transform: rotate(360deg); }
|
|
235
328
|
}
|
|
236
329
|
|
|
330
|
+
@media (prefers-reduced-motion: reduce) {
|
|
331
|
+
.lsa-tdx-feedback-loading-spinner {
|
|
332
|
+
animation-duration: 0s;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
237
336
|
/* Messages */
|
|
238
337
|
.lsa-tdx-feedback-message {
|
|
239
338
|
margin: 16px 24px 0;
|
|
@@ -243,23 +342,22 @@
|
|
|
243
342
|
}
|
|
244
343
|
|
|
245
344
|
.lsa-tdx-feedback-message.success {
|
|
246
|
-
background:
|
|
247
|
-
color:
|
|
248
|
-
border: 1px solid
|
|
345
|
+
background: var(--lsa-tdx-feedback-success-bg);
|
|
346
|
+
color: var(--lsa-tdx-feedback-success-text);
|
|
347
|
+
border: 1px solid var(--lsa-tdx-feedback-success-border);
|
|
249
348
|
}
|
|
250
349
|
|
|
251
350
|
.lsa-tdx-feedback-message.error {
|
|
252
|
-
background:
|
|
253
|
-
color:
|
|
254
|
-
border: 1px solid
|
|
351
|
+
background: var(--lsa-tdx-feedback-error-bg);
|
|
352
|
+
color: var(--lsa-tdx-feedback-error-text);
|
|
353
|
+
border: 1px solid var(--lsa-tdx-feedback-error-border);
|
|
255
354
|
}
|
|
256
355
|
|
|
257
356
|
/* Mobile Responsive */
|
|
258
357
|
@media (max-width: 768px) {
|
|
259
|
-
.lsa-tdx-feedback-modal
|
|
260
|
-
margin: 2vh auto;
|
|
261
|
-
max-height: 96vh;
|
|
358
|
+
.lsa-tdx-feedback-modal {
|
|
262
359
|
width: 95%;
|
|
360
|
+
max-height: 96vh;
|
|
263
361
|
}
|
|
264
362
|
|
|
265
363
|
.lsa-tdx-feedback-modal-header,
|
|
@@ -274,6 +372,5 @@
|
|
|
274
372
|
|
|
275
373
|
.lsa-tdx-feedback-btn {
|
|
276
374
|
width: 100%;
|
|
277
|
-
justify-content: center;
|
|
278
375
|
}
|
|
279
376
|
}
|
|
@@ -4,8 +4,29 @@ module LsaTdxFeedback
|
|
|
4
4
|
before_action :set_lsa_tdx_feedback_data
|
|
5
5
|
|
|
6
6
|
def create
|
|
7
|
-
|
|
7
|
+
feedback_data = build_feedback_data
|
|
8
8
|
|
|
9
|
+
# No fallback configured: preserve the original behavior exactly.
|
|
10
|
+
return create_via_tdx(feedback_data) if LsaTdxFeedback.configuration.fallback.nil?
|
|
11
|
+
|
|
12
|
+
# Fallback configured: file a TDX ticket when TDX is configured and the
|
|
13
|
+
# call succeeds; otherwise hand the feedback to the fallback so it's never
|
|
14
|
+
# lost (e.g. TDX creds absent, OAuth/API failure).
|
|
15
|
+
if LsaTdxFeedback.configuration.valid?
|
|
16
|
+
begin
|
|
17
|
+
ticket = TicketClient.new.create_feedback_ticket(feedback_data)
|
|
18
|
+
return render_ticket_created(ticket)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
Rails.logger.error "LsaTdxFeedback: ticket failed (#{e.class}: #{e.message}); using fallback"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
deliver_via_fallback(feedback_data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_feedback_data
|
|
9
30
|
# Build the title and include the app name if available
|
|
10
31
|
title = feedback_params[:title].presence || "User Feedback - #{feedback_params[:category]}"
|
|
11
32
|
|
|
@@ -13,7 +34,7 @@ module LsaTdxFeedback
|
|
|
13
34
|
title = "[#{@lsa_tdx_feedback_app_name}]: #{title}"
|
|
14
35
|
end
|
|
15
36
|
|
|
16
|
-
|
|
37
|
+
{
|
|
17
38
|
title: title,
|
|
18
39
|
feedback: feedback_params[:feedback],
|
|
19
40
|
category: feedback_params[:category],
|
|
@@ -23,33 +44,51 @@ module LsaTdxFeedback
|
|
|
23
44
|
additional_info: feedback_params[:additional_info],
|
|
24
45
|
priority_id: priority_from_category(feedback_params[:category])
|
|
25
46
|
}
|
|
47
|
+
end
|
|
26
48
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
render json: {
|
|
46
|
-
success: false,
|
|
47
|
-
message: 'Sorry, there was an unexpected error. Please try again later.'
|
|
48
|
-
}, status: :internal_server_error
|
|
49
|
-
end
|
|
49
|
+
# The original create path, unchanged — used when no fallback is configured.
|
|
50
|
+
def create_via_tdx(feedback_data)
|
|
51
|
+
render_ticket_created(TicketClient.new.create_feedback_ticket(feedback_data))
|
|
52
|
+
rescue LsaTdxFeedback::Error => e
|
|
53
|
+
Rails.logger.error "LsaTdxFeedback Error: #{e.message}"
|
|
54
|
+
|
|
55
|
+
render json: {
|
|
56
|
+
success: false,
|
|
57
|
+
message: 'Sorry, there was an error submitting your feedback. Please try again later.'
|
|
58
|
+
}, status: :unprocessable_entity
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
Rails.logger.error "Unexpected error in LsaTdxFeedback: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
61
|
+
|
|
62
|
+
render json: {
|
|
63
|
+
success: false,
|
|
64
|
+
message: 'Sorry, there was an unexpected error. Please try again later.'
|
|
65
|
+
}, status: :internal_server_error
|
|
50
66
|
end
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
def render_ticket_created(ticket_response)
|
|
69
|
+
render json: {
|
|
70
|
+
success: true,
|
|
71
|
+
message: 'Thank you for your feedback! Your ticket has been created.',
|
|
72
|
+
ticket_id: ticket_response['ID']
|
|
73
|
+
}, status: :created
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def deliver_via_fallback(feedback_data)
|
|
77
|
+
LsaTdxFeedback.configuration.fallback.call(feedback_data)
|
|
78
|
+
|
|
79
|
+
render json: {
|
|
80
|
+
success: true,
|
|
81
|
+
message: 'Thank you for your feedback!'
|
|
82
|
+
}, status: :created
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
# A failing fallback must not surface as a 500 — report a retryable error.
|
|
85
|
+
Rails.logger.error "LsaTdxFeedback: fallback delivery failed (#{e.class}: #{e.message})"
|
|
86
|
+
|
|
87
|
+
render json: {
|
|
88
|
+
success: false,
|
|
89
|
+
message: 'Sorry, there was an error submitting your feedback. Please try again later.'
|
|
90
|
+
}, status: :unprocessable_entity
|
|
91
|
+
end
|
|
53
92
|
|
|
54
93
|
def feedback_params
|
|
55
94
|
params.require(:feedback).permit(:title, :feedback, :category, :email, :url, :user_agent, :additional_info)
|
|
@@ -1,59 +1,62 @@
|
|
|
1
1
|
<!-- LsaTdxFeedback Modal -->
|
|
2
|
-
<
|
|
3
|
-
|
|
2
|
+
<%# Native <dialog>: the browser promotes it to the top layer (renders above all
|
|
3
|
+
page content with no z-index needed) and makes the rest of the page inert,
|
|
4
|
+
so it can't be covered by, or fight, host chrome. The backdrop is the
|
|
5
|
+
::backdrop pseudo-element (no manual backdrop div). %>
|
|
6
|
+
<dialog id="lsa-tdx-feedback-modal" class="lsa-tdx-feedback-modal" aria-labelledby="lsa-tdx-feedback-title">
|
|
4
7
|
<div class="lsa-tdx-feedback-modal-content">
|
|
5
8
|
<div class="lsa-tdx-feedback-modal-header">
|
|
6
|
-
<h2>
|
|
7
|
-
<%= "[#{@lsa_tdx_feedback_app_name}]: " if defined?(@lsa_tdx_feedback_app_name) && @lsa_tdx_feedback_app_name.present? %>
|
|
9
|
+
<h2 id="lsa-tdx-feedback-title">
|
|
10
|
+
<%= "[#{@lsa_tdx_feedback_app_name}]: " if defined?(@lsa_tdx_feedback_app_name) && @lsa_tdx_feedback_app_name.present? %><%= t('lsa_tdx_feedback.modal.title') %>
|
|
8
11
|
</h2>
|
|
9
|
-
<button type="button" class="lsa-tdx-feedback-close-btn" aria-label="
|
|
12
|
+
<button type="button" class="lsa-tdx-feedback-close-btn" aria-label="<%= t('lsa_tdx_feedback.modal.close') %>">×</button>
|
|
10
13
|
</div>
|
|
11
14
|
|
|
12
15
|
<div class="lsa-tdx-feedback-modal-body">
|
|
13
16
|
<form id="lsa-tdx-feedback-form">
|
|
14
17
|
<div class="lsa-tdx-feedback-form-group">
|
|
15
|
-
<label for="lsa-tdx-feedback-category"
|
|
18
|
+
<label for="lsa-tdx-feedback-category"><%= t('lsa_tdx_feedback.modal.category_label') %></label>
|
|
16
19
|
<select id="lsa-tdx-feedback-category" name="category" required>
|
|
17
|
-
<option value=""
|
|
18
|
-
<option value="bug"
|
|
19
|
-
<option value="suggestion"
|
|
20
|
-
<option value="feature"
|
|
21
|
-
<option value="general"
|
|
22
|
-
<option value="other"
|
|
20
|
+
<option value=""><%= t('lsa_tdx_feedback.modal.category_prompt') %></option>
|
|
21
|
+
<option value="bug"><%= t('lsa_tdx_feedback.modal.categories.bug') %></option>
|
|
22
|
+
<option value="suggestion"><%= t('lsa_tdx_feedback.modal.categories.suggestion') %></option>
|
|
23
|
+
<option value="feature"><%= t('lsa_tdx_feedback.modal.categories.feature') %></option>
|
|
24
|
+
<option value="general"><%= t('lsa_tdx_feedback.modal.categories.general') %></option>
|
|
25
|
+
<option value="other"><%= t('lsa_tdx_feedback.modal.categories.other') %></option>
|
|
23
26
|
</select>
|
|
24
27
|
</div>
|
|
25
28
|
|
|
26
29
|
<div class="lsa-tdx-feedback-form-group">
|
|
27
|
-
<label for="lsa-tdx-feedback-feedback"
|
|
30
|
+
<label for="lsa-tdx-feedback-feedback"><%= t('lsa_tdx_feedback.modal.feedback_label') %> *</label>
|
|
28
31
|
<textarea
|
|
29
32
|
id="lsa-tdx-feedback-feedback"
|
|
30
33
|
name="feedback"
|
|
31
34
|
required
|
|
32
35
|
rows="5"
|
|
33
|
-
placeholder="
|
|
36
|
+
placeholder="<%= t('lsa_tdx_feedback.modal.feedback_placeholder') %>"
|
|
34
37
|
></textarea>
|
|
35
38
|
</div>
|
|
36
39
|
|
|
37
40
|
<div class="lsa-tdx-feedback-form-group">
|
|
38
|
-
<label for="lsa-tdx-feedback-email"
|
|
41
|
+
<label for="lsa-tdx-feedback-email"><%= t('lsa_tdx_feedback.modal.email_label') %> *</label>
|
|
39
42
|
<input
|
|
40
43
|
type="email"
|
|
41
44
|
id="lsa-tdx-feedback-email"
|
|
42
45
|
name="email"
|
|
43
|
-
placeholder="
|
|
46
|
+
placeholder="<%= t('lsa_tdx_feedback.modal.email_placeholder') %>"
|
|
44
47
|
required
|
|
45
48
|
value="<%= @lsa_tdx_feedback_user_email if defined?(@lsa_tdx_feedback_user_email) && @lsa_tdx_feedback_user_email.present? %>"
|
|
46
49
|
>
|
|
47
|
-
<small class="lsa-tdx-feedback-help-text"
|
|
50
|
+
<small class="lsa-tdx-feedback-help-text"><%= t('lsa_tdx_feedback.modal.email_help') %></small>
|
|
48
51
|
</div>
|
|
49
52
|
|
|
50
53
|
<div class="lsa-tdx-feedback-form-group">
|
|
51
|
-
<label for="lsa-tdx-feedback-additional-info"
|
|
54
|
+
<label for="lsa-tdx-feedback-additional-info"><%= t('lsa_tdx_feedback.modal.additional_info_label') %></label>
|
|
52
55
|
<textarea
|
|
53
56
|
id="lsa-tdx-feedback-additional-info"
|
|
54
57
|
name="additional_info"
|
|
55
58
|
rows="3"
|
|
56
|
-
placeholder="
|
|
59
|
+
placeholder="<%= t('lsa_tdx_feedback.modal.additional_info_placeholder') %>"
|
|
57
60
|
></textarea>
|
|
58
61
|
</div>
|
|
59
62
|
|
|
@@ -65,16 +68,16 @@
|
|
|
65
68
|
|
|
66
69
|
<div class="lsa-tdx-feedback-modal-footer">
|
|
67
70
|
<button type="button" class="lsa-tdx-feedback-btn lsa-tdx-feedback-btn-secondary lsa-tdx-feedback-cancel-btn">
|
|
68
|
-
|
|
71
|
+
<%= t('lsa_tdx_feedback.modal.cancel') %>
|
|
69
72
|
</button>
|
|
70
73
|
<button type="submit" form="lsa-tdx-feedback-form" class="lsa-tdx-feedback-btn lsa-tdx-feedback-btn-primary lsa-tdx-feedback-submit-btn">
|
|
71
|
-
<span class="lsa-tdx-feedback-btn-text"
|
|
74
|
+
<span class="lsa-tdx-feedback-btn-text"><%= t('lsa_tdx_feedback.modal.submit') %></span>
|
|
72
75
|
<span class="lsa-tdx-feedback-loading-spinner" style="display: none;">
|
|
73
76
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
74
77
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"/>
|
|
75
78
|
<path d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" fill="currentColor"/>
|
|
76
79
|
</svg>
|
|
77
|
-
|
|
80
|
+
<%= t('lsa_tdx_feedback.modal.sending') %>
|
|
78
81
|
</span>
|
|
79
82
|
</button>
|
|
80
83
|
</div>
|
|
@@ -84,12 +87,17 @@
|
|
|
84
87
|
<div class="lsa-tdx-feedback-message-content"></div>
|
|
85
88
|
</div>
|
|
86
89
|
</div>
|
|
87
|
-
</
|
|
90
|
+
</dialog>
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<
|
|
95
|
-
|
|
92
|
+
<%# Floating trigger button. Rendered by default; pass trigger: false to
|
|
93
|
+
lsa_tdx_feedback_modal to omit it and open the modal from your own control
|
|
94
|
+
via window.LsaTdxFeedback.showModal(). Defaults to true for callers that
|
|
95
|
+
render the partial without the local (backward compatible). %>
|
|
96
|
+
<% if local_assigns.fetch(:trigger, true) %>
|
|
97
|
+
<button id="lsa-tdx-feedback-trigger" class="lsa-tdx-feedback-trigger-btn" title="<%= t('lsa_tdx_feedback.modal.title') %>">
|
|
98
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
99
|
+
<path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4l4 4 4-4h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/>
|
|
100
|
+
</svg>
|
|
101
|
+
<span class="lsa-tdx-feedback-trigger-text"><%= t('lsa_tdx_feedback.modal.trigger_label') %></span>
|
|
102
|
+
</button>
|
|
103
|
+
<% end %>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# All keys are scoped under `lsa_tdx_feedback.*` so they merge into a host
|
|
2
|
+
# app's I18n without colliding with its own keys. Rails auto-loads this file
|
|
3
|
+
# (Rails::Engine adds config/locales/**/*.yml to I18n.load_path). A host can
|
|
4
|
+
# translate to other locales, or override any string, by defining the same
|
|
5
|
+
# scoped keys in its own locale files (which load after the engine's, so they
|
|
6
|
+
# win).
|
|
7
|
+
en:
|
|
8
|
+
lsa_tdx_feedback:
|
|
9
|
+
modal:
|
|
10
|
+
title: "Send Feedback"
|
|
11
|
+
close: "Close"
|
|
12
|
+
category_label: "Category"
|
|
13
|
+
category_prompt: "Select a category"
|
|
14
|
+
categories:
|
|
15
|
+
bug: "Bug Report"
|
|
16
|
+
suggestion: "Suggestion"
|
|
17
|
+
feature: "Feature Request"
|
|
18
|
+
general: "General Feedback"
|
|
19
|
+
other: "Other"
|
|
20
|
+
feedback_label: "Your Feedback"
|
|
21
|
+
feedback_placeholder: "Please describe your feedback in detail..."
|
|
22
|
+
email_label: "Your Email"
|
|
23
|
+
email_placeholder: "your.email@example.com"
|
|
24
|
+
email_help: "Required - We'll use this to follow up on your feedback"
|
|
25
|
+
additional_info_label: "Additional Information"
|
|
26
|
+
additional_info_placeholder: "Any additional context or information..."
|
|
27
|
+
cancel: "Cancel"
|
|
28
|
+
submit: "Send Feedback"
|
|
29
|
+
sending: "Sending..."
|
|
30
|
+
trigger_label: "Feedback"
|
|
@@ -24,18 +24,9 @@ module LsaTdxFeedback
|
|
|
24
24
|
nil
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
Rails.logger.info "set_lsa_tdx_feedback_data called"
|
|
29
|
-
Rails.logger.info " respond_to? current_user_email_for_feedback: #{respond_to?(:current_user_email_for_feedback, true)}"
|
|
30
|
-
Rails.logger.info " current_user available: #{respond_to?(:current_user, true)}"
|
|
31
|
-
Rails.logger.info " current_user: #{current_user&.email || 'nil'}"
|
|
27
|
+
return unless respond_to?(:current_user_email_for_feedback, true)
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
@lsa_tdx_feedback_user_email = current_user_email_for_feedback
|
|
35
|
-
Rails.logger.info " @lsa_tdx_feedback_user_email set to: #{@lsa_tdx_feedback_user_email}"
|
|
36
|
-
else
|
|
37
|
-
Rails.logger.info " current_user_email_for_feedback method not available"
|
|
38
|
-
end
|
|
29
|
+
@lsa_tdx_feedback_user_email = current_user_email_for_feedback
|
|
39
30
|
end
|
|
40
31
|
|
|
41
32
|
# Override this method in your ApplicationController to provide user email
|
|
@@ -4,7 +4,15 @@ module LsaTdxFeedback
|
|
|
4
4
|
:default_type_id, :default_form_id, :default_classification,
|
|
5
5
|
:default_status_id, :default_priority_id, :default_responsible_group_id,
|
|
6
6
|
:default_service_id, :default_source_id, :service_offering_id, :account_id,
|
|
7
|
-
:cache_store, :cache_expiry
|
|
7
|
+
:cache_store, :cache_expiry,
|
|
8
|
+
# Optional callable invoked with the feedback data hash when a
|
|
9
|
+
# ticket can't be filed — TDX not configured, or the API call
|
|
10
|
+
# raises — so feedback is never lost. It receives the same
|
|
11
|
+
# feedback_data the ticket would have used (see
|
|
12
|
+
# FeedbackController#create) and is responsible for delivering
|
|
13
|
+
# it (e.g. emailing an admin). nil (default) preserves the
|
|
14
|
+
# original behavior: no fallback, controller returns an error.
|
|
15
|
+
:fallback
|
|
8
16
|
|
|
9
17
|
def initialize
|
|
10
18
|
# All values must be configured by the application for security
|
|
@@ -27,6 +35,9 @@ module LsaTdxFeedback
|
|
|
27
35
|
# Cache configuration
|
|
28
36
|
@cache_store = :redis_cache_store
|
|
29
37
|
@cache_expiry = 3600 # 1 hour for OAuth tokens
|
|
38
|
+
|
|
39
|
+
# Optional delivery fallback (see attr_accessor above)
|
|
40
|
+
@fallback = nil
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
def oauth_scope
|
|
@@ -5,9 +5,15 @@ module LsaTdxFeedback
|
|
|
5
5
|
"HELPER IS WORKING!".html_safe
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
# Renders the feedback modal and trigger button
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
# Renders the feedback modal and, by default, the floating trigger button.
|
|
9
|
+
#
|
|
10
|
+
# Pass trigger: false to render the modal WITHOUT the built-in floating
|
|
11
|
+
# button, and open it yourself from your own control (a nav link, a footer
|
|
12
|
+
# button, etc.) via `window.LsaTdxFeedback.showModal()`. Useful when the
|
|
13
|
+
# fixed bottom-right button collides with other fixed page chrome, or when
|
|
14
|
+
# you want feedback reachable from a specific place in your own UI.
|
|
15
|
+
def lsa_tdx_feedback_modal(trigger: true)
|
|
16
|
+
render(partial: 'lsa_tdx_feedback/shared/feedback_modal', locals: { trigger: trigger })
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
# Includes the feedback gem CSS
|
|
@@ -27,10 +33,11 @@ module LsaTdxFeedback
|
|
|
27
33
|
(css + js).html_safe
|
|
28
34
|
end
|
|
29
35
|
|
|
30
|
-
# All-in-one helper that includes both modal and assets
|
|
31
|
-
|
|
36
|
+
# All-in-one helper that includes both modal and assets.
|
|
37
|
+
# Passes trigger: through to lsa_tdx_feedback_modal (see there).
|
|
38
|
+
def lsa_tdx_feedback(trigger: true)
|
|
32
39
|
assets = lsa_tdx_feedback_assets
|
|
33
|
-
modal = lsa_tdx_feedback_modal
|
|
40
|
+
modal = lsa_tdx_feedback_modal(trigger: trigger)
|
|
34
41
|
(assets + modal).html_safe
|
|
35
42
|
end
|
|
36
43
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lsa_tdx_feedback
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LSA Rails Team
|
|
@@ -185,6 +185,7 @@ files:
|
|
|
185
185
|
- app/assets/stylesheets/lsa_tdx_feedback.css
|
|
186
186
|
- app/controllers/lsa_tdx_feedback/feedback_controller.rb
|
|
187
187
|
- app/views/lsa_tdx_feedback/shared/_feedback_modal.html.erb
|
|
188
|
+
- config/locales/en.yml
|
|
188
189
|
- config/routes.rb
|
|
189
190
|
- example_application_tests.rb
|
|
190
191
|
- lib/lsa_tdx_feedback.rb
|
|
@@ -216,7 +217,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
216
217
|
- !ruby/object:Gem::Version
|
|
217
218
|
version: '0'
|
|
218
219
|
requirements: []
|
|
219
|
-
rubygems_version:
|
|
220
|
+
rubygems_version: 4.0.12
|
|
220
221
|
specification_version: 4
|
|
221
222
|
summary: A self-contained Rails gem for collecting user feedback via TDX API for LSA
|
|
222
223
|
applications
|