Basic node registration form.
This commit is contained in:
parent
fb73dac224
commit
fb5bf934ff
40
frontend/src/components/ExpandableHelpBox.vue
Normal file
40
frontend/src/components/ExpandableHelpBox.vue
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
function toggleExpansion() {
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<i
|
||||||
|
class="fa fa-question-circle help-icon"
|
||||||
|
@click.prevent="toggleExpansion"
|
||||||
|
aria-hidden="true"
|
||||||
|
title="Hilfe"
|
||||||
|
/>
|
||||||
|
<p v-if="expanded" class="help-text">
|
||||||
|
{{ text }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../scss/variables";
|
||||||
|
|
||||||
|
.help-icon {
|
||||||
|
color: $help-icon-color;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: $help-text-margin;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCurrentInstance, onMounted, ref } from "vue";
|
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
||||||
import { type Constraint, forConstraint } from "@/shared/validation/validator";
|
import { type Constraint, forConstraint } from "@/shared/validation/validator";
|
||||||
|
import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
|
@ -9,6 +10,7 @@ interface Props {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
constraint: Constraint;
|
constraint: Constraint;
|
||||||
validationError: string;
|
validationError: string;
|
||||||
|
help?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
@ -16,6 +18,10 @@ const emit = defineEmits<{
|
||||||
(e: "update:modelValue", value: string): void;
|
(e: "update:modelValue", value: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const displayLabel = computed(() =>
|
||||||
|
props.constraint.optional ? props.label : `${props.label}*`
|
||||||
|
);
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>();
|
const input = ref<HTMLInputElement>();
|
||||||
const valid = ref(true);
|
const valid = ref(true);
|
||||||
const validated = ref(false);
|
const validated = ref(false);
|
||||||
|
@ -68,19 +74,20 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="validation-form-input">
|
||||||
<label>
|
<label>
|
||||||
{{ label }}:
|
{{ displayLabel }}:
|
||||||
|
<ExpandableHelpBox v-if="props.help" :text="props.help" />
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
:value="modelValue"
|
:value="props.modelValue"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
:type="type || 'text'"
|
:type="props.type || 'text'"
|
||||||
:placeholder="placeholder"
|
:placeholder="props.placeholder"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div class="validation-error" v-if="!valid">
|
<div class="validation-error" v-if="!valid">
|
||||||
{{ validationError }}
|
{{ props.validationError }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -88,6 +95,10 @@ onMounted(() => {
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import "../../scss/variables";
|
@import "../../scss/variables";
|
||||||
|
|
||||||
|
.validation-form-input {
|
||||||
|
margin: $validation-form-input-margin;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: $label-font-weight;
|
font-weight: $label-font-weight;
|
||||||
|
|
212
frontend/src/components/nodes/NodeCreateForm.vue
Normal file
212
frontend/src/components/nodes/NodeCreateForm.vue
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useConfigStore } from "@/stores/config";
|
||||||
|
import { useNodeStore } from "@/stores/node";
|
||||||
|
import { computed, nextTick, ref } from "vue";
|
||||||
|
import CONSTRAINTS from "@/shared/validation/constraints";
|
||||||
|
import ActionButton from "@/components/form/ActionButton.vue";
|
||||||
|
import type {
|
||||||
|
Coordinates,
|
||||||
|
EmailAddress,
|
||||||
|
FastdKey,
|
||||||
|
Hostname,
|
||||||
|
MAC,
|
||||||
|
Nickname,
|
||||||
|
StoredNode,
|
||||||
|
} from "@/types";
|
||||||
|
import {
|
||||||
|
ButtonSize,
|
||||||
|
ComponentAlignment,
|
||||||
|
ComponentVariant,
|
||||||
|
hasOwnProperty,
|
||||||
|
} from "@/types";
|
||||||
|
import ErrorCard from "@/components/ErrorCard.vue";
|
||||||
|
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||||
|
import ValidationForm from "@/components/form/ValidationForm.vue";
|
||||||
|
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||||
|
import { route, RouteName } from "@/router";
|
||||||
|
import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
|
import { ApiError } from "@/utils/Api";
|
||||||
|
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
const nodeStore = useNodeStore();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "create", node: StoredNode): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const generalError = ref<boolean>(false);
|
||||||
|
|
||||||
|
const CONFLICT_MESSAGES: Record<string, string> = {
|
||||||
|
hostname: "Der Knotenname ist bereits vergeben. Bitte wähle einen anderen.",
|
||||||
|
key: "Für den VPN-Schlüssel gibt es bereits einen Eintrag.",
|
||||||
|
mac: "Für die MAC-Adresse gibt es bereits einen Eintrag.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const conflictErrorMessage = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const hostname = ref("" as Hostname);
|
||||||
|
const fastdKey = ref("" as FastdKey);
|
||||||
|
const mac = ref("" as MAC);
|
||||||
|
const coords = ref("" as Coordinates);
|
||||||
|
const nickname = ref("" as Nickname);
|
||||||
|
const email = ref("" as EmailAddress);
|
||||||
|
const monitoring = ref(false);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
generalError.value = false;
|
||||||
|
conflictErrorMessage.value = undefined;
|
||||||
|
|
||||||
|
// Make sure to re-render error message to trigger scrolling into view.
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const node = await nodeStore.create({
|
||||||
|
hostname: hostname.value,
|
||||||
|
key: fastdKey.value || undefined,
|
||||||
|
mac: mac.value,
|
||||||
|
coords: coords.value || undefined,
|
||||||
|
nickname: nickname.value,
|
||||||
|
email: email.value,
|
||||||
|
monitoring: monitoring.value,
|
||||||
|
});
|
||||||
|
emit("create", node);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
const conflictingField = error.getConflictField();
|
||||||
|
if (
|
||||||
|
conflictingField !== undefined &&
|
||||||
|
hasOwnProperty(CONFLICT_MESSAGES, conflictingField)
|
||||||
|
) {
|
||||||
|
conflictErrorMessage.value =
|
||||||
|
CONFLICT_MESSAGES[conflictingField];
|
||||||
|
} else {
|
||||||
|
generalError.value = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ValidationForm novalidate="" ref="form" @submit="onSubmit">
|
||||||
|
<h2>Neuen Knoten anmelden</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Damit Dein neuer Freifunk-Router erfolgreich ins Netz
|
||||||
|
eingebunden werden kann, benötigen wir noch ein paar angaben von
|
||||||
|
Dir. Sobald Du fertig bist, kannst Du durch einen Klick auf
|
||||||
|
"Knoten anmelden" die Anmeldung abschließen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Und keine Sorge:
|
||||||
|
<strong>Datenschutz ist uns genauso wichtig wie Dir.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ErrorCard v-if="conflictErrorMessage">{{
|
||||||
|
conflictErrorMessage
|
||||||
|
}}</ErrorCard>
|
||||||
|
<ErrorCard v-if="generalError">
|
||||||
|
Beim Anlegen des Knotens ist ein Fehler aufgetreten. Bitte
|
||||||
|
probiere es später nochmal. Sollte dieses Problem weiter
|
||||||
|
bestehen, so wende dich bitte per E-Mail an
|
||||||
|
<a :href="`mailto:${email}`">{{ email }}</a
|
||||||
|
>.
|
||||||
|
</ErrorCard>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<h3>Knotendaten</h3>
|
||||||
|
|
||||||
|
<ValidationFormInput
|
||||||
|
v-model="hostname"
|
||||||
|
label="Knotenname"
|
||||||
|
placeholder="z. B. Lisas-Freifunk"
|
||||||
|
:constraint="CONSTRAINTS.node.hostname"
|
||||||
|
help="Das ist der Name, der auch auf der Karte auftaucht."
|
||||||
|
validation-error="Knotennamen dürfen maximal 32 Zeichen lang sein und nur Klein- und Großbuchstaben, sowie Ziffern, - und _ enthalten."
|
||||||
|
/>
|
||||||
|
<ValidationFormInput
|
||||||
|
v-model="fastdKey"
|
||||||
|
label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)"
|
||||||
|
placeholder="Dein 64-stelliger VPN-Schlüssel"
|
||||||
|
:constraint="CONSTRAINTS.node.key"
|
||||||
|
help="Dieser Schlüssel wird verwendet, um die Verbindung Deines Routers zu den Gateway-Servern abzusichern."
|
||||||
|
validation-error="Knotennamen dürfen maximal 32 Zeichen lang sein und nur Klein- und Großbuchstaben, sowie Ziffern, - und _ enthalten."
|
||||||
|
/>
|
||||||
|
<ValidationFormInput
|
||||||
|
v-model="mac"
|
||||||
|
label="MAC-Adresse"
|
||||||
|
placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc"
|
||||||
|
:constraint="CONSTRAINTS.node.mac"
|
||||||
|
help="Die MAC-Adresse (kurz „MAC“) steht üblicherweise auf dem Aufkleber auf der Unterseite deines Routers. Sie wird verwendet, um die Daten Deines Routers auf der Karte korrekt zuzuordnen."
|
||||||
|
validation-error="Die angegebene MAC-Adresse ist ungültig."
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h1>TODO: Standort</h1>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<h3>Wie können wir Dich erreichen?</h3>
|
||||||
|
|
||||||
|
<p class="help-block">
|
||||||
|
Deinen Namen und Deine E-Mail-Adresse verwenden wir
|
||||||
|
ausschließlich, um bei Problemen mit Deinem Router oder bei
|
||||||
|
wichtigen Änderungen Kontakt zu Dir aufzunehmen. Bitte trage
|
||||||
|
eine gültige E-Mail-Adresse ein, damit wir Dich im Zweifel
|
||||||
|
erreichen können. Deine persönlichen Daten sind
|
||||||
|
selbstverständlich
|
||||||
|
<strong>nicht öffentlich einsehbar</strong> und werden von
|
||||||
|
uns <strong>nicht weitergegeben</strong>
|
||||||
|
oder anderweitig verwendet. Versprochen!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ValidationFormInput
|
||||||
|
v-model="nickname"
|
||||||
|
label="Nickname / Name"
|
||||||
|
placeholder="z. B. Lisa"
|
||||||
|
:constraint="CONSTRAINTS.node.nickname"
|
||||||
|
validation-error="Nicknames dürfen maximal 64 Zeichen lang sein und nur Klein- und Großbuchstaben, sowie Ziffern, - und _ enthalten. Umlaute sind erlaubt."
|
||||||
|
/>
|
||||||
|
<ValidationFormInput
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
label="E-Mail-Adresse"
|
||||||
|
:placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`"
|
||||||
|
:constraint="CONSTRAINTS.node.email"
|
||||||
|
validation-error="Die angegebene E-Mail-Adresse ist ungültig."
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h1>TODO: Monitoring</h1>
|
||||||
|
|
||||||
|
<ButtonGroup
|
||||||
|
:align="ComponentAlignment.RIGHT"
|
||||||
|
:button-size="ButtonSize.SMALL"
|
||||||
|
>
|
||||||
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
icon="dot-circle-o"
|
||||||
|
:variant="ComponentVariant.INFO"
|
||||||
|
:size="ButtonSize.SMALL"
|
||||||
|
>
|
||||||
|
Knoten anmelden
|
||||||
|
</ActionButton>
|
||||||
|
<RouteButton
|
||||||
|
type="reset"
|
||||||
|
icon="times"
|
||||||
|
:variant="ComponentVariant.SECONDARY"
|
||||||
|
:size="ButtonSize.SMALL"
|
||||||
|
:route="route(RouteName.HOME)"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</RouteButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</ValidationForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
103
frontend/src/components/nodes/NodeCreatedPanel.vue
Normal file
103
frontend/src/components/nodes/NodeCreatedPanel.vue
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||||
|
import type { Hostname, StoredNode } from "@/types";
|
||||||
|
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
|
||||||
|
import { useConfigStore } from "@/stores/config";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
|
import { route, RouteName } from "@/router";
|
||||||
|
import NodePreviewCard from "@/components/nodes/NodePreviewCard.vue";
|
||||||
|
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
const email = computed(() => configStore.getConfig.community.contactEmail);
|
||||||
|
const mapUrl = computed(() => configStore.getConfig.map.mapUrl);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: StoredNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>Geschafft!</h2>
|
||||||
|
<p>
|
||||||
|
Dein Freifunk-Knoten ist erfolgreich angemeldet worden. Es kann
|
||||||
|
jetzt noch bis zu 20 Minuten dauern, bis Dein Knoten funktioniert
|
||||||
|
und in der
|
||||||
|
<a :href="mapUrl" target="_blank">Knotenkarte</a> auftaucht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Du kannst die Daten Deines Knotens später selber ändern. Dazu
|
||||||
|
benötigst Du das Token unten (16-stelliger Code).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="token-hint">Notiere Dir bitte folgendes Token:</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<span class="token">
|
||||||
|
<i class="fa fa-pencil"></i>
|
||||||
|
{{ props.node.token }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Hinweis:</strong>
|
||||||
|
Sollte Dein Knoten länger als drei Monate offline sein, so wird
|
||||||
|
dieser nach einer gewissen Zeit automatisch gelöscht. Du kannst
|
||||||
|
Deinen Knoten selbstverständlich jederzeit neu anmelden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Bei Fragen wende Dich gerne an
|
||||||
|
<a :href="`mailto:${email}`">{{ email }}</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<NodePreviewCard class="preview" :node="props.node" />
|
||||||
|
|
||||||
|
<ButtonGroup
|
||||||
|
:align="ComponentAlignment.CENTER"
|
||||||
|
:button-size="ButtonSize.MEDIUM"
|
||||||
|
>
|
||||||
|
<RouteButton
|
||||||
|
icon="reply"
|
||||||
|
:variant="ComponentVariant.SECONDARY"
|
||||||
|
:size="ButtonSize.MEDIUM"
|
||||||
|
:route="route(RouteName.HOME)"
|
||||||
|
>
|
||||||
|
Zurück zum Anfang
|
||||||
|
</RouteButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../scss/variables";
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.token {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
padding: $node-created-summary-padding;
|
||||||
|
|
||||||
|
background-color: $node-created-summary-background-color;
|
||||||
|
|
||||||
|
border: $node-created-summary-border;
|
||||||
|
border-radius: $node-created-summary-border-radius;
|
||||||
|
|
||||||
|
font-size: $node-created-summary-font-size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
margin: {
|
||||||
|
top: 1.5em;
|
||||||
|
bottom: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -23,7 +23,7 @@ const emit = defineEmits<{
|
||||||
(e: "submit", node: StoredNode): void;
|
(e: "submit", node: StoredNode): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const token = ref("");
|
const token = ref("" as Token);
|
||||||
const nodeNotFound = ref<boolean>(false);
|
const nodeNotFound = ref<boolean>(false);
|
||||||
const generalError = ref<boolean>(false);
|
const generalError = ref<boolean>(false);
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ async function onSubmit() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const node = await nodeStore.fetchByToken(token.value as Token);
|
const node = await nodeStore.fetchByToken(token.value);
|
||||||
emit("submit", node);
|
emit("submit", node);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
|
@ -86,6 +86,7 @@ async function onSubmit() {
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
|
class="token-input"
|
||||||
v-model="token"
|
v-model="token"
|
||||||
label="Token"
|
label="Token"
|
||||||
placeholder="Dein 16-stelliger Token"
|
placeholder="Dein 16-stelliger Token"
|
||||||
|
@ -130,4 +131,8 @@ async function onSubmit() {
|
||||||
</ValidationForm>
|
</ValidationForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
<style lang="scss" scoped>
|
||||||
|
.token-input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -20,7 +20,7 @@ const props = defineProps<Props>();
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>Erledigt!</h1>
|
<h2>Erledigt!</h2>
|
||||||
<p>
|
<p>
|
||||||
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann
|
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann
|
||||||
jetzt noch bis zu 20 Minuten dauern, bis die Änderungen überall
|
jetzt noch bis zu 20 Minuten dauern, bis die Änderungen überall
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
import AdminDashboardView from "@/views/AdminDashboardView.vue";
|
import AdminDashboardView from "@/views/AdminDashboardView.vue";
|
||||||
import AdminNodesView from "@/views/AdminNodesView.vue";
|
import AdminNodesView from "@/views/AdminNodesView.vue";
|
||||||
import HomeView from "@/views/HomeView.vue";
|
import HomeView from "@/views/HomeView.vue";
|
||||||
|
import NodeCreateView from "@/views/NodeCreateView.vue";
|
||||||
import NodeDeleteView from "@/views/NodeDeleteView.vue";
|
import NodeDeleteView from "@/views/NodeDeleteView.vue";
|
||||||
import {
|
import {
|
||||||
isNodesFilter,
|
isNodesFilter,
|
||||||
|
@ -21,6 +22,7 @@ export interface Route {
|
||||||
|
|
||||||
export enum RouteName {
|
export enum RouteName {
|
||||||
HOME = "home",
|
HOME = "home",
|
||||||
|
NODE_CREATE = "node-create",
|
||||||
NODE_DELETE = "node-delete",
|
NODE_DELETE = "node-delete",
|
||||||
ADMIN = "admin",
|
ADMIN = "admin",
|
||||||
ADMIN_NODES = "admin-nodes",
|
ADMIN_NODES = "admin-nodes",
|
||||||
|
@ -41,6 +43,11 @@ const router = createRouter({
|
||||||
name: RouteName.HOME,
|
name: RouteName.HOME,
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/node/create",
|
||||||
|
name: RouteName.NODE_CREATE,
|
||||||
|
component: NodeCreateView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/node/delete",
|
path: "/node/delete",
|
||||||
name: RouteName.NODE_DELETE,
|
name: RouteName.NODE_DELETE,
|
||||||
|
|
|
@ -87,6 +87,7 @@ $fieldset-padding: 0.5em 0.75em;
|
||||||
$fieldset-border: 0.1em solid $gray;
|
$fieldset-border: 0.1em solid $gray;
|
||||||
$fieldset-border-radius: 0.5em;
|
$fieldset-border-radius: 0.5em;
|
||||||
$fieldset-background-color: $gray-darker;
|
$fieldset-background-color: $gray-darker;
|
||||||
|
$validation-form-input-margin: 1em 0;
|
||||||
|
|
||||||
// Inputs
|
// Inputs
|
||||||
$input-padding: 0.25em 0.5em;
|
$input-padding: 0.25em 0.5em;
|
||||||
|
@ -110,6 +111,10 @@ $button-sizes: (
|
||||||
large: 1.15em,
|
large: 1.15em,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Help
|
||||||
|
$help-icon-color: $variant-color-info;
|
||||||
|
$help-text-margin: 0.5em 0 0.75em 0;
|
||||||
|
|
||||||
// Error cards
|
// Error cards
|
||||||
$error-card-margin: 1em 0;
|
$error-card-margin: 1em 0;
|
||||||
$error-card-padding: 0.5em;
|
$error-card-padding: 0.5em;
|
||||||
|
@ -136,6 +141,13 @@ $node-preview-card-map-margin: 1em 0 0.25em 0;
|
||||||
$node-preview-card-field-margin: 0.25em 0;
|
$node-preview-card-field-margin: 0.25em 0;
|
||||||
$node-preview-card-field-code-font-size: 1.25em;
|
$node-preview-card-field-code-font-size: 1.25em;
|
||||||
|
|
||||||
|
// Created node summary
|
||||||
|
$node-created-summary-padding: $node-preview-card-padding;
|
||||||
|
$node-created-summary-background-color: darken($variant-color-success, 30%);
|
||||||
|
$node-created-summary-border: 0.2em dashed $variant-color-success;
|
||||||
|
$node-created-summary-border-radius: $node-preview-card-border-radius;
|
||||||
|
$node-created-summary-font-size: 1.25em;
|
||||||
|
|
||||||
// Deleted node summary
|
// Deleted node summary
|
||||||
$node-deleted-summary-padding: $node-preview-card-padding;
|
$node-deleted-summary-padding: $node-preview-card-padding;
|
||||||
$node-deleted-summary-border: $node-preview-card-border;
|
$node-deleted-summary-border: $node-preview-card-border;
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { isStoredNode, type StoredNode, type Token } from "@/types";
|
import {
|
||||||
|
type CreateOrUpdateNode,
|
||||||
|
isNodeTokenResponse,
|
||||||
|
isStoredNode,
|
||||||
|
type StoredNode,
|
||||||
|
type Token,
|
||||||
|
} from "@/types";
|
||||||
import { api } from "@/utils/Api";
|
import { api } from "@/utils/Api";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
@ -12,6 +18,11 @@ export const useNodeStore = defineStore({
|
||||||
},
|
},
|
||||||
getters: {},
|
getters: {},
|
||||||
actions: {
|
actions: {
|
||||||
|
async create(node: CreateOrUpdateNode): Promise<StoredNode> {
|
||||||
|
const response = await api.post("node", isNodeTokenResponse, node);
|
||||||
|
return response.node;
|
||||||
|
},
|
||||||
|
|
||||||
async fetchByToken(token: Token): Promise<StoredNode> {
|
async fetchByToken(token: Token): Promise<StoredNode> {
|
||||||
return await api.get(`node/${token}`, isStoredNode);
|
return await api.get(`node/${token}`, isStoredNode);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,8 +1,25 @@
|
||||||
import { SortDirection, toIsArray, type TypeGuard } from "@/types";
|
import {
|
||||||
|
hasOwnProperty,
|
||||||
|
isPlainObject,
|
||||||
|
isString,
|
||||||
|
type JSONValue,
|
||||||
|
SortDirection,
|
||||||
|
toIsArray,
|
||||||
|
type TypeGuard,
|
||||||
|
} from "@/types";
|
||||||
import type { Headers } from "request";
|
import type { Headers } from "request";
|
||||||
import { parseToInteger } from "@/utils/Numbers";
|
import { parseToInteger } from "@/utils/Numbers";
|
||||||
|
|
||||||
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
type Method = "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
|
||||||
|
enum Header {
|
||||||
|
CONTENT_TYPE = "Content-Type",
|
||||||
|
"X_TOTAL_COUNT" = "x-total-count",
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MimeType {
|
||||||
|
APPLICATION_JSON = "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
enum ApiErrorType {
|
enum ApiErrorType {
|
||||||
REQUEST_FAILED = "request_failed",
|
REQUEST_FAILED = "request_failed",
|
||||||
|
@ -11,13 +28,15 @@ enum ApiErrorType {
|
||||||
|
|
||||||
enum HttpStatusCode {
|
enum HttpStatusCode {
|
||||||
NOT_FOUND = 404,
|
NOT_FOUND = 404,
|
||||||
|
CONFLICT = 409,
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
private constructor(
|
private constructor(
|
||||||
message: string,
|
message: string,
|
||||||
private status: number,
|
private status: number,
|
||||||
private errorType: ApiErrorType
|
private errorType: ApiErrorType,
|
||||||
|
private body: JSONValue
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
@ -27,11 +46,17 @@ export class ApiError extends Error {
|
||||||
path: string,
|
path: string,
|
||||||
response: Response
|
response: Response
|
||||||
): Promise<ApiError> {
|
): Promise<ApiError> {
|
||||||
const body = await response.text();
|
const body: JSONValue =
|
||||||
|
response.headers.get(Header.CONTENT_TYPE) ===
|
||||||
|
MimeType.APPLICATION_JSON
|
||||||
|
? await response.json()
|
||||||
|
: await response.text();
|
||||||
|
|
||||||
return new ApiError(
|
return new ApiError(
|
||||||
`API ${method} request failed: ${path} => ${response.status} - ${body}`,
|
`API ${method} request failed: ${path} => ${response.status} - ${body}`,
|
||||||
response.status,
|
response.status,
|
||||||
ApiErrorType.REQUEST_FAILED
|
ApiErrorType.REQUEST_FAILED,
|
||||||
|
body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,12 +64,13 @@ export class ApiError extends Error {
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
response: Response,
|
response: Response,
|
||||||
json: unknown
|
json: JSONValue
|
||||||
): Promise<ApiError> {
|
): Promise<ApiError> {
|
||||||
return new ApiError(
|
return new ApiError(
|
||||||
`API ${method} request result has unexpected type. ${path} => ${json}`,
|
`API ${method} request result has unexpected type. ${path} => ${json}`,
|
||||||
response.status,
|
response.status,
|
||||||
ApiErrorType.UNEXPECTED_RESULT_TYPE
|
ApiErrorType.UNEXPECTED_RESULT_TYPE,
|
||||||
|
json
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,6 +80,26 @@ export class ApiError extends Error {
|
||||||
this.status === HttpStatusCode.NOT_FOUND
|
this.status === HttpStatusCode.NOT_FOUND
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isConflict(): boolean {
|
||||||
|
return (
|
||||||
|
this.errorType === ApiErrorType.REQUEST_FAILED &&
|
||||||
|
this.status === HttpStatusCode.CONFLICT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConflictField(): string | undefined {
|
||||||
|
if (
|
||||||
|
!this.isConflict() ||
|
||||||
|
!isPlainObject(this.body) ||
|
||||||
|
!hasOwnProperty(this.body, "field") ||
|
||||||
|
!isString(this.body.field)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.body.field;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PagedListResult<T> {
|
interface PagedListResult<T> {
|
||||||
|
@ -92,35 +138,32 @@ class Api {
|
||||||
return this.baseURL + this.apiPrefix + path + queryString;
|
return this.baseURL + this.apiPrefix + path + queryString;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendRequest(
|
|
||||||
method: Method,
|
|
||||||
path: string,
|
|
||||||
queryParams?: object
|
|
||||||
): Promise<ApiResponse<undefined>>;
|
|
||||||
private async sendRequest<T>(
|
|
||||||
method: Method,
|
|
||||||
path: string,
|
|
||||||
isT: TypeGuard<T>,
|
|
||||||
queryParams?: object
|
|
||||||
): Promise<ApiResponse<T>>;
|
|
||||||
private async sendRequest<T>(
|
private async sendRequest<T>(
|
||||||
method: Method,
|
method: Method,
|
||||||
path: string,
|
path: string,
|
||||||
isT?: TypeGuard<T>,
|
isT?: TypeGuard<T>,
|
||||||
|
bodyData?: object,
|
||||||
queryParams?: object
|
queryParams?: object
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const url = this.toURL(path, queryParams);
|
const url = this.toURL(path, queryParams);
|
||||||
const response = await fetch(url, {
|
const options: RequestInit = {
|
||||||
method,
|
method,
|
||||||
});
|
};
|
||||||
|
if (bodyData) {
|
||||||
|
options.body = JSON.stringify(bodyData);
|
||||||
|
options.headers = {
|
||||||
|
[Header.CONTENT_TYPE]: MimeType.APPLICATION_JSON,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await ApiError.requestFailed(method, path, response);
|
throw await ApiError.requestFailed(method, path, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isT) {
|
if (isT) {
|
||||||
const json = await response.json();
|
const json: JSONValue = await response.json();
|
||||||
if (isT && !isT(json)) {
|
if (!isT(json)) {
|
||||||
console.log(json);
|
console.log(json);
|
||||||
throw await ApiError.unexpectedResultType(
|
throw await ApiError.unexpectedResultType(
|
||||||
method,
|
method,
|
||||||
|
@ -142,16 +185,58 @@ class Api {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async post<T>(
|
||||||
|
path: string,
|
||||||
|
isT: TypeGuard<T>,
|
||||||
|
postData: object,
|
||||||
|
queryParams?: object
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await this.sendRequest<T>(
|
||||||
|
"POST",
|
||||||
|
path,
|
||||||
|
isT,
|
||||||
|
postData,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(
|
||||||
|
path: string,
|
||||||
|
isT: TypeGuard<T>,
|
||||||
|
putData: object,
|
||||||
|
queryParams?: object
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await this.sendRequest<T>(
|
||||||
|
"PUT",
|
||||||
|
path,
|
||||||
|
isT,
|
||||||
|
putData,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
private async doGet<T>(
|
private async doGet<T>(
|
||||||
path: string,
|
path: string,
|
||||||
isT: TypeGuard<T>,
|
isT: TypeGuard<T>,
|
||||||
queryParams?: object
|
queryParams?: object
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
return await this.sendRequest<T>("GET", path, isT, queryParams);
|
return await this.sendRequest<T>(
|
||||||
|
"GET",
|
||||||
|
path,
|
||||||
|
isT,
|
||||||
|
undefined,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get<T>(path: string, isT: TypeGuard<T>): Promise<T> {
|
async get<T>(
|
||||||
const response = await this.doGet(path, isT);
|
path: string,
|
||||||
|
isT: TypeGuard<T>,
|
||||||
|
queryParams?: object
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await this.doGet(path, isT, queryParams);
|
||||||
return response.result;
|
return response.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,7 +256,7 @@ class Api {
|
||||||
_sortField: sortField,
|
_sortField: sortField,
|
||||||
...filter,
|
...filter,
|
||||||
});
|
});
|
||||||
const totalStr = response.headers.get("x-total-count");
|
const totalStr = response.headers.get(Header.X_TOTAL_COUNT);
|
||||||
const total = parseToInteger(totalStr, 10);
|
const total = parseToInteger(totalStr, 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -23,13 +23,14 @@ import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
:align="ComponentAlignment.CENTER"
|
:align="ComponentAlignment.CENTER"
|
||||||
:button-size="ButtonSize.LARGE"
|
:button-size="ButtonSize.LARGE"
|
||||||
>
|
>
|
||||||
<ActionButton
|
<RouteButton
|
||||||
:variant="ComponentVariant.INFO"
|
:variant="ComponentVariant.INFO"
|
||||||
:size="ButtonSize.LARGE"
|
:size="ButtonSize.LARGE"
|
||||||
icon="dot-circle-o"
|
icon="dot-circle-o"
|
||||||
|
:route="route(RouteName.NODE_CREATE)"
|
||||||
>
|
>
|
||||||
Neuen Knoten anmelden
|
Neuen Knoten anmelden
|
||||||
</ActionButton>
|
</RouteButton>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
:variant="ComponentVariant.PRIMARY"
|
:variant="ComponentVariant.PRIMARY"
|
||||||
:size="ButtonSize.LARGE"
|
:size="ButtonSize.LARGE"
|
||||||
|
|
22
frontend/src/views/NodeCreateView.vue
Normal file
22
frontend/src/views/NodeCreateView.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PageContainer from "@/components/page/PageContainer.vue";
|
||||||
|
import NodeCreateForm from "@/components/nodes/NodeCreateForm.vue";
|
||||||
|
import NodeCreatedPanel from "@/components/nodes/NodeCreatedPanel.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { StoredNode, Token } from "@/types";
|
||||||
|
|
||||||
|
const createdNode = ref<StoredNode | undefined>(undefined);
|
||||||
|
|
||||||
|
async function onCreate(node: StoredNode) {
|
||||||
|
createdNode.value = node;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<NodeCreateForm v-if="!createdNode" @create="onCreate" />
|
||||||
|
<NodeCreatedPanel v-if="createdNode" :node="createdNode" />
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
Loading…
Reference in a new issue