The OWL dialog used <t t-out="m.body"/> on message bodies, but t-out escapes plain strings — it only renders raw when the value is a Markup instance. Bodies arrive over JSON-RPC as plain strings (Markup is a client-side type, doesn't cross the wire), so the customer was seeing literal "<p>This has been fixed.</p>" in the thread instead of the rendered HTML. Wrap incoming bodies in `markup()` at the boundary (openTicket + sendReply call sites) so the template renders them as the sanitised HTML the central chatter already produced. Trust is fine — the body is sanitised server-side by mail.thread before it ever leaves nexa. Bumps fusion_helpdesk to 19.0.1.7.1.
450 lines
17 KiB
JavaScript
450 lines
17 KiB
JavaScript
/** @odoo-module **/
|
|
// Fusion Helpdesk — submission + follow-up dialog.
|
|
//
|
|
// Two tabs:
|
|
// • New — report a bug / request a feature (the original form),
|
|
// plus a confirmed "Your email" field so support can reply.
|
|
// • My Tickets — a live RPC view of the user's tickets on the central
|
|
// Odoo: list → open one → read support's replies → reply
|
|
// inline, without ever leaving this Odoo or logging in.
|
|
//
|
|
// Tickets are NOT copied locally — every list/thread/reply is a live call
|
|
// to the central Helpdesk, scoped server-side to the logged-in user.
|
|
|
|
import { Component, markup, useState, onWillStart } from "@odoo/owl";
|
|
import { Dialog } from "@web/core/dialog/dialog";
|
|
import { rpc } from "@web/core/network/rpc";
|
|
import { user } from "@web/core/user";
|
|
import { useService } from "@web/core/utils/hooks";
|
|
import { _t } from "@web/core/l10n/translation";
|
|
|
|
// Wrap message bodies in `markup()` so the OWL template's `t-out` renders them
|
|
// as HTML instead of escaping `<p>…</p>` literally. Bodies come from central as
|
|
// plain JSON strings (Markup doesn't survive JSON-RPC) but are already sanitised
|
|
// server-side by Odoo's mail.thread, so we trust them. Mutates the list in place
|
|
// because that's what the existing call sites assign straight to state.
|
|
function _markupBodies(messages) {
|
|
for (const m of messages || []) {
|
|
m.body = markup(m.body || "");
|
|
}
|
|
return messages || [];
|
|
}
|
|
|
|
const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file
|
|
|
|
export class FusionHelpdeskDialog extends Component {
|
|
static template = "fusion_helpdesk.Dialog";
|
|
static components = { Dialog };
|
|
static props = {
|
|
close: Function,
|
|
initialTab: { type: String, optional: true },
|
|
};
|
|
|
|
setup() {
|
|
this.notification = useService("notification");
|
|
this.state = useState({
|
|
tab: this.props.initialTab || "new", // 'new' | 'list' | 'thread'
|
|
// ---- New report ----
|
|
kind: "bug",
|
|
subject: "",
|
|
description: "",
|
|
errorCode: "",
|
|
replyEmail: user.login || "",
|
|
attachments: [],
|
|
isCritical: false,
|
|
capturing: false,
|
|
submitting: false,
|
|
error: "",
|
|
success: false,
|
|
ticketId: null,
|
|
ticketUrl: "",
|
|
attached: 0,
|
|
failed: 0,
|
|
// ---- My Tickets ----
|
|
isAdmin: false,
|
|
scope: "mine", // 'mine' | 'all'
|
|
tickets: [],
|
|
loadingList: false,
|
|
listError: "",
|
|
// ---- Thread ----
|
|
current: null, // {id, subject, stage, portal_url, messages}
|
|
loadingThread: false,
|
|
threadError: "",
|
|
replyBody: "",
|
|
sendingReply: false,
|
|
});
|
|
|
|
onWillStart(async () => {
|
|
if (this.state.tab === "list") {
|
|
await this.loadList();
|
|
}
|
|
});
|
|
}
|
|
|
|
get dialogTitle() {
|
|
if (this.state.tab === "thread" && this.state.current) {
|
|
return this.state.current.subject;
|
|
}
|
|
if (this.state.tab === "list") {
|
|
return _t("My Tickets");
|
|
}
|
|
return this.state.kind === "bug" ? _t("Report a Bug") : _t("Request a Feature");
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tabs
|
|
async setTab(tab) {
|
|
this.state.tab = tab;
|
|
this.state.error = "";
|
|
if (tab === "list") {
|
|
await this.loadList();
|
|
}
|
|
}
|
|
|
|
setKind(kind) {
|
|
this.state.kind = kind;
|
|
}
|
|
|
|
// ==================================================================
|
|
// My Tickets — list
|
|
// ==================================================================
|
|
async loadList() {
|
|
if (this.state.loadingList) return;
|
|
this.state.loadingList = true;
|
|
this.state.listError = "";
|
|
try {
|
|
const res = await rpc("/fusion_helpdesk/my_tickets", {
|
|
scope: this.state.scope,
|
|
});
|
|
if (!res.ok) {
|
|
this.state.listError = res.message || _t("Could not load your tickets.");
|
|
this.state.tickets = [];
|
|
} else {
|
|
this.state.tickets = res.tickets || [];
|
|
this.state.isAdmin = !!res.is_admin;
|
|
}
|
|
} catch (err) {
|
|
console.error("fusion_helpdesk: my_tickets failed", err);
|
|
this.state.listError = (err && err.message) || _t("Network error.");
|
|
} finally {
|
|
this.state.loadingList = false;
|
|
}
|
|
}
|
|
|
|
async setScope(scope) {
|
|
if (this.state.scope === scope) return;
|
|
this.state.scope = scope;
|
|
await this.loadList();
|
|
}
|
|
|
|
// Bucket tickets into Critical / New & Open / Solved for the section view.
|
|
// Backend already sorts by write_date desc, so iterating preserves latest-
|
|
// first within each bucket. We only render non-empty sections so the
|
|
// dialog doesn't show "0 Critical" noise when the user has none.
|
|
get groupedTickets() {
|
|
const groups = [
|
|
{ key: "critical", title: _t("Critical"),
|
|
iconClass: "fa fa-exclamation-circle", className: "o_fhd_group_critical",
|
|
tickets: [] },
|
|
{ key: "open", title: _t("New & Open"),
|
|
iconClass: "fa fa-inbox", className: "o_fhd_group_open",
|
|
tickets: [] },
|
|
{ key: "solved", title: _t("Solved"),
|
|
iconClass: "fa fa-check-circle", className: "o_fhd_group_solved",
|
|
tickets: [] },
|
|
];
|
|
const byKey = Object.fromEntries(groups.map((g) => [g.key, g]));
|
|
for (const t of this.state.tickets) {
|
|
(byKey[t.group] || byKey.open).tickets.push(t);
|
|
}
|
|
return groups.filter((g) => g.tickets.length);
|
|
}
|
|
|
|
// KPI stats — same source as the section list so counts always agree
|
|
// with what the user sees scrolling below. "Open" rolls Critical + Open
|
|
// groups together (anything not folded); "Closed" is the Solved bucket.
|
|
get ticketStats() {
|
|
const t = this.state.tickets;
|
|
const critical = t.filter((x) => x.group === "critical").length;
|
|
const open = t.filter((x) => x.group === "critical" || x.group === "open").length;
|
|
const closed = t.filter((x) => x.group === "solved").length;
|
|
return { total: t.length, open, closed, critical };
|
|
}
|
|
|
|
// Colour pill class per ticket. Critical priority shows as a dedicated red
|
|
// pill alongside the stage pill (rendered in the template), so this only
|
|
// needs to map the stage name to a hue. Match on substring (lowercased) so
|
|
// a renamed "Resolved" / "Done" stage on central still maps to green.
|
|
pillClass(t) {
|
|
const s = (t.stage || "").toLowerCase();
|
|
if (s.includes("cancel")) return "o_fhd_pill o_fhd_pill_cancelled";
|
|
if (s.includes("solv") || s.includes("done") || s.includes("resolv")) {
|
|
return "o_fhd_pill o_fhd_pill_solved";
|
|
}
|
|
if (s === "new") return "o_fhd_pill o_fhd_pill_new";
|
|
if (s.includes("progress") || s.includes("test") || s.includes("hold")) {
|
|
return "o_fhd_pill o_fhd_pill_progress";
|
|
}
|
|
return "o_fhd_pill"; // unknown stage falls back to neutral chip
|
|
}
|
|
|
|
isCriticalRow(t) {
|
|
return t.priority === "2" || t.priority === "3";
|
|
}
|
|
|
|
// ==================================================================
|
|
// My Tickets — thread
|
|
// ==================================================================
|
|
async openTicket(ticketId) {
|
|
this.state.loadingThread = true;
|
|
this.state.threadError = "";
|
|
this.state.replyBody = "";
|
|
try {
|
|
const res = await rpc(`/fusion_helpdesk/ticket/${ticketId}`, {});
|
|
if (!res.ok) {
|
|
this.state.threadError = res.message || _t("Could not open this ticket.");
|
|
return;
|
|
}
|
|
_markupBodies(res.ticket.messages);
|
|
this.state.current = res.ticket;
|
|
this.state.tab = "thread";
|
|
// The ticket is now seen server-side; clear its unread flag locally.
|
|
const row = this.state.tickets.find((t) => t.id === ticketId);
|
|
if (row) {
|
|
row.has_unread = false;
|
|
}
|
|
} catch (err) {
|
|
console.error("fusion_helpdesk: open ticket failed", err);
|
|
this.state.threadError = (err && err.message) || _t("Network error.");
|
|
} finally {
|
|
this.state.loadingThread = false;
|
|
}
|
|
}
|
|
|
|
async backToList() {
|
|
this.state.current = null;
|
|
this.state.tab = "list";
|
|
await this.loadList(); // refresh stages / unread after viewing
|
|
}
|
|
|
|
async sendReply() {
|
|
const body = (this.state.replyBody || "").trim();
|
|
if (!body || this.state.sendingReply || !this.state.current) return;
|
|
this.state.sendingReply = true;
|
|
this.state.threadError = "";
|
|
try {
|
|
const res = await rpc(
|
|
`/fusion_helpdesk/ticket/${this.state.current.id}/reply`,
|
|
{ body }
|
|
);
|
|
if (!res.ok) {
|
|
this.state.threadError = res.message || _t("Could not send your reply.");
|
|
} else {
|
|
this.state.current.messages = _markupBodies(res.messages) || this.state.current.messages;
|
|
this.state.replyBody = "";
|
|
this.notification.add(_t("Reply sent."), { type: "success" });
|
|
}
|
|
} catch (err) {
|
|
console.error("fusion_helpdesk: send reply failed", err);
|
|
this.state.threadError = (err && err.message) || _t("Network error.");
|
|
} finally {
|
|
this.state.sendingReply = false;
|
|
}
|
|
}
|
|
|
|
// ==================================================================
|
|
// New report — files / screenshot (unchanged behaviour)
|
|
// ==================================================================
|
|
async onFilesPicked(ev) {
|
|
const files = Array.from(ev.target.files || []);
|
|
for (const f of files) {
|
|
if (f.size > MAX_BYTES_PER_FILE) {
|
|
this.notification.add(
|
|
_t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name),
|
|
{ type: "warning" }
|
|
);
|
|
continue;
|
|
}
|
|
try {
|
|
const b64 = await this._fileToB64(f);
|
|
this._addAttachment({
|
|
name: f.name,
|
|
mimetype: f.type || "application/octet-stream",
|
|
data_b64: b64,
|
|
rawSize: f.size,
|
|
});
|
|
} catch (err) {
|
|
this.notification.add(
|
|
_t("Could not read '%s'.").replace("%s", f.name),
|
|
{ type: "danger" }
|
|
);
|
|
}
|
|
}
|
|
ev.target.value = "";
|
|
}
|
|
|
|
_fileToB64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result || "";
|
|
const idx = result.indexOf(",");
|
|
resolve(idx >= 0 ? result.slice(idx + 1) : result);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async onTakeScreenshot() {
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
|
this.notification.add(
|
|
_t("Your browser doesn't support screen capture. Use Attach files instead."),
|
|
{ type: "warning" }
|
|
);
|
|
return;
|
|
}
|
|
this.state.capturing = true;
|
|
let stream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { displaySurface: "browser" },
|
|
audio: false,
|
|
});
|
|
const blob = await this._streamToBlob(stream);
|
|
const b64 = await this._blobToB64(blob);
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
this._addAttachment({
|
|
name: `screenshot-${ts}.png`,
|
|
mimetype: "image/png",
|
|
data_b64: b64,
|
|
rawSize: blob.size,
|
|
});
|
|
} catch (err) {
|
|
if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
|
|
this.notification.add(
|
|
_t("Screenshot failed: %s").replace("%s", err.message || err),
|
|
{ type: "danger" }
|
|
);
|
|
}
|
|
} finally {
|
|
if (stream) {
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
}
|
|
this.state.capturing = false;
|
|
}
|
|
}
|
|
|
|
async _streamToBlob(stream) {
|
|
const video = document.createElement("video");
|
|
video.srcObject = stream;
|
|
await video.play();
|
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
return await new Promise((resolve) =>
|
|
canvas.toBlob((b) => resolve(b), "image/png", 0.92)
|
|
);
|
|
}
|
|
|
|
_blobToB64(blob) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const result = reader.result || "";
|
|
const idx = result.indexOf(",");
|
|
resolve(idx >= 0 ? result.slice(idx + 1) : result);
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
_addAttachment({ name, mimetype, data_b64, rawSize }) {
|
|
this.state.attachments.push({
|
|
name,
|
|
mimetype,
|
|
data_b64,
|
|
sizeLabel: this._formatBytes(rawSize),
|
|
iconClass: this._iconForMime(mimetype),
|
|
});
|
|
}
|
|
|
|
removeAttachment(idx) {
|
|
this.state.attachments.splice(idx, 1);
|
|
}
|
|
|
|
_formatBytes(n) {
|
|
if (!n) return "";
|
|
if (n < 1024) return n + " B";
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
|
}
|
|
|
|
_iconForMime(mt) {
|
|
mt = (mt || "").toLowerCase();
|
|
if (mt.startsWith("image/")) return "fa fa-file-image-o";
|
|
if (mt.startsWith("video/")) return "fa fa-file-video-o";
|
|
if (mt.startsWith("audio/")) return "fa fa-file-audio-o";
|
|
if (mt.includes("pdf")) return "fa fa-file-pdf-o";
|
|
if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o";
|
|
if (mt.includes("text")) return "fa fa-file-text-o";
|
|
return "fa fa-file-o";
|
|
}
|
|
|
|
// ==================================================================
|
|
// New report — submit
|
|
// ==================================================================
|
|
async onSubmit() {
|
|
if (this.state.submitting) return;
|
|
const subject = (this.state.subject || "").trim();
|
|
if (!subject) {
|
|
this.state.error = _t("Subject is required.");
|
|
return;
|
|
}
|
|
this.state.error = "";
|
|
this.state.success = false;
|
|
this.state.submitting = true;
|
|
try {
|
|
const payload = {
|
|
kind: this.state.kind,
|
|
subject,
|
|
description: this.state.description || "",
|
|
error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
|
|
reply_email: (this.state.replyEmail || "").trim(),
|
|
is_critical: !!this.state.isCritical,
|
|
attachments: this.state.attachments.map((a) => ({
|
|
name: a.name,
|
|
mimetype: a.mimetype,
|
|
data_b64: a.data_b64,
|
|
})),
|
|
page_url: window.location.href,
|
|
user_agent: navigator.userAgent,
|
|
};
|
|
const res = await rpc("/fusion_helpdesk/submit", payload);
|
|
if (!res.ok) {
|
|
this.state.error = res.message || _t("Submission failed.");
|
|
} else {
|
|
this.state.success = true;
|
|
this.state.ticketId = res.ticket_id;
|
|
this.state.ticketUrl = res.ticket_url;
|
|
this.state.attached = res.attached || 0;
|
|
this.state.failed = res.failed || 0;
|
|
this.state.subject = "";
|
|
this.state.description = "";
|
|
this.state.errorCode = "";
|
|
this.state.attachments = [];
|
|
this.state.isCritical = false;
|
|
}
|
|
} catch (err) {
|
|
console.error("fusion_helpdesk: submit failed", err);
|
|
this.state.error = (err && err.message) || _t("Network error.");
|
|
} finally {
|
|
this.state.submitting = false;
|
|
}
|
|
}
|
|
}
|