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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/MIT-LICENSE +20 -0
- data/README.md +265 -0
- data/Rakefile +8 -0
- data/app/assets/feedback_engine/widget.js +368 -0
- data/app/controllers/feedback_engine/application_controller.rb +15 -0
- data/app/controllers/feedback_engine/feedbacks_controller.rb +120 -0
- data/app/helpers/feedback_engine/widget_helper.rb +18 -0
- data/app/models/feedback_engine/application_record.rb +7 -0
- data/app/models/feedback_engine/feedback.rb +30 -0
- data/app/views/feedback_engine/feedbacks/index.html.erb +75 -0
- data/app/views/feedback_engine/feedbacks/show.html.erb +63 -0
- data/app/views/layouts/feedback_engine/application.html.erb +75 -0
- data/config/locales/feedback_engine.ar.yml +23 -0
- data/config/locales/feedback_engine.bg.yml +23 -0
- data/config/locales/feedback_engine.bn.yml +23 -0
- data/config/locales/feedback_engine.de.yml +23 -0
- data/config/locales/feedback_engine.el.yml +23 -0
- data/config/locales/feedback_engine.en.yml +43 -0
- data/config/locales/feedback_engine.es.yml +23 -0
- data/config/locales/feedback_engine.fr.yml +23 -0
- data/config/locales/feedback_engine.hi.yml +23 -0
- data/config/locales/feedback_engine.hr.yml +23 -0
- data/config/locales/feedback_engine.id.yml +23 -0
- data/config/locales/feedback_engine.it.yml +23 -0
- data/config/locales/feedback_engine.ja.yml +23 -0
- data/config/locales/feedback_engine.ko.yml +23 -0
- data/config/locales/feedback_engine.lb.yml +23 -0
- data/config/locales/feedback_engine.nl.yml +23 -0
- data/config/locales/feedback_engine.pl.yml +23 -0
- data/config/locales/feedback_engine.pt.yml +23 -0
- data/config/locales/feedback_engine.ro.yml +23 -0
- data/config/locales/feedback_engine.ru.yml +23 -0
- data/config/locales/feedback_engine.th.yml +23 -0
- data/config/locales/feedback_engine.tr.yml +23 -0
- data/config/locales/feedback_engine.uk.yml +23 -0
- data/config/locales/feedback_engine.ur.yml +23 -0
- data/config/locales/feedback_engine.vi.yml +23 -0
- data/config/locales/feedback_engine.zh-CN.yml +23 -0
- data/config/routes.rb +8 -0
- data/lib/feedback_engine/configuration.rb +87 -0
- data/lib/feedback_engine/engine.rb +13 -0
- data/lib/feedback_engine/version.rb +5 -0
- data/lib/feedback_engine/widget.rb +107 -0
- data/lib/feedback_engine.rb +34 -0
- data/lib/generators/feedback_engine/install/install_generator.rb +41 -0
- data/lib/generators/feedback_engine/install/templates/create_feedback_engine_feedbacks.rb.tt +21 -0
- data/lib/generators/feedback_engine/install/templates/initializer.rb +43 -0
- 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,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>
|