Config UI: General layout (#13166)

This commit is contained in:
Michael Geers 2024-03-28 10:27:31 +01:00 • committed by GitHub
parent 1e6780b8b7
commit 198e94e695
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 351 additions and 145 deletions

View file

@ -0,0 +1,151 @@
<template>
<div class="group pt-4 px-4 pb-1">
<dl class="row" data-testid="generalconfig-title">
<dt class="col-sm-4">Title</dt>
<dd class="col-sm-8">
{{ title || "---" }}
<a
href="#"
class="ms-2 d-inline-block text-muted"
@click.prevent="openModal('titleModal')"
>
edit
</a>
<TitleModal ref="titleModal" @changed="load" />
</dd>
</dl>
<dl class="row">
<dt class="col-sm-4">Telemetry</dt>
<dd class="col-sm-8">
{{ telemetryEnabled ? "on" : "off" }}
<a
href="#"
class="ms-2 d-inline-block text-muted"
@click.prevent="openModal('globalSettingsModal')"
>
change
</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Password</dt>
<dd class="col-sm-8">
*******
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">API-Key</dt>
<dd class="col-sm-8">
*******
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">show</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Sponsoring</dt>
<dd class="col-sm-8">
<span class="text-primary"> valid </span>
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">change</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Server</dt>
<dd class="col-sm-8">
http://evcc.local:7070
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Update Interval</dt>
<dd class="col-sm-8">
30s
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
</dd>
</dl>
</div>
</template>
<script>
import Modal from "bootstrap/js/dist/modal";
import TitleModal from "./TitleModal.vue";
import api from "../../api";
import settings from "../../settings";
export default {
name: "GeneralConfig",
data() {
return {
title: "",
};
},
components: { TitleModal },
emits: ["site-changed"],
async mounted() {
await this.load();
},
computed: {
telemetryEnabled() {
return settings.telemetry === true;
},
},
methods: {
async changed() {
this.$emit("site-changed");
this.load();
},
async load() {
try {
let res = await api.get("/config/site");
this.title = res.data.result.title;
} catch (e) {
console.error(e);
}
},
todo() {
alert("not implemented");
},
openModal(id) {
const $el = document.getElementById(id);
if ($el) {
Modal.getOrCreateInstance($el).show();
} else {
console.error(`modal ${id} not found`);
}
},
},
};
</script>
<style scoped>
.group {
border-radius: 1rem;
box-shadow: 0 0 0 0 var(--evcc-gray-50);
color: var(--evcc-default-text);
background: var(--evcc-box);
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
min-height: 10rem;
margin-bottom: 5rem;
border: 1px solid var(--evcc-gray-50);
transition: box-shadow var(--evcc-transition-fast) linear;
}
.group:hover {
border-color: var(--evcc-gray);
}
.group:focus-within {
box-shadow: 0 0 1rem 0 var(--evcc-gray-50);
}
.wip {
opacity: 0.2;
}
dt {
margin-bottom: 0.5rem;
}
dd {
margin-bottom: 1rem;
}
</style>

View file

@ -1,124 +0,0 @@
<template>
<div class="group p-4 pb-2">
<form class="container mx-0 px-0">
<FormRow id="siteTitle" label="Site title">
<input
id="siteTitle"
v-model="modifiedFormData.title"
class="form-control"
placeholder="Home"
/>
</FormRow>
<FormRow id="adminPassword" label="Admin password" class="wip">
<input
id="adminPassword"
type="password"
value="- not set -"
class="form-control"
disabled
/>
</FormRow>
<FormRow id="sponsorToken" label="Sponsor token" class="wip">
<textarea id="sponsorToken" class="form-control" rows="3" disabled value="tba" />
</FormRow>
<div class="my-4 d-flex justify-content-end">
<button type="reset" class="btn btn-link text-muted" @click.prevent="reset">
{{ $t("config.site.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="!saveNeeded || saving"
@click.prevent="save"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.site.save") }}
</button>
</div>
</form>
</div>
</template>
<script>
import api from "../../api";
import FormRow from "../FormRow.vue";
export default {
name: "SiteSettings",
components: { FormRow },
data() {
return {
originalFormData: { title: "" },
modifiedFormData: { title: "" },
saving: false,
};
},
emits: ["site-changed"],
async mounted() {
await this.load();
},
computed: {
saveNeeded() {
return JSON.stringify(this.modifiedFormData) !== JSON.stringify(this.originalFormData);
},
},
methods: {
async load() {
try {
const { data } = await api.get("/config/site");
this.originalFormData.title = data.result.title;
} catch (e) {
console.error(e);
}
this.modifiedFormData = { ...this.originalFormData };
},
async save() {
this.saving = true;
try {
await api.put("/config/site", this.modifiedFormData);
this.$emit("site-changed");
} catch (e) {
console.error(e);
}
this.saving = false;
await this.load();
},
reset() {
this.modifiedFormData = { ...this.originalFormData };
},
},
};
</script>
<style scoped>
.group {
border-radius: 1rem;
box-shadow: 0 0 0 0 var(--evcc-gray-50);
color: var(--evcc-default-text);
background: var(--evcc-box);
padding: 1rem 1rem 0.5rem;
display: block;
list-style-type: none;
min-height: 10rem;
margin-bottom: 5rem;
border: 1px solid var(--evcc-gray-50);
transition: box-shadow var(--evcc-transition-fast) linear;
}
.group:hover {
border-color: var(--evcc-gray);
}
.group:focus-within {
box-shadow: 0 0 1rem 0 var(--evcc-gray-50);
}
.wip {
opacity: 0.2;
}
</style>

View file

@ -0,0 +1,90 @@
<template>
<GenericModal
id="titleModal"
ref="modal"
:title="$t('config.title.title')"
data-testid="title-modal"
>
<p class="text-danger" v-if="error">{{ error }}</p>
<form ref="form" class="container mx-0 px-0">
<FormRow
id="siteTitle"
:label="$t('config.title.label')"
:help="$t('config.title.description')"
>
<input id="siteTitle" v-model="title" class="form-control" />
</FormRow>
<div class="my-4 d-flex justify-content-between">
<button type="button" class="btn btn-link text-muted" data-bs-dismiss="modal">
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="saving"
@click.prevent="save"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import GenericModal from "../GenericModal.vue";
import FormRow from "./FormRow.vue";
import api from "../../api";
export default {
name: "TitleModal",
components: { FormRow, GenericModal },
emits: ["changed"],
data() {
return {
saving: false,
error: "",
title: "",
};
},
async mounted() {
await this.load();
},
methods: {
async load() {
try {
const { data } = await api.get("/config/site");
this.title = data.result.title;
} catch (e) {
console.error(e);
}
},
async save() {
this.saving = true;
this.error = "";
try {
await api.put("/config/site", { title: this.title });
this.$emit("changed");
this.$refs.modal.close();
} catch (e) {
console.error(e);
this.error = e.response.data.error;
}
this.saving = false;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<Teleport to="body">
<div
:id="id"
ref="modal"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
:data-testid="dataTestid"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ title }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<slot />
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import Modal from "bootstrap/js/dist/modal";
export default {
name: "GenericModal",
props: {
id: String,
title: String,
dataTestid: String,
},
emits: ["visibilityChanged"],
watch: {
isModalVisible(visible) {
this.$emit("changed", visible);
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
},
methods: {
modalVisible() {
this.isModalVisible = true;
},
modalInvisible() {
this.isModalVisible = false;
},
open() {
Modal.getOrCreateInstance(this.$refs.modal).show();
},
close() {
Modal.getOrCreateInstance(this.$refs.modal).hide();
},
},
};
</script>

View file

@ -3,7 +3,7 @@
<div class="form-check form-switch my-3">
<input
id="telemetryEnabled"
v-model="enabled"
:checked="enabled"
class="form-check-input"
type="checkbox"
role="switch"
@ -75,6 +75,7 @@ export default {
}
try {
const response = await api.get("settings/telemetry");
console.log("update in settings", response.data.result);
settings.telemetry = response.data.result;
} catch (err) {
console.error(err);

View file

@ -35,7 +35,7 @@
</div>
<h2 class="my-4 mt-5">General</h2>
<SiteSettings @site-changed="siteChanged" />
<GeneralConfig @site-changed="siteChanged" />
<h2 class="my-4 mt-5">Grid, PV & Battery Systems</h2>
<ul class="p-0 config-list">
@ -236,14 +236,14 @@ import DeviceCard from "../components/Config/DeviceCard.vue";
import DeviceTags from "../components/Config/DeviceTags.vue";
import AddDeviceButton from "../components/Config/AddDeviceButton.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import SiteSettings from "../components/Config/SiteSettings.vue";
import GeneralConfig from "../components/Config/GeneralConfig.vue";
import formatter from "../mixins/formatter";
export default {
name: "Config",
components: {
TopHeader,
SiteSettings,
GeneralConfig,
VehicleIcon,
VehicleModal,
DeviceCard,
@ -457,10 +457,6 @@ export default {
grid-gap: 1rem;
margin-bottom: 5rem;
}
.wrapper {
max-width: 900px;
margin: 0 auto;
}
.wip {
opacity: 0.2 !important;
}

View file

@ -46,6 +46,10 @@ socLimit = "SoC Begrenzung"
example = "Beispiel"
optional = "optional"
[config.general]
cancel = "Abbrechen"
save = "Speichern"
[config.grid]
titleAdd = "Stromzähler (Netz) hinzufügen"
titleEdit = "Stromzähler (Netz) bearbeiten"
@ -72,9 +76,10 @@ validateSave = "Überprüfen & speichern"
titleAdd = "Zähler (PV) hinzufügen"
titleEdit = "Zähler (PV) bearbeiten"
[config.site]
cancel = "Abbrechen"
save = "Speichern"
[config.title]
description = "Wird in der Hauptansicht und im Browser-Tab angezeigt."
label = "Titel"
title = "Titel bearbeiten"
[config.validation]
failed = "fehlgeschlagen"

View file

@ -46,6 +46,10 @@ socLimit = "Limit"
example = "Example"
optional = "optional"
[config.general]
cancel = "Cancel"
save = "Save"
[config.grid]
titleAdd = "Add Grid Meter"
titleEdit = "Edit Grid Meter"
@ -72,9 +76,10 @@ validateSave = "Validate & save"
titleAdd = "Add Solar Meter"
titleEdit = "Edit Solar Meter"
[config.site]
cancel = "Cancel"
save = "Save"
[config.title]
description = "Displayed on main screen and browser tab."
label = "Title"
title = "Edit Title"
[config.validation]
failed = "failed"

View file

@ -187,7 +187,7 @@ test.describe("meters", async () => {
});
});
test.describe("site", async () => {
test.describe("general", async () => {
test("change site title", async ({ page }) => {
// initial value on main ui
await page.goto("/");
@ -195,15 +195,23 @@ test.describe("site", async () => {
// change value in config
await page.goto("/#/config");
await page.getByLabel("Site title").fill("Whoops World");
await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
const modal = page.getByTestId("title-modal");
await expect(modal).toBeVisible();
await modal.getByLabel("Title").fill("Whoops World");
// reset form to initial value
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByLabel("Site title")).toHaveValue("Hello World");
// close modal and ignore entry on cancel
await modal.getByRole("button", { name: "Cancel" }).click();
await expect(modal).not.toBeVisible();
await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
// change and save value
await page.getByLabel("Site title").fill("Ahoy World");
await page.getByRole("button", { name: "Save" }).click();
await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
await modal.getByLabel("Title").fill("Ahoy World");
await modal.getByRole("button", { name: "Save" }).click();
await expect(modal).not.toBeVisible();
await expect(page.getByTestId("generalconfig-title")).toContainText("Ahoy World");
// check changed value on main ui
await page.getByTestId("home-link").click();