feedback_engine 0.1.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +265 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/feedback_engine/widget.js +368 -0
  7. data/app/controllers/feedback_engine/application_controller.rb +15 -0
  8. data/app/controllers/feedback_engine/feedbacks_controller.rb +120 -0
  9. data/app/helpers/feedback_engine/widget_helper.rb +18 -0
  10. data/app/models/feedback_engine/application_record.rb +7 -0
  11. data/app/models/feedback_engine/feedback.rb +30 -0
  12. data/app/views/feedback_engine/feedbacks/index.html.erb +75 -0
  13. data/app/views/feedback_engine/feedbacks/show.html.erb +63 -0
  14. data/app/views/layouts/feedback_engine/application.html.erb +75 -0
  15. data/config/locales/feedback_engine.ar.yml +23 -0
  16. data/config/locales/feedback_engine.bg.yml +23 -0
  17. data/config/locales/feedback_engine.bn.yml +23 -0
  18. data/config/locales/feedback_engine.de.yml +23 -0
  19. data/config/locales/feedback_engine.el.yml +23 -0
  20. data/config/locales/feedback_engine.en.yml +43 -0
  21. data/config/locales/feedback_engine.es.yml +23 -0
  22. data/config/locales/feedback_engine.fr.yml +23 -0
  23. data/config/locales/feedback_engine.hi.yml +23 -0
  24. data/config/locales/feedback_engine.hr.yml +23 -0
  25. data/config/locales/feedback_engine.id.yml +23 -0
  26. data/config/locales/feedback_engine.it.yml +23 -0
  27. data/config/locales/feedback_engine.ja.yml +23 -0
  28. data/config/locales/feedback_engine.ko.yml +23 -0
  29. data/config/locales/feedback_engine.lb.yml +23 -0
  30. data/config/locales/feedback_engine.nl.yml +23 -0
  31. data/config/locales/feedback_engine.pl.yml +23 -0
  32. data/config/locales/feedback_engine.pt.yml +23 -0
  33. data/config/locales/feedback_engine.ro.yml +23 -0
  34. data/config/locales/feedback_engine.ru.yml +23 -0
  35. data/config/locales/feedback_engine.th.yml +23 -0
  36. data/config/locales/feedback_engine.tr.yml +23 -0
  37. data/config/locales/feedback_engine.uk.yml +23 -0
  38. data/config/locales/feedback_engine.ur.yml +23 -0
  39. data/config/locales/feedback_engine.vi.yml +23 -0
  40. data/config/locales/feedback_engine.zh-CN.yml +23 -0
  41. data/config/routes.rb +8 -0
  42. data/lib/feedback_engine/configuration.rb +87 -0
  43. data/lib/feedback_engine/engine.rb +13 -0
  44. data/lib/feedback_engine/version.rb +5 -0
  45. data/lib/feedback_engine/widget.rb +107 -0
  46. data/lib/feedback_engine.rb +34 -0
  47. data/lib/generators/feedback_engine/install/install_generator.rb +41 -0
  48. data/lib/generators/feedback_engine/install/templates/create_feedback_engine_feedbacks.rb.tt +21 -0
  49. data/lib/generators/feedback_engine/install/templates/initializer.rb +43 -0
  50. metadata +110 -0
@@ -0,0 +1,368 @@
1
+ /*
2
+ * feedback_engine widget — self-contained, no framework, no build step.
3
+ *
4
+ * Reads its config from the <script type="application/json"
5
+ * data-feedback-engine-config> the server renders — re-read on every render so
6
+ * a Turbo visit always reflects the current page's config.
7
+ *
8
+ * A floating button (or any host element carrying `data-feedback-engine-open`)
9
+ * opens a small modal form: type, optional section, message, optional
10
+ * screenshots. The form POSTs multipart form data to the mounted engine with
11
+ * the page's CSRF token. Esc or the backdrop closes it.
12
+ */
13
+ (function () {
14
+ "use strict";
15
+
16
+ var config = readConfig();
17
+ if (!config || window.__feedbackEngineLoaded) return;
18
+ window.__feedbackEngineLoaded = true;
19
+
20
+ var Z = 2147483000;
21
+ var overlay = null;
22
+ var lastFocused = null;
23
+
24
+ function ready(fn) {
25
+ if (document.readyState === "loading") {
26
+ document.addEventListener("DOMContentLoaded", fn);
27
+ } else {
28
+ fn();
29
+ }
30
+ }
31
+
32
+ ready(function () {
33
+ // Document-level listeners survive Turbo navigations; register them once.
34
+ document.addEventListener("keydown", handleKeydown);
35
+ document.addEventListener("click", handleOpenerClick, true);
36
+
37
+ // The button lives in <body>, which Turbo replaces on every visit, so
38
+ // re-run the per-page setup on each visit. render() also runs now for the
39
+ // initial (or non-Turbo) load.
40
+ render();
41
+ document.addEventListener("turbo:load", render);
42
+ });
43
+
44
+ function readConfig() {
45
+ var el = document.querySelector("script[data-feedback-engine-config]");
46
+ if (!el) return null;
47
+ try {
48
+ return JSON.parse(el.textContent);
49
+ } catch (e) {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function render() {
55
+ // Re-read on each visit: the config block is data (not an executed
56
+ // script), so it reflects the page Turbo just rendered.
57
+ config = readConfig() || config;
58
+ injectStyles();
59
+ if (config.showButton !== false) buildButton();
60
+ }
61
+
62
+ function handleOpenerClick(event) {
63
+ var opener = event.target && event.target.closest
64
+ ? event.target.closest("[data-feedback-engine-open]")
65
+ : null;
66
+ if (!opener) return;
67
+ event.preventDefault();
68
+ event.stopPropagation();
69
+ openForm();
70
+ }
71
+
72
+ function handleKeydown(event) {
73
+ if (event.key === "Escape" && overlay) closeForm();
74
+ }
75
+
76
+ // --- floating button --------------------------------------------------------
77
+
78
+ function buildButton() {
79
+ if (document.getElementById("fbe-button")) return;
80
+ var button = document.createElement("button");
81
+ button.id = "fbe-button";
82
+ button.type = "button";
83
+ button.setAttribute("data-feedback-engine-open", "");
84
+ button.textContent = config.buttonLabel || config.labels.button;
85
+ document.body.appendChild(button);
86
+ }
87
+
88
+ // --- form -------------------------------------------------------------------
89
+
90
+ function openForm() {
91
+ if (overlay) return;
92
+ lastFocused = document.activeElement;
93
+
94
+ overlay = document.createElement("div");
95
+ overlay.id = "fbe-overlay";
96
+ overlay.addEventListener("mousedown", function (event) {
97
+ if (event.target === overlay) closeForm();
98
+ });
99
+
100
+ var dialog = document.createElement("div");
101
+ dialog.id = "fbe-dialog";
102
+ dialog.setAttribute("role", "dialog");
103
+ dialog.setAttribute("aria-modal", "true");
104
+ dialog.setAttribute("aria-label", config.labels.title);
105
+ if (config.rtl) dialog.setAttribute("dir", "rtl");
106
+
107
+ dialog.appendChild(header());
108
+ dialog.appendChild(form());
109
+ overlay.appendChild(dialog);
110
+ document.body.appendChild(overlay);
111
+
112
+ var first = dialog.querySelector("select, textarea, input");
113
+ if (first) first.focus();
114
+ }
115
+
116
+ function closeForm() {
117
+ if (!overlay) return;
118
+ overlay.remove();
119
+ overlay = null;
120
+ if (lastFocused && lastFocused.focus) lastFocused.focus();
121
+ }
122
+
123
+ function header() {
124
+ var head = document.createElement("div");
125
+ head.className = "fbe-head";
126
+
127
+ var title = document.createElement("h2");
128
+ title.textContent = config.labels.title;
129
+
130
+ var close = document.createElement("button");
131
+ close.type = "button";
132
+ close.className = "fbe-x";
133
+ close.setAttribute("aria-label", config.labels.close);
134
+ close.textContent = "×";
135
+ close.addEventListener("click", closeForm);
136
+
137
+ head.appendChild(title);
138
+ head.appendChild(close);
139
+ return head;
140
+ }
141
+
142
+ function form() {
143
+ var form = document.createElement("form");
144
+ form.addEventListener("submit", function (event) {
145
+ event.preventDefault();
146
+ submit(form);
147
+ });
148
+
149
+ if (config.kinds.length > 1) form.appendChild(kindField());
150
+ if (config.sections.length > 0) form.appendChild(sectionField());
151
+ form.appendChild(messageField());
152
+ if (config.screenshots.enabled) form.appendChild(screenshotsField());
153
+
154
+ var error = document.createElement("p");
155
+ error.className = "fbe-error";
156
+ error.hidden = true;
157
+ form.appendChild(error);
158
+
159
+ var actions = document.createElement("div");
160
+ actions.className = "fbe-actions";
161
+
162
+ var cancel = document.createElement("button");
163
+ cancel.type = "button";
164
+ cancel.className = "fbe-secondary";
165
+ cancel.textContent = config.labels.cancel;
166
+ cancel.addEventListener("click", closeForm);
167
+
168
+ var save = document.createElement("button");
169
+ save.type = "submit";
170
+ save.className = "fbe-primary";
171
+ save.textContent = config.labels.submit;
172
+
173
+ actions.appendChild(cancel);
174
+ actions.appendChild(save);
175
+ form.appendChild(actions);
176
+ return form;
177
+ }
178
+
179
+ function field(labelText, control) {
180
+ var wrap = document.createElement("label");
181
+ wrap.className = "fbe-field";
182
+ var caption = document.createElement("span");
183
+ caption.textContent = labelText;
184
+ wrap.appendChild(caption);
185
+ wrap.appendChild(control);
186
+ return wrap;
187
+ }
188
+
189
+ function kindField() {
190
+ var select = document.createElement("select");
191
+ select.name = "kind";
192
+ config.kinds.forEach(function (kind) {
193
+ var option = document.createElement("option");
194
+ option.value = kind.value;
195
+ option.textContent = kind.label;
196
+ select.appendChild(option);
197
+ });
198
+ return field(config.labels.kind, select);
199
+ }
200
+
201
+ function sectionField() {
202
+ var select = document.createElement("select");
203
+ select.name = "section";
204
+ var blank = document.createElement("option");
205
+ blank.value = "";
206
+ blank.textContent = config.labels.sectionAny;
207
+ select.appendChild(blank);
208
+ config.sections.forEach(function (section) {
209
+ var option = document.createElement("option");
210
+ option.value = section;
211
+ option.textContent = section;
212
+ select.appendChild(option);
213
+ });
214
+ return field(config.labels.section, select);
215
+ }
216
+
217
+ function messageField() {
218
+ var textarea = document.createElement("textarea");
219
+ textarea.name = "message";
220
+ textarea.rows = 5;
221
+ textarea.placeholder = config.labels.messagePlaceholder;
222
+ return field(config.labels.message, textarea);
223
+ }
224
+
225
+ function screenshotsField() {
226
+ var input = document.createElement("input");
227
+ input.type = "file";
228
+ input.name = "screenshots";
229
+ input.multiple = true;
230
+ input.accept = "image/*";
231
+ var wrap = field(config.labels.screenshots, input);
232
+ var hint = document.createElement("span");
233
+ hint.className = "fbe-hint";
234
+ hint.textContent = config.labels.screenshotsHint;
235
+ wrap.appendChild(hint);
236
+ return wrap;
237
+ }
238
+
239
+ // --- submit -------------------------------------------------------------------
240
+
241
+ function submit(form) {
242
+ var message = form.querySelector("textarea[name=message]").value.trim();
243
+ if (!message) return showError(form, config.labels.errorBlank);
244
+
245
+ var files = fileList(form);
246
+ if (files.length > config.screenshots.max) {
247
+ return showError(form, config.labels.errorTooMany);
248
+ }
249
+ for (var i = 0; i < files.length; i++) {
250
+ if (files[i].size > config.screenshots.maxSize) {
251
+ return showError(form, config.labels.errorTooLarge);
252
+ }
253
+ }
254
+
255
+ var data = new FormData();
256
+ var kindSelect = form.querySelector("select[name=kind]");
257
+ data.append("feedback[kind]", kindSelect ? kindSelect.value : config.kinds[0].value);
258
+ var sectionSelect = form.querySelector("select[name=section]");
259
+ if (sectionSelect && sectionSelect.value) data.append("feedback[section]", sectionSelect.value);
260
+ data.append("feedback[message]", message);
261
+ data.append("feedback[page_url]", window.location.href);
262
+ files.forEach(function (file) {
263
+ data.append("feedback[screenshots][]", file);
264
+ });
265
+
266
+ var save = form.querySelector(".fbe-primary");
267
+ save.disabled = true;
268
+
269
+ fetch(config.endpoint, {
270
+ method: "POST",
271
+ headers: csrfHeaders(),
272
+ body: data,
273
+ credentials: "same-origin"
274
+ })
275
+ .then(function (response) {
276
+ if (response.ok) return thanks();
277
+ return response
278
+ .json()
279
+ .catch(function () { return {}; })
280
+ .then(function (body) {
281
+ var messages = body && body.errors;
282
+ showError(form, (messages && messages[0]) || config.labels.errorSave);
283
+ });
284
+ })
285
+ .catch(function () {
286
+ showError(form, config.labels.errorSave);
287
+ })
288
+ .finally(function () {
289
+ save.disabled = false;
290
+ });
291
+ }
292
+
293
+ function fileList(form) {
294
+ var input = form.querySelector("input[type=file]");
295
+ return input ? Array.prototype.slice.call(input.files) : [];
296
+ }
297
+
298
+ function csrfHeaders() {
299
+ var meta = document.querySelector('meta[name="csrf-token"]');
300
+ return meta ? { "X-CSRF-Token": meta.content } : {};
301
+ }
302
+
303
+ function showError(form, text) {
304
+ var error = form.querySelector(".fbe-error");
305
+ error.textContent = text;
306
+ error.hidden = false;
307
+ }
308
+
309
+ function thanks() {
310
+ if (!overlay) return;
311
+ var dialog = overlay.querySelector("#fbe-dialog");
312
+ dialog.textContent = "";
313
+ var note = document.createElement("p");
314
+ note.className = "fbe-thanks";
315
+ note.textContent = config.labels.thanks;
316
+ dialog.appendChild(note);
317
+ setTimeout(closeForm, 1600);
318
+ }
319
+
320
+ // --- styles -------------------------------------------------------------------
321
+
322
+ function injectStyles() {
323
+ if (document.getElementById("fbe-styles")) return;
324
+ var css = [
325
+ "#fbe-button{position:fixed;bottom:16px;right:16px;z-index:" + Z + ";",
326
+ "padding:10px 16px;border:0;border-radius:999px;cursor:pointer;",
327
+ "background:#2563eb;color:#fff;font:600 14px/1 system-ui,-apple-system,sans-serif;",
328
+ "box-shadow:0 4px 14px rgba(0,0,0,.25)}",
329
+ "#fbe-button:hover{background:#1d4ed8}",
330
+ "#fbe-overlay{position:fixed;inset:0;z-index:" + (Z + 1) + ";background:rgba(0,0,0,.45);",
331
+ "display:flex;align-items:center;justify-content:center;padding:16px}",
332
+ "#fbe-dialog{width:100%;max-width:420px;max-height:90vh;overflow:auto;",
333
+ "background:#fff;color:#1c2024;border-radius:14px;padding:20px;",
334
+ "font:14px/1.5 system-ui,-apple-system,sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.35)}",
335
+ "#fbe-dialog .fbe-head{display:flex;align-items:center;justify-content:space-between;margin:0 0 12px}",
336
+ "#fbe-dialog h2{margin:0;font-size:17px}",
337
+ "#fbe-dialog .fbe-x{border:0;background:none;font-size:22px;line-height:1;cursor:pointer;color:inherit;padding:2px 6px}",
338
+ "#fbe-dialog .fbe-field{display:block;margin-bottom:12px}",
339
+ "#fbe-dialog .fbe-field>span{display:block;margin-bottom:4px;font-weight:600}",
340
+ "#fbe-dialog select,#fbe-dialog textarea,#fbe-dialog input[type=file]{width:100%;box-sizing:border-box;",
341
+ "padding:8px;border:1px solid #d1d5db;border-radius:8px;background:inherit;color:inherit;font:inherit}",
342
+ "#fbe-dialog input[type=file]{padding:6px;color:#6b7280;font-size:13px}",
343
+ "#fbe-dialog input[type=file]::file-selector-button{margin-inline-end:10px;padding:6px 12px;",
344
+ "border:1px solid #d1d5db;border-radius:6px;background:none;color:#1c2024;font:inherit;cursor:pointer}",
345
+ "#fbe-dialog textarea{resize:vertical}",
346
+ "#fbe-dialog .fbe-hint{display:block;margin-top:4px;font-size:12px;color:#6b7280;font-weight:400}",
347
+ "#fbe-dialog .fbe-error{color:#dc2626;margin:0 0 12px}",
348
+ "#fbe-dialog .fbe-actions{display:flex;justify-content:flex-end;gap:8px}",
349
+ "#fbe-dialog button{padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
350
+ "#fbe-dialog .fbe-secondary{border:1px solid #d1d5db;background:none;color:inherit}",
351
+ "#fbe-dialog .fbe-primary{border:0;background:#2563eb;color:#fff;font-weight:600}",
352
+ "#fbe-dialog .fbe-primary:disabled{opacity:.6;cursor:default}",
353
+ "#fbe-dialog .fbe-thanks{margin:8px 0;text-align:center;font-size:15px}",
354
+ "@media (prefers-color-scheme:dark){",
355
+ "#fbe-dialog{background:#1a1f26;color:#e6e8ea}",
356
+ "#fbe-dialog select,#fbe-dialog textarea,#fbe-dialog input[type=file]{border-color:#2a313a}",
357
+ "#fbe-dialog input[type=file]{color:#9aa2ab}",
358
+ "#fbe-dialog input[type=file]::file-selector-button{border-color:#2a313a;color:#e6e8ea}",
359
+ "#fbe-dialog .fbe-secondary{border-color:#2a313a}",
360
+ "#fbe-dialog .fbe-hint{color:#9aa2ab}",
361
+ "}"
362
+ ].join("");
363
+ var style = document.createElement("style");
364
+ style.id = "fbe-styles";
365
+ style.textContent = css;
366
+ document.head.appendChild(style);
367
+ }
368
+ })();
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeedbackEngine
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ private
8
+
9
+ def current_author
10
+ return @current_author if defined?(@current_author)
11
+
12
+ @current_author = FeedbackEngine.config.current_user.call(request)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeedbackEngine
4
+ class FeedbacksController < ApplicationController
5
+ PER_PAGE = 50
6
+
7
+ layout 'feedback_engine/application', except: :create
8
+
9
+ # The widget posts here; everything else is the triage dashboard.
10
+ before_action :require_enabled, only: :create
11
+ before_action :require_admin, except: :create
12
+ before_action :set_feedback, only: %i[show update destroy]
13
+
14
+ def index
15
+ @status = Feedback::STATUSES.include?(params[:status]) ? params[:status] : 'open'
16
+ @kind = FeedbackEngine.config.kinds.map(&:to_s).include?(params[:kind]) ? params[:kind] : nil
17
+ @counts = Feedback.group(:status).count
18
+
19
+ scope = Feedback.where(status: @status)
20
+ scope = scope.where(kind: @kind) if @kind
21
+ @page = [params[:page].to_i, 1].max
22
+ @feedbacks = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
23
+ @more = @feedbacks.size > PER_PAGE
24
+ @feedbacks = @feedbacks.first(PER_PAGE)
25
+ end
26
+
27
+ def show; end
28
+
29
+ def create
30
+ feedback = Feedback.new(feedback_params)
31
+ feedback.user_agent = request.user_agent
32
+ attribute_author(feedback)
33
+
34
+ error = attach_screenshots(feedback)
35
+ return render json: { errors: [error] }, status: :unprocessable_entity if error
36
+
37
+ if feedback.save
38
+ FeedbackEngine.config.on_submit.call(feedback)
39
+ head :created
40
+ else
41
+ render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
42
+ end
43
+ end
44
+
45
+ def update
46
+ @feedback.update!(params.require(:feedback).permit(:status))
47
+ redirect_back fallback_location: feedback_path(@feedback), status: :see_other
48
+ end
49
+
50
+ def destroy
51
+ @feedback.destroy!
52
+ redirect_to root_path, status: :see_other
53
+ end
54
+
55
+ private
56
+
57
+ def require_enabled
58
+ head :forbidden unless FeedbackEngine.enabled?(request)
59
+ end
60
+
61
+ # Server-side gate for the dashboard. Default: development only.
62
+ def require_admin
63
+ return if FeedbackEngine.admin?(request)
64
+
65
+ render plain: 'Forbidden. Set FeedbackEngine.config.authorize_admin to grant access.',
66
+ status: :forbidden
67
+ end
68
+
69
+ def set_feedback
70
+ @feedback = Feedback.find(params[:id])
71
+ end
72
+
73
+ def feedback_params
74
+ params.require(:feedback).permit(:kind, :section, :message, :page_url)
75
+ end
76
+
77
+ def attribute_author(feedback)
78
+ author = current_author
79
+ return unless author
80
+
81
+ feedback.author_id = author.id.to_s if author.respond_to?(:id)
82
+ feedback.author_label = FeedbackEngine.config.author_label.call(author)
83
+ end
84
+
85
+ # Validates and attaches uploads. Returns an error message, or nil when
86
+ # everything (including "no screenshots at all") is fine.
87
+ def attach_screenshots(feedback)
88
+ files = Array(params.dig(:feedback, :screenshots)).reject(&:blank?)
89
+ return nil if files.empty?
90
+ return t_error(:error_save) unless FeedbackEngine.config.screenshots_enabled?
91
+ return t_error(:error_too_many, count: FeedbackEngine.config.max_screenshots) if too_many?(files)
92
+ return t_error(:error_too_large, size: max_size_mb) if files.any? { |f| f.size > max_size }
93
+ return t_error(:error_save) unless files.all? { |f| f.content_type.to_s.start_with?('image/') }
94
+
95
+ feedback.screenshots.attach(files)
96
+ nil
97
+ end
98
+
99
+ def too_many?(files)
100
+ files.size > FeedbackEngine.config.max_screenshots
101
+ end
102
+
103
+ def max_size
104
+ FeedbackEngine.config.max_screenshot_size
105
+ end
106
+
107
+ def max_size_mb
108
+ max_size / (1024 * 1024)
109
+ end
110
+
111
+ def t_error(key, **args)
112
+ defaults = {
113
+ error_save: 'Could not send feedback. Please try again.',
114
+ error_too_many: 'Too many screenshots (max %{count}).',
115
+ error_too_large: 'A screenshot is too large (max %{size} MB).'
116
+ }
117
+ I18n.t(key, scope: :feedback_engine, default: defaults[key], **args)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeedbackEngine
4
+ # Included into the host's ActionView. Drop `<%= feedback_engine_tag %>`
5
+ # before </body> in your layout; it renders nothing unless feedback is
6
+ # enabled for the request.
7
+ module WidgetHelper
8
+ def feedback_engine_tag
9
+ return unless FeedbackEngine.enabled?(request)
10
+
11
+ Widget.snippet(
12
+ endpoint: FeedbackEngine.config.feedbacks_endpoint,
13
+ locale: I18n.locale,
14
+ nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
15
+ ).html_safe
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeedbackEngine
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FeedbackEngine
4
+ # One piece of user feedback. Author attribution is optional and stored as
5
+ # loose fields (no foreign key to the host's user table) so the model is
6
+ # portable across apps with different user models.
7
+ class Feedback < ApplicationRecord
8
+ # Hand-rolled instead of an AR enum: `open` would collide with Kernel#open
9
+ # as a scope name, and three statuses don't need the machinery anyway.
10
+ STATUSES = %w[open in_review resolved].freeze
11
+
12
+ has_many_attached :screenshots if defined?(::ActiveStorage)
13
+
14
+ validates :message, presence: true
15
+ validates :status, inclusion: { in: STATUSES }
16
+ validates :kind,
17
+ presence: true,
18
+ inclusion: { in: ->(_) { FeedbackEngine.config.kinds.map(&:to_s) } }
19
+
20
+ scope :newest_first, -> { order(id: :desc) }
21
+
22
+ STATUSES.each do |status|
23
+ define_method(:"#{status}?") { self.status == status }
24
+ end
25
+
26
+ def screenshots?
27
+ respond_to?(:screenshots) && screenshots.attached?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,75 @@
1
+ <h1><%= t('feedback_engine.dashboard.title', default: 'Feedback') %></h1>
2
+
3
+ <div class="tabs">
4
+ <% FeedbackEngine::Feedback::STATUSES.each do |status| %>
5
+ <%= link_to feedbacks_path(status: status, kind: @kind),
6
+ class: ('active' if status == @status) do %>
7
+ <%= t("feedback_engine.statuses.#{status}", default: status.humanize) %>
8
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
9
+ <% end %>
10
+ <% end %>
11
+ </div>
12
+
13
+ <form class="filters" method="get">
14
+ <input type="hidden" name="status" value="<%= @status %>">
15
+ <select name="kind" onchange="this.form.submit()">
16
+ <option value=""><%= t('feedback_engine.dashboard.all_kinds', default: 'All types') %></option>
17
+ <% FeedbackEngine.config.kinds.each do |kind| %>
18
+ <option value="<%= kind %>" <%= 'selected' if kind.to_s == @kind %>>
19
+ <%= t("feedback_engine.kinds.#{kind}", default: kind.to_s.humanize) %>
20
+ </option>
21
+ <% end %>
22
+ </select>
23
+ <noscript><button><%= t('feedback_engine.dashboard.filter', default: 'Filter') %></button></noscript>
24
+ </form>
25
+
26
+ <div class="card">
27
+ <% if @feedbacks.any? %>
28
+ <table>
29
+ <thead>
30
+ <tr>
31
+ <th><%= t('feedback_engine.kind', default: 'Type') %></th>
32
+ <th><%= t('feedback_engine.message', default: 'Message') %></th>
33
+ <th><%= t('feedback_engine.section', default: 'Section') %></th>
34
+ <th><%= t('feedback_engine.dashboard.from', default: 'From') %></th>
35
+ <th><%= t('feedback_engine.dashboard.filed', default: 'Filed') %></th>
36
+ </tr>
37
+ </thead>
38
+ <tbody>
39
+ <% @feedbacks.each do |feedback| %>
40
+ <tr>
41
+ <td>
42
+ <span class="badge kind-<%= feedback.kind.to_s.parameterize %>">
43
+ <%= t("feedback_engine.kinds.#{feedback.kind}", default: feedback.kind.humanize) %>
44
+ </span>
45
+ </td>
46
+ <td>
47
+ <%= link_to truncate(feedback.message, length: 120), feedback_path(feedback) %>
48
+ <% if feedback.screenshots? %>
49
+ <div class="muted">📎 <%= feedback.screenshots.count %></div>
50
+ <% end %>
51
+ </td>
52
+ <td><%= feedback.section %></td>
53
+ <td><%= feedback.author_label %></td>
54
+ <td class="muted" title="<%= feedback.created_at %>">
55
+ <%= time_ago_in_words(feedback.created_at) %>
56
+ </td>
57
+ </tr>
58
+ <% end %>
59
+ </tbody>
60
+ </table>
61
+ <% else %>
62
+ <div class="empty"><%= t('feedback_engine.dashboard.empty', default: 'Nothing here yet.') %></div>
63
+ <% end %>
64
+ </div>
65
+
66
+ <div class="pager">
67
+ <% if @page > 1 %>
68
+ <%= link_to t('feedback_engine.dashboard.newer', default: '← Newer'),
69
+ feedbacks_path(status: @status, kind: @kind, page: @page - 1) %>
70
+ <% end %>
71
+ <% if @more %>
72
+ <%= link_to t('feedback_engine.dashboard.older', default: 'Older →'),
73
+ feedbacks_path(status: @status, kind: @kind, page: @page + 1) %>
74
+ <% end %>
75
+ </div>