Sessions UI: make odometer editable (#30437)
This commit is contained in:
parent
7b5df178ce
commit
5920e56ff8
9 changed files with 218 additions and 26 deletions
|
|
@ -121,12 +121,48 @@
|
|||
{{ socRange }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="session.odometer" data-testid="session-details-odometer">
|
||||
<th>
|
||||
<tr data-testid="session-details-odometer">
|
||||
<th class="align-middle">
|
||||
{{ $t("session.odometer") }}
|
||||
</th>
|
||||
<td>
|
||||
{{ formatKm(session.odometer) }}
|
||||
<div class="odometer-cell d-flex align-items-center">
|
||||
<div
|
||||
v-if="editingOdometer"
|
||||
class="input-group input-group-sm odometer-input w-auto"
|
||||
>
|
||||
<input
|
||||
ref="odometerInput"
|
||||
v-model="odometerInput"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
class="form-control grow-0 text-end"
|
||||
:aria-label="$t('session.odometer')"
|
||||
aria-describedby="sessionDetailsOdometerUnit"
|
||||
@blur="saveOdometer"
|
||||
@keydown.enter.prevent="saveOdometer"
|
||||
@keydown.esc.stop="cancelOdometer"
|
||||
/>
|
||||
<span id="sessionDetailsOdometerUnit" class="input-group-text">
|
||||
{{ distanceUnitLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn-reset text-decoration-underline"
|
||||
:class="{ 'text-muted': !session.odometer }"
|
||||
:aria-label="$t('session.odometer')"
|
||||
@click="startEditOdometer"
|
||||
>
|
||||
{{
|
||||
session.odometer
|
||||
? formatKm(session.odometer)
|
||||
: $t("session.addOdometer")
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="session.meterStart" data-testid="session-details-meter">
|
||||
|
|
@ -181,7 +217,7 @@ import formatter from "@/mixins/formatter";
|
|||
import Options from "../Vehicles/Options.vue";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import GenericModal from "../Helper/GenericModal.vue";
|
||||
import { distanceUnit, distanceValue } from "@/units";
|
||||
import { distanceUnit, distanceValue, distanceValueReverse } from "@/units";
|
||||
import api from "@/api";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { Session } from "./types";
|
||||
|
|
@ -198,7 +234,17 @@ export default defineComponent({
|
|||
loadpoints: { type: Array as PropType<string[]>, default: () => [] },
|
||||
},
|
||||
emits: ["session-changed"],
|
||||
data() {
|
||||
return {
|
||||
editingOdometer: false,
|
||||
odometerInput: "" as number | string,
|
||||
odometerOriginal: "" as number | string,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
distanceUnitLabel(): string {
|
||||
return distanceUnit();
|
||||
},
|
||||
chargedEnergy() {
|
||||
return this.session.chargedEnergy * 1e3;
|
||||
},
|
||||
|
|
@ -246,6 +292,26 @@ export default defineComponent({
|
|||
formatKm(value: number) {
|
||||
return `${this.fmtNumber(distanceValue(value), 0)} ${distanceUnit()}`;
|
||||
},
|
||||
startEditOdometer() {
|
||||
this.odometerInput = this.session.odometer
|
||||
? Math.round(distanceValue(this.session.odometer))
|
||||
: "";
|
||||
this.odometerOriginal = this.odometerInput;
|
||||
this.editingOdometer = true;
|
||||
this.$nextTick(() => (this.$refs["odometerInput"] as HTMLInputElement)?.focus());
|
||||
},
|
||||
cancelOdometer() {
|
||||
this.editingOdometer = false;
|
||||
},
|
||||
async saveOdometer() {
|
||||
if (!this.editingOdometer) return;
|
||||
this.editingOdometer = false;
|
||||
const value = this.odometerInput;
|
||||
if (value === this.odometerOriginal) return;
|
||||
// empty input clears the stored value
|
||||
const odometer = value === "" ? null : distanceValueReverse(Number(value));
|
||||
await this.updateSession({ odometer });
|
||||
},
|
||||
async changeVehicle(title: string) {
|
||||
await this.updateSession({ vehicle: title });
|
||||
},
|
||||
|
|
@ -255,7 +321,9 @@ export default defineComponent({
|
|||
async changeLoadpoint(title: string) {
|
||||
await this.updateSession({ loadpoint: title });
|
||||
},
|
||||
async updateSession(data: Partial<Session> | { vehicle: null }) {
|
||||
async updateSession(
|
||||
data: Partial<Session> | { vehicle: null } | { odometer: number | null }
|
||||
) {
|
||||
try {
|
||||
await api.put("session/" + this.session.id, data);
|
||||
this.$emit("session-changed");
|
||||
|
|
@ -284,4 +352,23 @@ export default defineComponent({
|
|||
.options .loadpoint-name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.odometer-cell {
|
||||
/* reserve edit-input height so the row doesn't jump between view/edit */
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.odometer-input .form-control {
|
||||
/* fit ~7 digits (e.g. 128222) */
|
||||
width: calc(7ch + 1rem);
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.odometer-input .form-control::-webkit-inner-spin-button,
|
||||
.odometer-input .form-control::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ export function distanceValue(value: number) {
|
|||
return isMiles() ? value * MILES_FACTOR : value;
|
||||
}
|
||||
|
||||
export function distanceValueReverse(value: number) {
|
||||
return isMiles() ? value / MILES_FACTOR : value;
|
||||
}
|
||||
|
||||
export function distanceUnit() {
|
||||
return isMiles() ? "mi" : "km";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1403,6 +1403,7 @@
|
|||
"updatePassword": "Passwort ändern"
|
||||
},
|
||||
"session": {
|
||||
"addOdometer": "Wert eintragen",
|
||||
"cancel": "Abbrechen",
|
||||
"co2": "CO₂",
|
||||
"date": "Zeitraum",
|
||||
|
|
|
|||
|
|
@ -1403,6 +1403,7 @@
|
|||
"updatePassword": "Update password"
|
||||
},
|
||||
"session": {
|
||||
"addOdometer": "Add value",
|
||||
"cancel": "Cancel",
|
||||
"co2": "CO₂",
|
||||
"date": "Period",
|
||||
|
|
|
|||
|
|
@ -119,22 +119,30 @@ func updateSessionHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
var data struct {
|
||||
Vehicle, Loadpoint *string
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if data.Vehicle == nil && data.Loadpoint == nil {
|
||||
// only update fields present in the request; a null value clears the column
|
||||
updates := map[string]any{}
|
||||
if v, ok := body["vehicle"]; ok {
|
||||
updates["vehicle"] = v
|
||||
}
|
||||
if v, ok := body["loadpoint"]; ok {
|
||||
updates["loadpoint"] = v
|
||||
}
|
||||
if v, ok := body["odometer"]; ok {
|
||||
updates["odometer"] = v
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
jsonError(w, http.StatusBadRequest, errors.New("nothing to update"))
|
||||
return
|
||||
}
|
||||
|
||||
// https://github.com/evcc-io/evcc/issues/13738#issuecomment-2094070362
|
||||
if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(data); txn.Error != nil {
|
||||
if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(updates); txn.Error != nil {
|
||||
jsonError(w, http.StatusBadRequest, txn.Error)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -405,9 +405,7 @@
|
|||
"type": "number"
|
||||
},
|
||||
"odometer": {
|
||||
"description": "Vehicle odometer reading in kilometers",
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"$ref": "#/components/schemas/Odometer"
|
||||
},
|
||||
"vehicle": {
|
||||
"$ref": "#/components/schemas/VehicleName"
|
||||
|
|
@ -561,6 +559,11 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
"Odometer": {
|
||||
"description": "Vehicle odometer reading in kilometers",
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"Password": {
|
||||
"description": "Admin password",
|
||||
"type": "string"
|
||||
|
|
@ -1939,7 +1942,7 @@
|
|||
}
|
||||
],
|
||||
"put": {
|
||||
"description": "Update vehicle of charging session.",
|
||||
"description": "Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value.",
|
||||
"externalDocs": {
|
||||
"url": "https://docs.evcc.io/en/docs/features/sessions"
|
||||
},
|
||||
|
|
@ -1952,6 +1955,9 @@
|
|||
"loadpoint": {
|
||||
"$ref": "#/components/schemas/LoadpointName"
|
||||
},
|
||||
"odometer": {
|
||||
"$ref": "#/components/schemas/Odometer"
|
||||
},
|
||||
"vehicle": {
|
||||
"$ref": "#/components/schemas/VehicleName"
|
||||
}
|
||||
|
|
@ -1967,7 +1973,7 @@
|
|||
"description": "Success"
|
||||
}
|
||||
},
|
||||
"summary": "Update vehicle of charging session",
|
||||
"summary": "Update charging session",
|
||||
"tags": [
|
||||
"sessions"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -946,7 +946,7 @@ call getSessions {
|
|||
|
||||
## updateSession
|
||||
|
||||
Update vehicle of charging session.
|
||||
Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value.
|
||||
|
||||
**Tags:** sessions
|
||||
|
||||
|
|
|
|||
|
|
@ -696,8 +696,8 @@ paths:
|
|||
- $ref: "#/components/parameters/id"
|
||||
put:
|
||||
operationId: updateSession
|
||||
summary: Update vehicle of charging session
|
||||
description: "Update vehicle of charging session."
|
||||
summary: Update charging session
|
||||
description: "Update vehicle, loadpoint or odometer of a charging session. Only provided fields are changed; a null odometer clears the stored value."
|
||||
externalDocs:
|
||||
url: https://docs.evcc.io/en/docs/features/sessions
|
||||
tags:
|
||||
|
|
@ -713,6 +713,8 @@ paths:
|
|||
$ref: "#/components/schemas/VehicleName"
|
||||
loadpoint:
|
||||
$ref: "#/components/schemas/LoadpointName"
|
||||
odometer:
|
||||
$ref: "#/components/schemas/Odometer"
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
|
|
@ -1182,9 +1184,7 @@ components:
|
|||
vehicle:
|
||||
$ref: "#/components/schemas/VehicleName"
|
||||
odometer:
|
||||
nullable: true
|
||||
type: "number"
|
||||
description: Vehicle odometer reading in kilometers
|
||||
$ref: "#/components/schemas/Odometer"
|
||||
meterStart:
|
||||
nullable: true
|
||||
type: "number"
|
||||
|
|
@ -1305,6 +1305,10 @@ components:
|
|||
- "now"
|
||||
- "minpv"
|
||||
- "pv"
|
||||
Odometer:
|
||||
nullable: true
|
||||
type: "number"
|
||||
description: Vehicle odometer reading in kilometers
|
||||
Password:
|
||||
description: Admin password
|
||||
type: string
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { test, expect, devices, type Page } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils";
|
||||
import {
|
||||
expectModalVisible,
|
||||
expectModalHidden,
|
||||
enableAppContext,
|
||||
expectAppEvent,
|
||||
openMoreMenu,
|
||||
} from "./utils";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
|
|
@ -347,7 +353,7 @@ test.describe("session details", async () => {
|
|||
await expect(modal.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)");
|
||||
await expect(modal.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh");
|
||||
await expect(modal.getByTestId("session-details-co2")).toHaveCount(0);
|
||||
await expect(modal.getByTestId("session-details-odometer")).toHaveCount(0);
|
||||
await expect(modal.getByTestId("session-details-odometer")).toContainText("Add value");
|
||||
await expect(modal.getByTestId("session-details-meter")).toHaveCount(0);
|
||||
await expect(modal.getByTestId("session-details-delete")).toContainText("Delete");
|
||||
});
|
||||
|
|
@ -431,3 +437,78 @@ test.describe("session details", async () => {
|
|||
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("edit odometer", async () => {
|
||||
async function openFirstSession(page: Page) {
|
||||
await page.getByTestId("sessions-entry").nth(0).click();
|
||||
await expectModalVisible(page.getByTestId("session-details"));
|
||||
}
|
||||
|
||||
async function closeSession(page: Page) {
|
||||
const modal = page.getByTestId("session-details");
|
||||
await modal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(modal);
|
||||
}
|
||||
|
||||
async function setDistanceUnit(page: Page, unit: string) {
|
||||
await openMoreMenu(page);
|
||||
await page.getByRole("button", { name: "User Interface", exact: true }).click();
|
||||
const settings = page.getByTestId("global-settings-modal");
|
||||
await expectModalVisible(settings);
|
||||
await settings.getByRole("radio", { name: `Units: ${unit}` }).click();
|
||||
await settings.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(settings);
|
||||
}
|
||||
|
||||
test("edit existing value and convert between km and miles (session 1)", async ({ page }) => {
|
||||
const odometer = page.getByTestId("session-details").getByLabel("Mileage");
|
||||
|
||||
await page.goto("/#/sessions?year=2023&month=3");
|
||||
await openFirstSession(page);
|
||||
await expect(odometer).toContainText("12,345 km");
|
||||
await odometer.click();
|
||||
await expect(odometer).toHaveValue("12345");
|
||||
await odometer.fill("100000");
|
||||
await odometer.press("Enter");
|
||||
// value reloads from the server after save
|
||||
await expect(odometer).toContainText("100,000 km");
|
||||
|
||||
// switch to miles, value converts on display (100000 km -> 62137 mi)
|
||||
await closeSession(page);
|
||||
await setDistanceUnit(page, "miles");
|
||||
await openFirstSession(page);
|
||||
await expect(odometer).toContainText("62,137 mi");
|
||||
|
||||
// edit in miles
|
||||
await odometer.click();
|
||||
await expect(odometer).toHaveValue("62137");
|
||||
await odometer.fill("50000");
|
||||
await odometer.press("Enter");
|
||||
await expect(odometer).toContainText("50,000 mi");
|
||||
|
||||
// switch back to km, value round-trips (50000 mi -> ~80,467 km)
|
||||
await closeSession(page);
|
||||
await setDistanceUnit(page, "km");
|
||||
await openFirstSession(page);
|
||||
await expect(odometer).toContainText("80,467 km");
|
||||
});
|
||||
|
||||
test("add missing value (session 4)", async ({ page }) => {
|
||||
const odometer = page.getByTestId("session-details").getByLabel("Mileage");
|
||||
|
||||
await page.goto("/#/sessions?year=2023&month=5");
|
||||
await openFirstSession(page);
|
||||
await expect(odometer).toContainText("Add value");
|
||||
await odometer.click();
|
||||
await expect(odometer).toHaveValue("");
|
||||
await odometer.fill("1000");
|
||||
await odometer.press("Enter");
|
||||
await expect(odometer).toContainText("1,000 km");
|
||||
|
||||
// clearing the field deletes the value
|
||||
await odometer.click();
|
||||
await odometer.fill("");
|
||||
await odometer.press("Enter");
|
||||
await expect(odometer).toContainText("Add value");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue