Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(load-config): added upload config feature #135

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion public/schemas/.gitignore

This file was deleted.

116 changes: 106 additions & 10 deletions src/components/CreateConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@

<template>
<div class="btn-container">
<v-tooltip location="right" open-delay="500" v-if="state == ComponentStates.DEFAULT">
<template v-slot:activator="{ props }">
<v-btn color="default"
variant="flat"
density="compact"
icon="mdi-upload"
data-cy="upload-config-btn"
v-bind="props"
@click="uploadConfigPrompt()"
></v-btn>
</template>
<span>Upload Config</span>
</v-tooltip>
<v-tooltip location="right" open-delay="500" v-if="state == ComponentStates.DEFAULT">
<template v-slot:activator="{ props }">
<v-btn color="default"
Expand All @@ -24,7 +37,7 @@
data-cy="abort-create-config-btn"
icon="mdi-close"
v-bind="props"
@click="abortConfigCreation()"
@click="resetDialog()"
></v-btn>
</template>
<span>Abort</span>
Expand All @@ -38,7 +51,7 @@
data-cy="accept-create-config-btn"
v-bind="props"
:disabled="!configNameValid"
@click="createConfig()"
@click="onAcceptBtnClick()"
></v-btn>
</template>
<span>Create Config</span>
Expand All @@ -52,38 +65,81 @@
placeholder="config name"
:rules="[validateConfigName]"
></v-text-field>
<v-dialog v-model="showErrorDialog" @click:outside="resetDialog()">
<v-card color="danger">
<v-card-title>Couldn't load config</v-card-title>
<v-card-text>
<pre><code>{{ errors }}</code></pre>
</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="resetDialog()">OK</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script setup lang="ts">
import {computed, ref} from "vue";
import {useEvbcStore} from "@/store/evbc";
import {storeToRefs} from "pinia";
import yaml from "js-yaml";
import Ajv from "ajv";
import {EverestConfig} from "@/modules/evbc";
import {urlToPublicAsset} from "@/utils";

enum ComponentStates {
DEFAULT,
ASK_USER_FOR_CONFIG_NAME,
DEFAULT,
ASK_USER_FOR_CONFIG_NAME,
}
lukas-mertens marked this conversation as resolved.
Show resolved Hide resolved

const evbcStore = useEvbcStore();
const state = ref<ComponentStates>(ComponentStates.DEFAULT);
const configName = ref<string>("");
const configNameValid = computed<boolean>(() => validateConfigName() === true);
const emit = defineEmits<{
createConfig: [name: string],
createConfig: [name: string, content?: EverestConfig],
}>();
const {available_configs} = storeToRefs(evbcStore);
const configContent = ref<EverestConfig>(null);
const errors = ref<string>(null);
const showErrorDialog = computed<boolean>(() => !!errors.value);

function createConfig() {
function onAcceptBtnClick() {
if (validateConfigName() === true) {
emit("createConfig", configName.value);
state.value = ComponentStates.DEFAULT;
configName.value = "";
emit("createConfig", configName.value, configContent.value ?? undefined);
resetDialog();
}
}

function abortConfigCreation() {
function resetDialog() {
state.value = ComponentStates.DEFAULT;
configName.value = "";
configContent.value = null;
errors.value = null;
}

function uploadConfigPrompt() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,.yaml,.yml";
input.click();
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = async (e) => {
const parseResult = await parseConfig(e.target?.result as string);
if (!parseResult.errors) {
configContent.value = parseResult.config;
configName.value = file.name.replace(/\.[^.]+$/, ""); // remove file extension
state.value = ComponentStates.ASK_USER_FOR_CONFIG_NAME;
} else {
errors.value = parseResult.errors;
}
};
reader.readAsText(file);
}
};
}

/**
Expand All @@ -102,6 +158,46 @@ const {available_configs} = storeToRefs(evbcStore);
return true;
}
}

/**
* Validates that the config content is a valid JSON or YAML config
*/
async function validateConfigContent(content: object): Promise<true | string> {
const ajv = new Ajv();
const schema = await getConfigJsonSchema();
const validate = ajv.compile(schema);
const valid = validate(content);
if (valid) {
return true;
} else {
return JSON.stringify(validate.errors, null, 2);
}
}

async function getConfigJsonSchema(): Promise<object> {
const response = await fetch(urlToPublicAsset('schemas/config.json'));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}

/**
* Parse config
*/
async function parseConfig(content: string): Promise<{ errors: string, config: EverestConfig }> {
try {
const config = yaml.load(content);
const validationResult = await validateConfigContent(config);
if (validationResult === true) {
return {errors: null, config: config as EverestConfig};
} else {
return {errors: validationResult, config: null};
}
} catch (e) {
return {errors: e.toString(), config: null};
}
}
</script>


Expand Down
7 changes: 4 additions & 3 deletions src/components/EvModuleList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<template v-slot:activator="{ props }">
<v-list-item :title="config" v-bind="props" @click="load_config_if_empty(config)" data-cy="config-list-item">
<template v-slot:append>
<v-icon>mdi-upload</v-icon>
<v-icon>mdi-file-document-arrow-right</v-icon>
</template>
</v-list-item>
</template>
Expand Down Expand Up @@ -86,6 +86,7 @@ import EvDialog from "@/components/EvDialog.vue";
import EVConfigModel from "@/modules/evbc/config_model";
import {Notyf} from "notyf";
import CreateConfig from "@/components/CreateConfig.vue";
import {EverestConfig} from "@/modules/evbc";

let evbcStore: ReturnType<typeof useEvbcStore>;
let evbc: EVBackendClient;
Expand Down Expand Up @@ -171,8 +172,8 @@ export default defineComponent({
evbcStore.get_config_context().clicked_terminal(terminalToClick, added_module_id);
}
},
create_config(name: string) {
const new_config = evbc.create_empty_config(name);
create_config(name: string, content?: EverestConfig) {
const new_config = evbc.create_config_model(name, content);
evbcStore.setOpenedConfig(new_config);
this.expansionPanelState = ["modules"];
},
Expand Down
12 changes: 3 additions & 9 deletions src/modules/evbc/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2024 Pionix GmbH and Contributors to EVerest

import {
EventHandler,
EverestConfigList,
EverestDefinitions,
EverestInterfaceDefinitionList,
EverestModuleDefinitionList,
} from ".";
import {EventHandler, EverestConfig, EverestDefinitions,} from ".";
import EVConfigModel from "./config_model";
import EVBackendConnection, {ConnectionStatus} from "./connection";
import {useEvbcStore} from "@/store/evbc";
Expand Down Expand Up @@ -78,8 +72,8 @@ class EVBackendClient {
return new EVConfigModel(this.everest_definitions, name, config);
}

create_empty_config(name: string): EVConfigModel {
return new EVConfigModel(this.everest_definitions, name);
create_config_model(name: string, config?: EverestConfig): EVConfigModel {
return new EVConfigModel(this.everest_definitions, name, config ?? undefined);
}

async save_config(config: EVConfigModel) {
Expand Down
26 changes: 26 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {urlToPublicAsset} from "@/utils";
import { describe, it, expect, beforeAll, afterAll } from "vitest";

let originalBaseUrl: string;
describe("Utils", () => {
beforeAll(() => {
originalBaseUrl = import.meta.env.BASE_URL;
});
describe("urlToPublicAsset", () => {
it('should not produce double slashes', () => {
import.meta.env.BASE_URL = '/';
expect(urlToPublicAsset('/some-path')).toEqual('/some-path');
});
it('should add slash if necessary', () => {
import.meta.env.BASE_URL = 'localhost';
expect(urlToPublicAsset('some-path')).toEqual('localhost/some-path');
});
it('should deal with trailing base url slash and no path slash', () => {
import.meta.env.BASE_URL = 'localhost/';
expect(urlToPublicAsset('some-path')).toEqual('localhost/some-path');
});
});
afterAll(() => {
import.meta.env.BASE_URL = originalBaseUrl;
});
})
15 changes: 15 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
*
* @param filePath The path relative to public
*/
export function urlToPublicAsset(filePath: string): string {
let base: string = import.meta.env.BASE_URL;
if (!base.endsWith("/")) {
base += "/";
}
if (!filePath.startsWith('/')) {
return base + filePath;
} else {
return base + filePath.slice(1);
}
}
Loading