feat(app): emit structured download event for CSV exports (#30175)
This commit is contained in:
parent
dcc8ed6f7e
commit
2e682c689f
13 changed files with 185 additions and 6 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -270,3 +270,20 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
||||||
- Implement proper caching strategies and connection pooling
|
- Implement proper caching strategies and connection pooling
|
||||||
- Avoid blocking operations in main application loop
|
- Avoid blocking operations in main application loop
|
||||||
- Include appropriate comments for complex business logic
|
- Include appropriate comments for complex business logic
|
||||||
|
|
||||||
|
## Pull Request Descriptions
|
||||||
|
|
||||||
|
Structure PR descriptions in this order. No headlines. Be concise.
|
||||||
|
|
||||||
|
1. **References first line**: link related issues or PRs (`fixes #1123`, `replaces #222`, `pairs with org/repo#345`). PRs should almost always reference an issue or related PR — only skip in rare exceptions (e.g. trivial typo fixes).
|
||||||
|
2. **Intro**: one or a few concise sentences framing what the PR does and why it was created this way. The full problem description belongs in the linked issue, not here.
|
||||||
|
3. **Bullet list**: most significant changes or user-facing implications. Lead with the most significant.
|
||||||
|
4. **TODO section** (only if open points remain):
|
||||||
|
|
||||||
|
```
|
||||||
|
**TODO**
|
||||||
|
- [ ] item a
|
||||||
|
- [ ] item b
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid file paths, line numbers, or code listings reproduced from the diff. Include a code snippet only when it conveys the contract (event shape, API signature) more clearly than prose. No testing checklists, no co-author footers, no generator footers.
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import GenericModal from "../Helper/GenericModal.vue";
|
import GenericModal from "../Helper/GenericModal.vue";
|
||||||
import api, { downloadFile } from "@/api";
|
import api, { downloadFile } from "@/api";
|
||||||
|
import { dispatchDownload } from "@/utils/native";
|
||||||
import PropertyFileField from "./PropertyFileField.vue";
|
import PropertyFileField from "./PropertyFileField.vue";
|
||||||
import FormRow from "./FormRow.vue";
|
import FormRow from "./FormRow.vue";
|
||||||
import { isLoggedIn } from "../Auth/auth";
|
import { isLoggedIn } from "../Auth/auth";
|
||||||
|
|
@ -296,6 +297,10 @@ export default defineComponent({
|
||||||
return r;
|
return r;
|
||||||
},
|
},
|
||||||
async downloadBackup() {
|
async downloadBackup() {
|
||||||
|
if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) {
|
||||||
|
this.closeConfirmModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await this.call(
|
const res = await this.call(
|
||||||
api.post(
|
api.post(
|
||||||
"/system/backup",
|
"/system/backup",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,12 @@
|
||||||
$t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) })
|
$t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) })
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<a :href="csvLink" download class="alert-link text-nowrap">
|
<a
|
||||||
|
:href="csvLink"
|
||||||
|
download
|
||||||
|
class="alert-link text-nowrap"
|
||||||
|
@click="handleDownloadClick($event, csvLink)"
|
||||||
|
>
|
||||||
{{ $t("config.hems.downloadCsv") }}
|
{{ $t("config.hems.downloadCsv") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -40,6 +45,7 @@
|
||||||
import YamlModal from "./YamlModal.vue";
|
import YamlModal from "./YamlModal.vue";
|
||||||
import defaultYaml from "./defaultYaml/hems.yaml?raw";
|
import defaultYaml from "./defaultYaml/hems.yaml?raw";
|
||||||
import api from "../../api";
|
import api from "../../api";
|
||||||
|
import { handleDownloadClick } from "../../utils/native";
|
||||||
import formatter from "../../mixins/formatter";
|
import formatter from "../../mixins/formatter";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -78,6 +84,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
handleDownloadClick,
|
||||||
async loadSessions() {
|
async loadSessions() {
|
||||||
try {
|
try {
|
||||||
const response = await api.get("gridsessions", {
|
const response = await api.get("gridsessions", {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ declare global {
|
||||||
}
|
}
|
||||||
interface Window {
|
interface Window {
|
||||||
ReactNativeWebView?: WebView;
|
ReactNativeWebView?: WebView;
|
||||||
|
evccAppCapabilities?: string[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,27 @@ export function appDetection() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendToApp(data: { type: string }) {
|
export function hasAppCapability(capability: string): boolean {
|
||||||
|
return isApp() && window.evccAppCapabilities?.includes(capability) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppMessage =
|
||||||
|
| { type: "online" | "offline" | "settings" }
|
||||||
|
| { type: "download"; url: string; method?: string; body?: unknown };
|
||||||
|
|
||||||
|
export function sendToApp(data: AppMessage) {
|
||||||
window.ReactNativeWebView?.postMessage(JSON.stringify(data));
|
window.ReactNativeWebView?.postMessage(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleDownloadClick(event: Event, url: string) {
|
||||||
|
if (dispatchDownload(url)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchDownload(url: string, method?: string, body?: unknown): boolean {
|
||||||
|
if (!hasAppCapability("download")) return false;
|
||||||
|
const absolute = new URL(url, window.location.href).toString();
|
||||||
|
sendToApp({ type: "download", url: absolute, method, body });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@
|
||||||
download
|
download
|
||||||
class="text-muted small history-csv-link"
|
class="text-muted small history-csv-link"
|
||||||
data-testid="history-csv-download"
|
data-testid="history-csv-download"
|
||||||
|
@click="handleDownloadClick($event, csvLink)"
|
||||||
>
|
>
|
||||||
{{ $t("main.history.downloadCsv") }}
|
{{ $t("main.history.downloadCsv") }}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -126,6 +127,7 @@ import { PERIODS } from "../components/Sessions/types";
|
||||||
import { GROUP_ORDER, groupColor } from "../components/History/groups";
|
import { GROUP_ORDER, groupColor } from "../components/History/groups";
|
||||||
import colors, { resolveColors, deviceColorMap } from "../colors";
|
import colors, { resolveColors, deviceColorMap } from "../colors";
|
||||||
import LegendList from "../components/Sessions/LegendList.vue";
|
import LegendList from "../components/Sessions/LegendList.vue";
|
||||||
|
import { handleDownloadClick } from "@/utils/native";
|
||||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import store from "../store";
|
import store from "../store";
|
||||||
|
|
@ -368,6 +370,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
groupColor,
|
groupColor,
|
||||||
|
handleDownloadClick,
|
||||||
legendsForGroup(group: string): Legend[] {
|
legendsForGroup(group: string): Legend[] {
|
||||||
const items = this.entityLegends(group);
|
const items = this.entityLegends(group);
|
||||||
const focused = this.focusedEntity[group] ?? null;
|
const focused = this.focusedEntity[group] ?? null;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
:aria-label="$t('log.download')"
|
:aria-label="$t('log.download')"
|
||||||
:href="downloadUrl"
|
:href="downloadUrl"
|
||||||
download
|
download
|
||||||
|
@click="handleDownloadClick($event, downloadUrl)"
|
||||||
>
|
>
|
||||||
<shopicon-regular-download
|
<shopicon-regular-download
|
||||||
size="s"
|
size="s"
|
||||||
|
|
@ -118,6 +119,7 @@ import store from "../store";
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import type { Timeout } from "@/types/evcc";
|
import type { Timeout } from "@/types/evcc";
|
||||||
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
|
import { LOG_LEVELS, DEFAULT_LOG_LEVEL } from "@/utils/log";
|
||||||
|
import { handleDownloadClick } from "@/utils/native";
|
||||||
const DEFAULT_COUNT = 1000;
|
const DEFAULT_COUNT = 1000;
|
||||||
|
|
||||||
const levelMatcher = new RegExp(`\\[.*?\\] (${LOG_LEVELS.map((l) => l.toUpperCase()).join("|")})`);
|
const levelMatcher = new RegExp(`\\[.*?\\] (${LOG_LEVELS.map((l) => l.toUpperCase()).join("|")})`);
|
||||||
|
|
@ -222,6 +224,7 @@ export default defineComponent({
|
||||||
this.stopInterval();
|
this.stopInterval();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
handleDownloadClick,
|
||||||
async updateLogs(showAll: boolean = false) {
|
async updateLogs(showAll: boolean = false) {
|
||||||
// prevent concurrent requests
|
// prevent concurrent requests
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@
|
||||||
:href="csvLink"
|
:href="csvLink"
|
||||||
download
|
download
|
||||||
data-testid="sessions-download"
|
data-testid="sessions-download"
|
||||||
|
@click="handleDownloadClick($event, csvLink)"
|
||||||
>
|
>
|
||||||
{{ csvLinkLabel }}
|
{{ csvLinkLabel }}
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -211,6 +212,7 @@ import settings from "../settings";
|
||||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||||
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
|
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
|
||||||
|
import { handleDownloadClick } from "@/utils/native";
|
||||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||||
import TotalIcon from "../components/MaterialIcon/Total.vue";
|
import TotalIcon from "../components/MaterialIcon/Total.vue";
|
||||||
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
|
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
|
||||||
|
|
@ -671,6 +673,7 @@ export default defineComponent({
|
||||||
this.loadSessions();
|
this.loadSessions();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
handleDownloadClick,
|
||||||
changePeriod(newPeriod: PERIODS) {
|
changePeriod(newPeriod: PERIODS) {
|
||||||
let month: number | undefined = this.month;
|
let month: number | undefined = this.month;
|
||||||
let year: number | undefined = this.year;
|
let year: number | undefined = this.year;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { start, stop, baseUrl, restart } from "./evcc";
|
import { start, stop, baseUrl, restart } from "./evcc";
|
||||||
import { openMoreMenu, expectModalVisible, expectModalHidden } from "./utils";
|
import {
|
||||||
|
openMoreMenu,
|
||||||
|
expectModalVisible,
|
||||||
|
expectModalHidden,
|
||||||
|
enableAppContext,
|
||||||
|
expectAppEvent,
|
||||||
|
} from "./utils";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|
@ -247,3 +253,28 @@ test.describe("backup and restore", async () => {
|
||||||
await stop();
|
await stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("backup in app context", async () => {
|
||||||
|
test("download backup dispatches POST event with password body", async ({ page }) => {
|
||||||
|
await enableAppContext(page);
|
||||||
|
await start();
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Backup & Restore" }).click();
|
||||||
|
const backupModal = page.getByTestId("backup-restore-modal");
|
||||||
|
await expectModalVisible(backupModal);
|
||||||
|
|
||||||
|
await backupModal.getByRole("button", { name: "Download backup..." }).click();
|
||||||
|
const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal");
|
||||||
|
await expectModalVisible(backupConfirmModal);
|
||||||
|
|
||||||
|
await backupConfirmModal.getByRole("button", { name: "Download backup" }).click();
|
||||||
|
expect(await expectAppEvent(page)).toMatchObject({
|
||||||
|
type: "download",
|
||||||
|
url: expect.stringContaining("/api/system/backup"),
|
||||||
|
method: "POST",
|
||||||
|
body: { password: "" },
|
||||||
|
});
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { start, stop, restart, baseUrl } from "./evcc";
|
import { start, stop, restart, baseUrl } from "./evcc";
|
||||||
import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils";
|
import {
|
||||||
|
expectModalVisible,
|
||||||
|
expectModalHidden,
|
||||||
|
editorClear,
|
||||||
|
editorPaste,
|
||||||
|
enableAppContext,
|
||||||
|
expectAppEvent,
|
||||||
|
} from "./utils";
|
||||||
import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator";
|
import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator";
|
||||||
|
|
||||||
test.use({ baseURL: baseUrl() });
|
test.use({ baseURL: baseUrl() });
|
||||||
|
|
@ -109,6 +116,25 @@ limit:
|
||||||
await stopSimulator();
|
await stopSimulator();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("grid sessions CSV in app context", () => {
|
||||||
|
test("dispatches download event", async ({ page }) => {
|
||||||
|
await enableAppContext(page);
|
||||||
|
await start(CONFIG, "hems.sql");
|
||||||
|
await page.goto("/#/config");
|
||||||
|
|
||||||
|
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||||
|
const hemsModal = page.getByTestId("hems-modal");
|
||||||
|
await expectModalVisible(hemsModal);
|
||||||
|
|
||||||
|
const csvLink = hemsModal.getByRole("link", { name: "Download CSV" });
|
||||||
|
await csvLink.click();
|
||||||
|
expect(await expectAppEvent(page)).toMatchObject({
|
||||||
|
type: "download",
|
||||||
|
url: expect.stringContaining("/api/gridsessions?format=csv&lang=en"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("external control with circuits", async ({ page }) => {
|
test("external control with circuits", async ({ page }) => {
|
||||||
const GRID_CONFIG = "hems-grid.evcc.yaml";
|
const GRID_CONFIG = "hems-grid.evcc.yaml";
|
||||||
await start(GRID_CONFIG);
|
await start(GRID_CONFIG);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { start, stop, baseUrl } from "./evcc";
|
import { start, stop, baseUrl } from "./evcc";
|
||||||
import { openMoreMenu } from "./utils";
|
import { openMoreMenu, enableAppContext, expectAppEvent } from "./utils";
|
||||||
|
|
||||||
test.use({ baseURL: baseUrl() });
|
test.use({ baseURL: baseUrl() });
|
||||||
|
|
||||||
|
|
@ -49,3 +49,15 @@ test.describe("features", async () => {
|
||||||
await expect(page.getByTestId("log-content")).toContainText("UI local");
|
await expect(page.getByTestId("log-content")).toContainText("UI local");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("log download in app context", async () => {
|
||||||
|
test("dispatches download event", async ({ page }) => {
|
||||||
|
await enableAppContext(page);
|
||||||
|
await page.goto("/#/log");
|
||||||
|
await page.getByRole("link", { name: "Download complete log" }).click();
|
||||||
|
expect(await expectAppEvent(page)).toMatchObject({
|
||||||
|
type: "download",
|
||||||
|
url: expect.stringContaining("/api/system/log?level=debug&format=txt"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { test, expect, devices, type Page } from "@playwright/test";
|
import { test, expect, devices, type Page } from "@playwright/test";
|
||||||
import { start, stop, baseUrl } from "./evcc";
|
import { start, stop, baseUrl } from "./evcc";
|
||||||
import { expectModalVisible, expectModalHidden } from "./utils";
|
import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils";
|
||||||
|
|
||||||
test.use({ baseURL: baseUrl() });
|
test.use({ baseURL: baseUrl() });
|
||||||
|
|
||||||
|
|
@ -309,6 +309,26 @@ test.describe("csv export", async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("csv export download", async () => {
|
||||||
|
test("in browser context", async ({ page }) => {
|
||||||
|
await page.goto("/#/sessions?period=total");
|
||||||
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
await page.getByRole("link", { name: "Download total CSV" }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
expect(download.url()).toContain("/api/sessions?format=csv");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in app context", async ({ page }) => {
|
||||||
|
await enableAppContext(page);
|
||||||
|
await page.goto("/#/sessions?period=total");
|
||||||
|
await page.getByRole("link", { name: "Download total CSV" }).click();
|
||||||
|
expect(await expectAppEvent(page)).toMatchObject({
|
||||||
|
type: "download",
|
||||||
|
url: expect.stringContaining("/api/sessions?format=csv&lang=en"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("session details", async () => {
|
test.describe("session details", async () => {
|
||||||
test("show session details (session 5)", async ({ page }) => {
|
test("show session details (session 5)", async ({ page }) => {
|
||||||
await page.goto("/#/sessions?year=2023&month=5");
|
await page.goto("/#/sessions?year=2023&month=5");
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,33 @@ export async function getDatalistOptions(input: Locator): Promise<string[]> {
|
||||||
return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value);
|
return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AppState = {
|
||||||
|
evccAppCapabilities: string[];
|
||||||
|
__appEvent: unknown;
|
||||||
|
ReactNativeWebView: { postMessage: (m: string) => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enableAppContext(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, "userAgent", {
|
||||||
|
value: "evcc/playwright",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const w = window as unknown as AppState;
|
||||||
|
w.evccAppCapabilities = ["download"];
|
||||||
|
w.__appEvent = undefined;
|
||||||
|
w.ReactNativeWebView = {
|
||||||
|
postMessage: (msg: string) => {
|
||||||
|
w.__appEvent = JSON.parse(msg);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAppEvent(page: Page): Promise<unknown> {
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => (window as unknown as AppState).__appEvent !== undefined))
|
||||||
|
.toBe(true);
|
||||||
|
return page.evaluate(() => (window as unknown as AppState).__appEvent);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue