Add sorting by column and fix page reload issue.
This commit is contained in:
parent
ed29b96d45
commit
6057ad5a2a
|
@ -7,12 +7,14 @@ import {
|
||||||
NODES_FILTER_FIELDS,
|
NODES_FILTER_FIELDS,
|
||||||
type NodesFilter,
|
type NodesFilter,
|
||||||
OnlineState,
|
OnlineState,
|
||||||
|
type SearchTerm,
|
||||||
type UnixTimestampMilliseconds,
|
type UnixTimestampMilliseconds,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import {computed, nextTick, onMounted, ref, watch} from "vue";
|
import {computed, nextTick, onMounted, ref, watch} from "vue";
|
||||||
import {useConfigStore} from "@/stores/config";
|
import {useConfigStore} from "@/stores/config";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
searchTerm: SearchTerm;
|
||||||
filter: NodesFilter;
|
filter: NodesFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +43,7 @@ const FILTER_LABELS: Record<string, string | Map<any, any>> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "updateFilter", filter: NodesFilter, searchTerm?: string): void,
|
(e: "updateFilter", filter: NodesFilter, searchTerm: SearchTerm): void,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
@ -204,15 +206,12 @@ function buildNodesFilter(): NodesFilter {
|
||||||
|
|
||||||
let lastSearchTimestamp: UnixTimestampMilliseconds = 0 as UnixTimestampMilliseconds;
|
let lastSearchTimestamp: UnixTimestampMilliseconds = 0 as UnixTimestampMilliseconds;
|
||||||
let searchTimeout: NodeJS.Timeout | undefined = undefined;
|
let searchTimeout: NodeJS.Timeout | undefined = undefined;
|
||||||
let lastSearchTerm = "";
|
let lastSearchTerm: SearchTerm = "" as SearchTerm;
|
||||||
|
|
||||||
function doSearch(): void {
|
function doSearch(): void {
|
||||||
const nodesFilter = buildNodesFilter();
|
const nodesFilter = buildNodesFilter();
|
||||||
lastSearchTerm = input.value.value;
|
lastSearchTerm = input.value.value as SearchTerm;
|
||||||
let searchTerm: string | undefined = lastSearchTerm.trim();
|
const searchTerm: SearchTerm = lastSearchTerm.trim() as SearchTerm;
|
||||||
if (!searchTerm) {
|
|
||||||
searchTerm = undefined;
|
|
||||||
}
|
|
||||||
emit("updateFilter", nodesFilter, searchTerm);
|
emit("updateFilter", nodesFilter, searchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -258,6 +257,7 @@ function doThrottledSearch(): void {
|
||||||
@keyup="doThrottledSearch()"
|
@keyup="doThrottledSearch()"
|
||||||
maxlength="64"
|
maxlength="64"
|
||||||
type="search"
|
type="search"
|
||||||
|
:value="searchTerm"
|
||||||
placeholder="Knoten durchsuchen..."/>
|
placeholder="Knoten durchsuchen..."/>
|
||||||
<i class="fa fa-search search" @click="doSearch()"/>
|
<i class="fa fa-search search" @click="doSearch()"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
67
frontend/src/components/table/SortTH.vue
Normal file
67
frontend/src/components/table/SortTH.vue
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {type Component, defineComponent, type PropType} from "vue";
|
||||||
|
import {type EnumValue, SortDirection} from "@/types";
|
||||||
|
|
||||||
|
type Props<SortField> = {
|
||||||
|
field: PropType<EnumValue<SortField>>,
|
||||||
|
currentField: PropType<EnumValue<SortField>>,
|
||||||
|
currentDirection: PropType<SortDirection>,
|
||||||
|
};
|
||||||
|
|
||||||
|
type SortTH<SortField> = Component<Props<SortField>>;
|
||||||
|
|
||||||
|
function defineGenericComponent<SortField>(): SortTH<SortField> {
|
||||||
|
const props: Props<SortField> = {
|
||||||
|
field: null as unknown as PropType<EnumValue<SortField>>,
|
||||||
|
currentField: null as unknown as PropType<EnumValue<SortField>>,
|
||||||
|
currentDirection: null as unknown as PropType<SortDirection>,
|
||||||
|
};
|
||||||
|
return defineComponent({
|
||||||
|
name: "SortTH",
|
||||||
|
props,
|
||||||
|
computed: {
|
||||||
|
sortDirection: function () {
|
||||||
|
return this.field === this.currentField ? this.currentDirection : undefined;
|
||||||
|
},
|
||||||
|
isAscending: function () {
|
||||||
|
return this.sortDirection === SortDirection.ASCENDING;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
sort: (field: SortField, direction: SortDirection) => true,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onClick(): void {
|
||||||
|
this.$emit(
|
||||||
|
'sort',
|
||||||
|
this.field,
|
||||||
|
this.isAscending ? SortDirection.DESCENDING : SortDirection.ASCENDING
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = defineGenericComponent<unknown>();
|
||||||
|
|
||||||
|
export function SortTH<SortField>(): SortTH<SortField> {
|
||||||
|
return component as SortTH<SortField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
export default component;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<th>
|
||||||
|
<a href="javascript:" title="Sortieren" @click="onClick">
|
||||||
|
<slot/>
|
||||||
|
<i v-if="sortDirection && isAscending" class="fa fa-chevron-down" />
|
||||||
|
<i v-if="sortDirection && !isAscending" class="fa fa-chevron-up" />
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../scss/variables";
|
||||||
|
</style>
|
|
@ -2,7 +2,7 @@ import {createRouter, createWebHistory} from "vue-router";
|
||||||
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 {isNodesFilter} from "@/types";
|
import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -34,10 +34,12 @@ const router = createRouter({
|
||||||
filter = {};
|
filter = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchTerm = route.query.q ? route.query.q as string : undefined;
|
const searchTerm = route.query.q ? route.query.q as SearchTerm : undefined;
|
||||||
return {
|
return {
|
||||||
filter: isNodesFilter(filter) ? filter : {},
|
filter: isNodesFilter(filter) ? filter : {},
|
||||||
searchTerm,
|
searchTerm,
|
||||||
|
sortDirection: isSortDirection(route.query.sortDir) ? route.query.sortDir : undefined,
|
||||||
|
sortField: isNodeSortField(route.query.sortField) ? route.query.sortField : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,23 +1,53 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useNodesStore} from "@/stores/nodes";
|
import {useNodesStore} from "@/stores/nodes";
|
||||||
import {onMounted, ref, watch} from "vue";
|
import {onMounted, type PropType, ref, watch} from "vue";
|
||||||
import type {DomainSpecificNodeResponse, MAC, NodesFilter} from "@/types";
|
import type {DomainSpecificNodeResponse, MAC, NodesFilter, SearchTerm} from "@/types";
|
||||||
import {NodeSortField, SortDirection} from "@/types";
|
import {NodeSortField, SortDirection} from "@/types";
|
||||||
import Pager from "@/components/Pager.vue";
|
import Pager from "@/components/Pager.vue";
|
||||||
import LoadingContainer from "@/components/LoadingContainer.vue";
|
import LoadingContainer from "@/components/LoadingContainer.vue";
|
||||||
import NodesFilterPanel from "@/components/nodes/NodesFilterPanel.vue";
|
import NodesFilterPanel from "@/components/nodes/NodesFilterPanel.vue";
|
||||||
|
import {SortTH} from "@/components/table/SortTH.vue";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
|
||||||
|
function debug(...args: any[]): void {
|
||||||
|
console.debug("==================================================================");
|
||||||
|
console.debug("AdminNodesVue:", ...args);
|
||||||
|
console.table({
|
||||||
|
filter: JSON.stringify(props.filter),
|
||||||
|
searchTerm: props.searchTerm,
|
||||||
|
sortDirection: props.sortDirection,
|
||||||
|
sortField: props.sortField,
|
||||||
|
});
|
||||||
|
console.debug("==================================================================");
|
||||||
|
console.debug();
|
||||||
|
}
|
||||||
|
|
||||||
|
debug("init page");
|
||||||
|
|
||||||
const NODE_PER_PAGE = 50;
|
const NODE_PER_PAGE = 50;
|
||||||
|
|
||||||
interface Props {
|
// noinspection JSUnusedGlobalSymbols
|
||||||
filter: NodesFilter;
|
const sth = SortTH<NodeSortField>();
|
||||||
searchTerm?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps({
|
||||||
const currentFilter = ref<NodesFilter>({});
|
filter: {
|
||||||
const currentSearchTerm = ref<string | undefined>(undefined);
|
type: Object as PropType<NodesFilter>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
searchTerm: {
|
||||||
|
type: String as unknown as PropType<SearchTerm>,
|
||||||
|
default: "" as SearchTerm,
|
||||||
|
},
|
||||||
|
|
||||||
|
sortDirection: {
|
||||||
|
type: String as PropType<SortDirection>,
|
||||||
|
default: SortDirection.ASCENDING,
|
||||||
|
},
|
||||||
|
sortField: {
|
||||||
|
type: String as PropType<NodeSortField>,
|
||||||
|
default: NodeSortField.HOSTNAME,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
type NodeRedactField = "nickname" | "email" | "token";
|
type NodeRedactField = "nickname" | "email" | "token";
|
||||||
type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
|
type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
|
||||||
|
@ -36,10 +66,10 @@ async function refresh(page: number): Promise<void> {
|
||||||
await nodes.refresh(
|
await nodes.refresh(
|
||||||
page,
|
page,
|
||||||
NODE_PER_PAGE,
|
NODE_PER_PAGE,
|
||||||
SortDirection.ASCENDING,
|
props.sortDirection,
|
||||||
NodeSortField.HOSTNAME,
|
props.sortField,
|
||||||
currentFilter.value,
|
props.filter,
|
||||||
currentSearchTerm.value,
|
props.searchTerm,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
@ -69,37 +99,44 @@ function setRedactField(node: DomainSpecificNodeResponse, field: NodeRedactField
|
||||||
redactFieldsMap[field] = value;
|
redactFieldsMap[field] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateFilter(filter: NodesFilter, searchTerm?: string): Promise<void> {
|
async function updateRouterState(
|
||||||
|
filter: NodesFilter,
|
||||||
|
searchTerm: SearchTerm,
|
||||||
|
sortDirection: SortDirection,
|
||||||
|
sortField: NodeSortField,
|
||||||
|
): Promise<void> {
|
||||||
const filterStr = Object.keys(filter).length > 0 ? JSON.stringify(filter) : undefined;
|
const filterStr = Object.keys(filter).length > 0 ? JSON.stringify(filter) : undefined;
|
||||||
await router.push({
|
await router.replace({
|
||||||
path: '/admin/nodes',
|
path: '/admin/nodes',
|
||||||
query: {
|
query: {
|
||||||
q: searchTerm,
|
q: searchTerm || undefined,
|
||||||
filter: filterStr
|
filter: filterStr,
|
||||||
|
sortDir: sortDirection,
|
||||||
|
sortField: sortField,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
currentFilter.value = filter;
|
}
|
||||||
currentSearchTerm.value = searchTerm;
|
|
||||||
|
async function updateFilter(filter: NodesFilter, searchTerm: SearchTerm): Promise<void> {
|
||||||
|
await updateRouterState(filter, searchTerm, props.sortDirection, props.sortField);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSortOrder(sortField: NodeSortField, sortDirection: SortDirection): Promise<void> {
|
||||||
|
await updateRouterState(props.filter, props.searchTerm, sortDirection, sortField);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
await refresh(1);
|
await refresh(1);
|
||||||
}
|
});
|
||||||
|
watch(props, async () => {
|
||||||
async function refreshFromProps(): Promise<void> {
|
await refresh(1);
|
||||||
const needsRefresh = currentFilter.value !== props.filter || currentSearchTerm.value !== props.searchTerm;
|
});
|
||||||
currentFilter.value = props.filter;
|
|
||||||
currentSearchTerm.value = props.searchTerm;
|
|
||||||
if (needsRefresh) {
|
|
||||||
await refresh(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(refreshFromProps);
|
|
||||||
watch(props, refreshFromProps);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h2>Knoten</h2>
|
<h2>Knoten</h2>
|
||||||
|
|
||||||
<NodesFilterPanel :filter="props.filter" @update-filter="updateFilter"/>
|
<NodesFilterPanel :search-term="searchTerm" :filter="filter" @update-filter="updateFilter"/>
|
||||||
|
|
||||||
<Pager
|
<Pager
|
||||||
:page="nodes.getPage"
|
:page="nodes.getPage"
|
||||||
|
@ -126,17 +163,83 @@ watch(props, refreshFromProps);
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<sth
|
||||||
<th>Besitzer*in</th>
|
:field="NodeSortField.HOSTNAME"
|
||||||
<th>E-Mail</th>
|
:currentField="sortField"
|
||||||
<th>Token</th>
|
:currentDirection="sortDirection"
|
||||||
<th>MAC</th>
|
@sort="updateSortOrder">
|
||||||
<th>VPN</th>
|
Name
|
||||||
<th>Site</th>
|
</sth>
|
||||||
<th>Domäne</th>
|
<sth
|
||||||
<th>GPS</th>
|
:field="NodeSortField.NICKNAME"
|
||||||
<th>Status</th>
|
:currentField="sortField"
|
||||||
<th>Monitoring</th>
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
Besitzer*in
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.EMAIL"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
E-Mail
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.TOKEN"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
Token
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.MAC"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
MAC
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.KEY"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
VPN
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.SITE"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
Site
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.DOMAIN"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
Domäne
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.COORDS"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
GPS
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.ONLINE_STATE"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
Status
|
||||||
|
</sth>
|
||||||
|
<sth
|
||||||
|
:field="NodeSortField.MONITORING_STATE"
|
||||||
|
:currentField="sortField"
|
||||||
|
:currentDirection="sortDirection"
|
||||||
|
@sort="updateSortOrder">
|
||||||
|
Monitoring
|
||||||
|
</sth>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
|
|
@ -562,6 +562,9 @@ export function isNodesFilter(arg: unknown): arg is NodesFilter {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SearchTerm = string & { readonly __tag: unique symbol }
|
||||||
|
export const isSearchTerm = isString;
|
||||||
|
|
||||||
export enum MonitoringSortField {
|
export enum MonitoringSortField {
|
||||||
ID = 'id',
|
ID = 'id',
|
||||||
HOSTNAME = 'hostname',
|
HOSTNAME = 'hostname',
|
||||||
|
|
Loading…
Reference in a new issue