UI Customize: email-based problem report flow for custom support email (#32584)

This commit is contained in:
Michael Geers 2026-08-07 18:30:58 +02:00 • committed by GitHub
parent 84cf093d3d
commit ffb91cc335
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 175 additions and 9 deletions

View file

@ -1,5 +1,11 @@
import { describe, it, expect } from "vite-plus/test";
import { generateGitHubContent } from "./template";
import {
generateGitHubContent,
generateMailtoUrl,
generateDebugFile,
MAX_MAIL_TITLE_LENGTH,
MAX_MAIL_DESCRIPTION_LENGTH,
} from "./template";
import type { IssueData, Sections } from "./types";
describe("Issue Utils", () => {
@ -123,4 +129,46 @@ Linux/amd64, MST -07:00`);
expect(result.body).not.toContain("## Logs");
});
});
describe("generateMailtoUrl", () => {
it("generates plaintext mailto url without steps or diagnostics", () => {
const url = generateMailtoUrl("support@example.com", mockIssueData);
expect(url).toContain("mailto:support@example.com?subject=Test%20Issue&body=");
const body = decodeURIComponent(url.split("&body=")[1]);
expect(body).toBe(`This is a test description
Version: v1.0.0
System: Linux/amd64, MST -07:00`);
});
it("stays below the windows mailto limit at maximum input length", () => {
// umlaut-dense german text, each umlaut costs 6 chars percent-encoded
const text = "Fehlermeldung: Ladepunkt überprüfen, Zählerstände größer als üblich. ";
const fill = (length: number) =>
text.repeat(Math.ceil(length / text.length)).slice(0, length);
const url = generateMailtoUrl("support@installer-example.com", {
...mockIssueData,
title: fill(MAX_MAIL_TITLE_LENGTH),
description: fill(MAX_MAIL_DESCRIPTION_LENGTH),
});
expect(url.length).toBeLessThan(2000);
});
});
describe("generateDebugFile", () => {
it("contains version, system and enabled sections", () => {
const content = generateDebugFile(mockIssueData, mockSections);
expect(content).toContain("# evcc debug information");
expect(content).toContain("Version: v1.0.0");
expect(content).toContain("System: Linux/amd64, MST -07:00");
expect(content).toContain("## Configuration (YAML)");
expect(content).toContain("## Logs");
expect(content).not.toContain("## System State");
});
});
});

View file

@ -3,6 +3,10 @@ import type { IssueData, Sections, GitHubContent, Template, HelpType } from "./t
// Constants
const PLACEHOLDER = "⚠️ RETURN TO EVCC TAB → COPY STEP 2 → PASTE HERE";
const MAX_BODY_LENGTH = 8000;
// windows shell silently truncates mailto urls at ~2000 chars; input limits keep
// the percent-encoded url below that even for umlaut-heavy text
export const MAX_MAIL_TITLE_LENGTH = 100;
export const MAX_MAIL_DESCRIPTION_LENGTH = 600;
function toString(sections: Template): string {
return sections
@ -68,6 +72,26 @@ function generateAdditional(sections: Sections): string {
return toString(result);
}
// Generates mailto url with plaintext body; diagnostics travel as file attachment instead
export function generateMailtoUrl(email: string, issue: IssueData): string {
const body = toString([
issue.description,
`Version: ${issue.version}`,
`System: ${issue.system}, ${issue.timezone}`,
]);
return `mailto:${email}?subject=${encodeURIComponent(issue.title)}&body=${encodeURIComponent(body)}`;
}
// Generates text file content with selected diagnostics for manual mail attachment
export function generateDebugFile(issue: IssueData, sections: Sections): string {
return toString([
"# evcc debug information",
[`Version: ${issue.version}`, `System: ${issue.system}, ${issue.timezone}`],
generateAdditional(sections),
]);
}
// Generates GitHub URL for issues or discussions
export function generateGitHubUrl(type: HelpType, title: string, body: string): string {
const baseUrl =

View file

@ -8,7 +8,7 @@
</div>
<!-- Help Type Selection -->
<div class="mb-5">
<div v-if="!emailMode" class="mb-5">
<h5 class="mb-3">{{ $t("issue.helpType.title") }}</h5>
<div class="row g-3">
<div class="col-12 col-md-6">
@ -60,14 +60,14 @@
</h4>
</div>
<p class="text-muted mb-4">
<p v-if="!emailMode" class="text-muted mb-4">
🇬🇧 Please write your issue in English so everyone can participate.
</p>
<!-- Two Column Layout -->
<div class="row mb-5 g-5">
<!-- Left Column: Form Fields -->
<div class="col-12 col-lg-6">
<div class="col-12 col-lg-6 pe-lg-5">
<div class="mb-4">
<label for="issueTitle" class="form-label">
{{ $t("issue.issueTitle") }} *
@ -78,6 +78,7 @@
type="text"
class="form-control"
placeholder="Brief description of the problem"
:maxlength="titleMaxLength"
required
/>
</div>
@ -89,12 +90,18 @@
id="issueDescription"
v-model="issue.description"
class="form-control"
rows="6"
:rows="emailMode ? 12 : 6"
placeholder="Describe what you expected to happen and what actually happened..."
:maxlength="descriptionMaxLength"
required
></textarea>
<div v-if="descriptionMaxLength" class="text-end">
<small class="text-muted">
{{ issue.description.length }} / {{ descriptionMaxLength }}
</small>
</div>
</div>
<div class="mb-4">
<div v-if="!emailMode" class="mb-4">
<label for="stepsToReproduce" class="form-label">
{{ $t("issue.stepsToReproduce") }} *
</label>
@ -149,10 +156,16 @@
<div class="text-end">
<small class="text-muted">* required</small>
</div>
<div v-if="emailMode" class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary">
{{ buttonText }}
</button>
</div>
</div>
<!-- Right Column: Toggleable Sections -->
<div class="col-12 col-lg-6">
<div class="col-12 col-lg-6 ps-lg-5">
<hr class="d-lg-none mt-0 mb-5" />
<div class="mb-4">
<h5>{{ $t("issue.additional.title") }}</h5>
<p class="text-muted small">
@ -289,11 +302,24 @@
</p>
</template>
</IssueAdditionalItem>
<div v-if="emailMode" class="mt-4">
<p class="text-muted small">{{ $t("issue.downloadHint") }}</p>
<div class="d-flex justify-content-end">
<button
type="button"
class="btn btn-outline-primary"
@click="downloadDebugFile"
>
{{ $t("issue.downloadButton") }}
</button>
</div>
</div>
</div>
</div>
<!-- Essential Section Actions -->
<div class="d-flex justify-content-end gap-3 mb-5">
<div v-if="!emailMode" class="d-flex justify-content-end gap-3 mb-5">
<button type="submit" class="btn" :class="buttonClass">
{{ buttonText }}
</button>
@ -304,6 +330,7 @@
<!-- Issue Summary Modal -->
<SummaryModal
v-if="!emailMode"
:help-type="helpType"
:button-class="buttonClass"
:issue-data="issueData"
@ -324,6 +351,12 @@ import api from "@/api";
import store from "@/store";
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
import { formatJson } from "@/components/Issue/format";
import {
generateMailtoUrl,
generateDebugFile,
MAX_MAIL_TITLE_LENGTH,
MAX_MAIL_DESCRIPTION_LENGTH,
} from "@/components/Issue/template";
import type { HelpType, IssueData } from "@/components/Issue/types";
import type { State } from "@/types/evcc";
@ -387,6 +420,18 @@ export default defineComponent({
return { title: this.$t("issue.title") };
},
computed: {
customEmail(): string {
return window.evcc?.customEmail ?? "";
},
emailMode(): boolean {
return !!this.customEmail;
},
titleMaxLength(): number | undefined {
return this.emailMode ? MAX_MAIL_TITLE_LENGTH : undefined;
},
descriptionMaxLength(): number | undefined {
return this.emailMode ? MAX_MAIL_DESCRIPTION_LENGTH : undefined;
},
versionString(): string {
return `v${store.state.version || ""}`;
},
@ -463,7 +508,11 @@ export default defineComponent({
methods: {
// Type-dependent translation helper
$tt(key: string): string {
const suffix = this.helpType === "discussion" ? "Discussion" : "Issue";
const suffix = this.emailMode
? "Email"
: this.helpType === "discussion"
? "Discussion"
: "Issue";
return this.$t(`${key}${suffix}`);
},
@ -603,12 +652,28 @@ export default defineComponent({
},
handleFormSubmit() {
if (this.emailMode) {
window.location.href = generateMailtoUrl(this.customEmail, this.issueData);
return;
}
const modalElement = document.getElementById("issueSummaryModal") as HTMLElement;
if (modalElement) {
Modal.getOrCreateInstance(modalElement).show();
}
},
downloadDebugFile() {
const blob = new Blob([generateDebugFile(this.issueData, this.sections)], {
type: "text/plain",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `evcc-debug-${this.versionString}.txt`;
a.click();
URL.revokeObjectURL(url);
},
clearSessionStorage() {
sessionStorage.removeItem("issue.title");
sessionStorage.removeItem("issue.description");

View file

@ -1223,7 +1223,10 @@
"additionalContext": "Zusätzlicher Kontext",
"additionalContextPlaceholder": "Alle zusätzlichen Informationen, die hilfreich sein könnten...\n- Konfigurationsdetails\n- Was du versucht hast\n- Umgebungsdetails",
"createButtonDiscussion": "GitHub Diskussion starten...",
"createButtonEmail": "Anfrage per E-Mail senden",
"createButtonIssue": "GitHub Issue erstellen...",
"downloadButton": "Debug-Informationen herunterladen",
"downloadHint": "Hänge diese Datei an deine E-Mail an, falls weitere Informationen benötigt werden.",
"description": "Deine Installation funktioniert nicht wie erwartet? Nutze diese Seite um Hilfe zu bekommen oder Probleme zu melden. Gib genügend Details an, damit wir das Problem verstehen und reproduzieren können. Halte deine Beschreibung prägnant, klar und leicht verständlich.",
"helpType": {
"discussion": "Brauche Hilfe bei meiner Einrichtung",
@ -1236,6 +1239,7 @@
"issueTitle": "Titel",
"stepsToReproduce": "Schritte zur Reproduktion",
"subTitleDiscussion": "Beschreibe dein Problem",
"subTitleEmail": "Beschreibe dein Problem",
"subTitleIssue": "Beschreibe das Problem",
"summary": {
"confirmationButtonDiscussion": "GitHub Diskussion starten",

View file

@ -1223,7 +1223,10 @@
"additionalContext": "Additional context",
"additionalContextPlaceholder": "Any additional information that might be helpful...\n- Configuration details\n- What you tried\n- Environment details",
"createButtonDiscussion": "Start GitHub Discussion...",
"createButtonEmail": "Send request by email",
"createButtonIssue": "Create GitHub Issue...",
"downloadButton": "Download debug information",
"downloadHint": "Attach this file to your email if more information is needed.",
"description": "Your installation is not working as expected? Use this page to get help or report issues. Provide enough detail to help us understand and reproduce the problem, while keeping your description concise, clear and easy to follow.",
"helpType": {
"discussion": "Need help with my setup",
@ -1236,6 +1239,7 @@
"issueTitle": "Title",
"stepsToReproduce": "Steps to reproduce",
"subTitleDiscussion": "Describe your problem",
"subTitleEmail": "Describe your problem",
"subTitleIssue": "Describe the issue",
"summary": {
"confirmationButtonDiscussion": "Start GitHub Discussion",

View file

@ -56,6 +56,27 @@ test.describe("customization", async () => {
await expect(email).toHaveAttribute("href", "mailto:support@example.com");
});
test("issue page uses email flow", async ({ page }) => {
await page.goto("/#/issue");
// github-only elements gone
await expect(page.getByRole("radio")).toHaveCount(0);
await expect(page.getByLabel("Steps to Reproduce")).toHaveCount(0);
await expect(page.getByText("Please write your issue in English")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Send request by email" })).toBeVisible();
await expect(
page.getByText("Attach this file to your email if more information is needed.")
).toBeVisible();
const downloadButton = page.getByRole("button", { name: "Download debug information" });
await expect(downloadButton).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await downloadButton.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^evcc-debug-v.+\.txt$/);
});
test("help modal shows contact button instead of github discussions", async ({ page }) => {
await page.goto("/");
const menu = await openMoreMenu(page);