chore: Typescript: Rewrite Top (#21632)

This commit is contained in:
Maschga 2025-06-06 10:20:59 +02:00 • committed by GitHub
parent b8563f2d4a
commit b89be8fd8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 239 additions and 180 deletions

View file

@ -24,14 +24,15 @@
</header>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/settings";
import Navigation from "./Navigation.vue";
import collector from "@/mixins/collector";
import store from "@/store";
import { defineComponent } from "vue";
export default {
export default defineComponent({
name: "TopHeader",
components: {
TopNavigation: Navigation,
@ -47,7 +48,7 @@ export default {
return { vehicleLogins, ...this.collectProps(Navigation, store.state) };
},
},
};
});
</script>
<style scoped>

View file

@ -1,57 +0,0 @@
import Navigation from "./Navigation.vue";
export default {
title: "Top/Navigation",
component: Navigation,
parameters: {
layout: "centered",
},
argTypes: {
vehicleLogins: { control: "object" },
sponsor: { control: "object" },
},
};
const Template = (args) => ({
components: { Navigation },
setup() {
return { args };
},
template: '<Navigation v-bind="args" />',
});
export const Standard = Template.bind({});
Standard.args = {};
export const VehicleLogins = Template.bind({});
VehicleLogins.args = {
vehicleLogins: {
"Mercedes EQS": {
authenticated: true,
uri: "https://login-provider-a.test/",
},
"Nissan Leaf Pro": {
authenticated: true,
uri: "https://login-provider-b.test/",
},
},
};
export const PendingVehicleLogins = Template.bind({});
PendingVehicleLogins.args = {
vehicleLogins: {
"Mercedes EQS": {
authenticated: true,
uri: "https://login-provider-a.test/",
},
"Nissan Leaf Pro": {
authenticated: false,
uri: "https://login-provider-b.test/",
},
},
};
export const TokenExpires = Template.bind({});
TokenExpires.args = {
sponsor: { expiresSoon: true },
};

View file

@ -0,0 +1,62 @@
import Navigation from "./Navigation.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Top/Navigation",
component: Navigation,
parameters: {
layout: "centered",
},
argTypes: {
vehicleLogins: { control: "object" },
sponsor: { control: "object" },
},
} as Meta<typeof Navigation>;
const Template: StoryFn<typeof Navigation> = (args) => ({
components: { Navigation },
setup() {
return { args };
},
template: '<Navigation v-bind="args" />',
});
export const Standard = Template.bind({});
Standard.args = {};
export const VehicleLogins = Template.bind({});
VehicleLogins.args = {
vehicleLogins: {
"Mercedes EQS": {
authenticated: true,
uri: "https://login-provider-a.test/",
},
"Nissan Leaf Pro": {
authenticated: true,
uri: "https://login-provider-b.test/",
},
},
};
export const PendingVehicleLogins = Template.bind({});
PendingVehicleLogins.args = {
vehicleLogins: {
"Mercedes EQS": {
authenticated: true,
uri: "https://login-provider-a.test/",
},
"Nissan Leaf Pro": {
authenticated: false,
uri: "https://login-provider-b.test/",
},
},
};
export const TokenExpires = Template.bind({});
TokenExpires.args = {
sponsor: {
name: "Sponsor",
expiresAt: new Date().toISOString(),
expiresSoon: true,
},
};

View file

@ -123,7 +123,7 @@
</div>
</template>
<script>
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import Dropdown from "bootstrap/js/dist/dropdown";
import "@h2d2/shopicons/es/regular/gift";
@ -135,30 +135,34 @@ import { logout, isLoggedIn, openLoginModal } from "../Auth/auth";
import baseAPI from "./baseapi";
import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, VehicleLogins } from "@/types/evcc";
import type { Provider as Provider } from "./types";
export default {
export default defineComponent({
name: "TopNavigation",
mixins: [collector],
props: {
vehicleLogins: {
type: Object,
type: Object as PropType<VehicleLogins>,
default: () => {
return {};
},
},
sponsor: {
type: Object,
type: Object as PropType<Sponsor>,
default: () => {
return {};
},
},
forecast: Object,
battery: Array,
fatal: Object,
fatal: Object as PropType<FatalError>,
},
data() {
return {
isApp: isApp(),
dropdown: null as Dropdown | null,
};
},
computed: {
@ -168,7 +172,7 @@ export default {
logoutCount() {
return this.providerLogins.filter((login) => !login.loggedIn).length;
},
providerLogins() {
providerLogins(): Provider[] {
return Object.entries(this.vehicleLogins).map(([k, v]) => ({
title: k,
loggedIn: v.authenticated,
@ -205,13 +209,15 @@ export default {
if (!$el) {
return;
}
this.dropdown = new Dropdown(document.getElementById("topNavigatonDropdown"));
this.dropdown = new Dropdown(
document.getElementById("topNavigatonDropdown") as HTMLElement
);
},
unmounted() {
this.dropdown?.dispose();
},
methods: {
async handleProviderAuthorization(provider) {
async handleProviderAuthorization(provider: Provider) {
if (!provider.loggedIn) {
baseAPI.post(provider.loginPath).then(function (response) {
window.location.href = response.data.loginUri;
@ -221,21 +227,27 @@ export default {
}
},
openSettingsModal() {
const modal = Modal.getOrCreateInstance(document.getElementById("globalSettingsModal"));
const modal = Modal.getOrCreateInstance(
document.getElementById("globalSettingsModal") as HTMLElement
);
modal.show();
},
openHelpModal() {
const modal = Modal.getOrCreateInstance(document.getElementById("helpModal"));
const modal = Modal.getOrCreateInstance(
document.getElementById("helpModal") as HTMLElement
);
modal.show();
},
openBatterySettingsModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("batterySettingsModal")
document.getElementById("batterySettingsModal") as HTMLElement
);
modal.show();
},
openForecastModal() {
const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
const modal = Modal.getOrCreateInstance(
document.getElementById("forecastModal") as HTMLElement
);
modal.show();
},
openNativeSettings() {
@ -249,7 +261,7 @@ export default {
this.$router.push({ path: "/" });
},
},
};
});
</script>
<style scoped>
.menu-button {

View file

@ -1,80 +0,0 @@
import Notifications from "./Notifications.vue";
const timeAgo = (hours = 0, minutes = 0, seconds = 0) => {
const date = new Date();
date.setHours(date.getHours() - hours);
date.setMinutes(date.getMinutes() - minutes);
date.setSeconds(date.getSeconds() - seconds);
return date;
};
const notificationsData = [
{
message: "Server unavailable",
level: "error",
time: timeAgo(),
count: 1,
},
{
message: "charger out of sync: expected disabled, got enabled",
level: "warn",
count: 4,
time: timeAgo(0, 0, 42),
},
{
message: "Sponsortoken: x509: certificate has expired",
level: "error",
count: 1,
time: timeAgo(1, 12, 44),
},
{
message:
"Block device full: can not write to /tmp/evcc/foobarloremawefhwuiehfwuiehfwiauhefjkajowaeigjwalvmoweivwail",
level: "error",
count: 1,
time: timeAgo(1, 12, 44),
},
{
message: "charger out of sync: expected disabled, got enabled",
level: "warn",
count: 4,
time: timeAgo(1, 22, 0),
},
{
message: "vehicle remote charge start: invalid character '<' looking for beginning of value",
level: "warn",
count: 3,
time: timeAgo(4, 2, 0),
},
{
message:
"Amet irure quis incididunt voluptate esse. Commodo ea sunt est ipsum tempor nisi laboris voluptate labore elit laborum. Ex irure commodo reprehenderit consequat consequat do ad tempor aliquip deserunt eu. Laboris minim nostrud quis nisi. Dolor occaecat reprehenderit velit dolore exercitation cupidatat et voluptate. Nulla pariatur deserunt esse minim nisi nisi nulla. Sit eiusmod do incididunt sint minim pariatur aute.",
level: "warn",
count: 1,
time: timeAgo(5, 2, 44),
},
];
export default {
title: "Top/Notifications",
component: Notifications,
parameters: {
layout: "centered",
},
argTypes: {
notifications: { control: "object" },
},
};
const Template = (args) => ({
components: { Notifications },
setup() {
return { args };
},
template: '<Notifications v-bind="args" />',
});
export const Default = Template.bind({});
Default.args = {
notifications: notificationsData,
};

View file

@ -0,0 +1,90 @@
import Notifications from "./Notifications.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import type { Notification } from "./types";
const timeAgo = (hours = 0, minutes = 0, seconds = 0) => {
const date = new Date();
date.setHours(date.getHours() - hours);
date.setMinutes(date.getMinutes() - minutes);
date.setSeconds(date.getSeconds() - seconds);
return date;
};
const notificationsData: Notification[] = [
{
message: "Server unavailable",
level: "error",
time: timeAgo(),
count: 1,
lp: 1,
},
{
message: "charger out of sync: expected disabled, got enabled",
level: "warn",
count: 4,
time: timeAgo(0, 0, 42),
lp: 1,
},
{
message: "Sponsortoken: x509: certificate has expired",
level: "error",
count: 1,
time: timeAgo(1, 12, 44),
lp: 1,
},
{
message:
"Block device full: can not write to /tmp/evcc/foobarloremawefhwuiehfwuiehfwiauhefjkajowaeigjwalvmoweivwail",
level: "error",
count: 1,
time: timeAgo(1, 12, 44),
lp: 1,
},
{
message: "charger out of sync: expected disabled, got enabled",
level: "warn",
count: 4,
time: timeAgo(1, 22, 0),
lp: 1,
},
{
message:
"vehicle remote charge start: invalid character '<' looking for beginning of value",
level: "warn",
count: 3,
time: timeAgo(4, 2, 0),
lp: 1,
},
{
message:
"Amet irure quis incididunt voluptate esse. Commodo ea sunt est ipsum tempor nisi laboris voluptate labore elit laborum. Ex irure commodo reprehenderit consequat consequat do ad tempor aliquip deserunt eu. Laboris minim nostrud quis nisi. Dolor occaecat reprehenderit velit dolore exercitation cupidatat et voluptate. Nulla pariatur deserunt esse minim nisi nisi nulla. Sit eiusmod do incididunt sint minim pariatur aute.",
level: "warn",
count: 1,
time: timeAgo(5, 2, 44),
lp: 1,
},
];
export default {
title: "Top/Notifications",
component: Notifications,
parameters: {
layout: "centered",
},
argTypes: {
notifications: { control: "object" },
},
} as Meta<typeof Notifications>;
const Template: StoryFn<typeof Notifications> = (args) => ({
components: { Notifications },
setup() {
return { args };
},
template: '<Notifications v-bind="args" />',
});
export const Default = Template.bind({});
Default.args = {
notifications: notificationsData,
};

View file

@ -41,7 +41,7 @@
class="d-flex justify-content-end mt-3"
:title="fmtAbsoluteDate(msg.time)"
>
{{ fmtTimeAgo(msg.time - new Date()) }}
{{ fmtTimeAgo(msg.time.getTime() - new Date().getTime()) }}
</small>
<p class="d-flex align-items-baseline">
<shopicon-regular-exclamationtriangle
@ -86,16 +86,24 @@
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/exclamationtriangle";
import formatter from "@/mixins/formatter";
import { defineComponent, type PropType } from "vue";
import type { Notification } from "./types";
import type { Timeout } from "@/types/evcc";
export default {
export default defineComponent({
name: "Notifications",
mixins: [formatter],
props: {
notifications: Array,
loadpointTitles: Array,
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
loadpointTitles: { type: Array as PropType<string[]>, default: () => [] },
},
data() {
return {
interval: null as Timeout,
};
},
computed: {
iconVisible() {
@ -113,10 +121,12 @@ export default {
}, 10 * 1000);
},
unmounted() {
clearTimeout(this.interval);
if (this.interval) {
clearTimeout(this.interval);
}
},
methods: {
message({ message, lp }) {
message({ message, lp }: { message: string | string[]; lp?: number }) {
const lines = Array.isArray(message) ? message : [message];
if (lp) {
// add loadpoint title to first line
@ -128,5 +138,5 @@ export default {
window.app?.clear();
},
},
};
});
</script>

View file

@ -1,19 +0,0 @@
import axios from "axios";
const { protocol, hostname, port, pathname } = window.location;
const baseAPI = axios.create({
baseURL: protocol + "//" + hostname + (port ? ":" + port : "") + pathname,
});
// global error handling
baseAPI.interceptors.response.use(
(response) => response,
(error) => {
const url = error.config.baseURL + error.config.url;
const message = `${error.message}: API request failed ${url}`;
window.app.raise({ message });
}
);
export default baseAPI;

View file

@ -0,0 +1,19 @@
import axios from "axios";
const { protocol, hostname, port, pathname } = window.location;
const baseAPI = axios.create({
baseURL: protocol + "//" + hostname + (port ? ":" + port : "") + pathname,
});
// global error handling
baseAPI.interceptors.response.use(
(response) => response,
(error) => {
const url = error.config.baseURL + error.config.url;
const message = `${error.message}: API request failed ${url}`;
window.app.raise({ message });
}
);
export default baseAPI;

14
assets/js/components/Top/types.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
export interface Notification {
message: string;
time: Date;
level: string;
lp: number;
count: number;
}
export interface Provider {
title: string;
loggedIn: boolean;
loginPath: string;
logoutPath: string;
}

View file

@ -7,6 +7,12 @@ declare global {
}
}
export interface Auth {
vehicles: VehicleLogins;
}
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
export interface FatalError {
error: any;
class?: any;
@ -19,6 +25,7 @@ export interface State {
forecast?: Forecast;
currency?: CURRENCY;
fatal?: FatalError;
auth?: Auth;
}
export interface LoadpointCompact {