Admin: Added filter / search panel to nodes list.
This commit is contained in:
parent
ac7f8c98b0
commit
f95829adc6
13 changed files with 568 additions and 45 deletions
frontend/src/components
|
@ -17,7 +17,6 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
const firstItem = computed(() => {
|
||||
console.log(props.totalItems, props.page, props.itemsPerPage);
|
||||
return Math.min(props.totalItems, (props.page - 1) * props.itemsPerPage + 1)
|
||||
});
|
||||
const lastItem = computed(() => Math.min(props.totalItems, firstItem.value + props.itemsPerPage - 1));
|
||||
|
|
|
@ -17,7 +17,9 @@ const linkTarget = computed(() => {
|
|||
if (props.filter) {
|
||||
return {
|
||||
path: props.link,
|
||||
query: props.filter,
|
||||
query: {
|
||||
filter: JSON.stringify(props.filter),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return props.link;
|
||||
|
@ -47,11 +49,19 @@ const linkTarget = computed(() => {
|
|||
padding: $statistics-card-padding;
|
||||
|
||||
border-radius: $statistics-card-border-radius;
|
||||
border-width: 0.2em;
|
||||
border-style: solid;
|
||||
|
||||
@each $variant, $color in $variant-colors {
|
||||
&.statistics-card-#{$variant} {
|
||||
background-color: $color;
|
||||
color: map-get($variant-text-colors, $variant);
|
||||
border-color: $color;
|
||||
|
||||
&:focus {
|
||||
border-color: $page-background-color;
|
||||
outline: 0.2em solid $color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
382
frontend/src/components/nodes/NodesFilterPanel.vue
Normal file
382
frontend/src/components/nodes/NodesFilterPanel.vue
Normal file
|
@ -0,0 +1,382 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
isMap,
|
||||
isNodesFilter,
|
||||
isString,
|
||||
MonitoringState,
|
||||
NODES_FILTER_FIELDS,
|
||||
type NodesFilter,
|
||||
OnlineState,
|
||||
type UnixTimestampMilliseconds,
|
||||
} from "@/types";
|
||||
import {computed, nextTick, onMounted, ref, watch} from "vue";
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
|
||||
interface Props {
|
||||
filter: NodesFilter;
|
||||
}
|
||||
|
||||
const SEARCH_THROTTLE_DELAY_MS = 500;
|
||||
|
||||
const FILTER_LABELS: Record<string, string | Map<any, any>> = {
|
||||
hasKey: new Map([
|
||||
[true, "Mit VPN-Schlüssel"],
|
||||
[false, "Ohne VPN-Schlüssel"],
|
||||
]),
|
||||
hasCoords: new Map([
|
||||
[true, "Mit Koordinaten"],
|
||||
[false, "Ohne Koordinaten"],
|
||||
]),
|
||||
monitoringState: new Map([
|
||||
[MonitoringState.ACTIVE, "Monitoring: aktiv"],
|
||||
[MonitoringState.PENDING, "Monitoring: Bestätigung ausstehend"],
|
||||
[MonitoringState.DISABLED, "Monitoring: nicht aktiv"],
|
||||
]),
|
||||
site: "Site",
|
||||
domain: "Domäne",
|
||||
onlineState: new Map([
|
||||
[OnlineState.ONLINE, "online"],
|
||||
[OnlineState.OFFLINE, "offline"],
|
||||
]),
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "updateFilter", filter: NodesFilter, searchTerm?: string): void,
|
||||
}>();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const input = ref();
|
||||
const hasFocus = ref(false);
|
||||
const suggestedFiltersExpanded = ref(false);
|
||||
const config = useConfigStore();
|
||||
|
||||
type Filter = {
|
||||
field: string,
|
||||
value: any,
|
||||
};
|
||||
const selectedFilters = ref<Filter[]>([]);
|
||||
|
||||
function selectedFilterIndex(filter: Filter): number {
|
||||
for (let i = 0; i < selectedFilters.value.length; i += 1) {
|
||||
const selectedFilter = selectedFilters.value[i];
|
||||
if (selectedFilter.field === filter.field && selectedFilter.value === filter.value) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function selectedFilterIndexForField(field: string): number {
|
||||
for (let i = 0; i < selectedFilters.value.length; i += 1) {
|
||||
const selectedFilter = selectedFilters.value[i];
|
||||
if (selectedFilter.field === field) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
const suggestedFilters = computed<Filter[][]>(() => {
|
||||
const cfg = config.getConfig;
|
||||
const sites = cfg?.community.sites || [];
|
||||
const domains = cfg?.community.domains || [];
|
||||
|
||||
const filterGroups: Filter[][] = [];
|
||||
|
||||
for (const field of Object.keys(NODES_FILTER_FIELDS)) {
|
||||
const filterGroup: Filter[] = []
|
||||
|
||||
function pushFilter(filter: Filter): void {
|
||||
if (selectedFilterIndex(filter) < 0) {
|
||||
filterGroup.push(filter);
|
||||
}
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case "site":
|
||||
for (const site of sites) {
|
||||
pushFilter({
|
||||
field: "site",
|
||||
value: site,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "domain":
|
||||
for (const domain of domains) {
|
||||
pushFilter({
|
||||
field: "domain",
|
||||
value: domain,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
const labels = FILTER_LABELS[field];
|
||||
if (!isMap(labels)) {
|
||||
throw new Error(`Missing case for field ${field}.`);
|
||||
}
|
||||
|
||||
for (const value of labels.keys()) {
|
||||
pushFilter({
|
||||
field,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
filterGroups.push(filterGroup);
|
||||
}
|
||||
return filterGroups;
|
||||
});
|
||||
|
||||
function addSelectedFilter(filter: Filter): void {
|
||||
const i = selectedFilterIndexForField(filter.field);
|
||||
if (i >= 0) {
|
||||
selectedFilters.value.splice(selectedFilterIndex(filter), 1);
|
||||
}
|
||||
selectedFilters.value.push(filter);
|
||||
doSearch();
|
||||
}
|
||||
|
||||
function removeSelectedFilter(filter: Filter): void {
|
||||
selectedFilters.value.splice(selectedFilterIndex(filter), 1);
|
||||
doSearch();
|
||||
}
|
||||
|
||||
function updateSelectedFilters() {
|
||||
const filter = props.filter as Record<string, any>;
|
||||
selectedFilters.value = [];
|
||||
for (const field of Object.keys(NODES_FILTER_FIELDS)) {
|
||||
if (filter.hasOwnProperty(field)) {
|
||||
addSelectedFilter({
|
||||
field,
|
||||
value: filter[field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(props, updateSelectedFilters);
|
||||
onMounted(updateSelectedFilters);
|
||||
|
||||
function renderFilter(filter: Filter): string {
|
||||
if (!FILTER_LABELS.hasOwnProperty(filter.field)) {
|
||||
throw new Error(`Filter has no translation: ${filter.field}`);
|
||||
}
|
||||
|
||||
const label = FILTER_LABELS[filter.field];
|
||||
if (isString(label)) {
|
||||
return `${label}: ${filter.value}`;
|
||||
}
|
||||
|
||||
if (!label.has(filter.value)) {
|
||||
throw new Error(`Filter ${filter.field} has no translation for value: ${filter.value}(${typeof filter.value})`);
|
||||
}
|
||||
|
||||
return label.get(filter.value);
|
||||
}
|
||||
|
||||
function setFocus(focus: boolean): void {
|
||||
hasFocus.value = focus;
|
||||
}
|
||||
|
||||
function focusInput(): void {
|
||||
nextTick(() => input.value.focus());
|
||||
}
|
||||
|
||||
function showSuggestedFilters(expanded: boolean): void {
|
||||
suggestedFiltersExpanded.value = expanded;
|
||||
}
|
||||
|
||||
function buildNodesFilter(): NodesFilter {
|
||||
const nodesFilter: Record<string, any> = {};
|
||||
for (const filter of selectedFilters.value) {
|
||||
nodesFilter[filter.field] = filter.value;
|
||||
}
|
||||
if (!isNodesFilter(nodesFilter)) {
|
||||
throw new Error(`Invalid nodes filter: ${JSON.stringify(nodesFilter)}`);
|
||||
}
|
||||
return nodesFilter;
|
||||
}
|
||||
|
||||
let lastSearchTimestamp: UnixTimestampMilliseconds = 0;
|
||||
let searchTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
let lastSearchTerm = "";
|
||||
|
||||
function doSearch(): void {
|
||||
const nodesFilter = buildNodesFilter();
|
||||
lastSearchTerm = input.value.value;
|
||||
let searchTerm: string | undefined = lastSearchTerm.trim();
|
||||
if (!searchTerm) {
|
||||
searchTerm = undefined;
|
||||
}
|
||||
emit("updateFilter", nodesFilter, searchTerm);
|
||||
}
|
||||
|
||||
function doThrottledSearch(): void {
|
||||
if (lastSearchTerm === input.value.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const now: UnixTimestampMilliseconds = Date.now();
|
||||
if (now - SEARCH_THROTTLE_DELAY_MS >= lastSearchTimestamp) {
|
||||
lastSearchTimestamp = now;
|
||||
doSearch();
|
||||
} else if (!searchTimeout) {
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchTimeout = undefined
|
||||
doThrottledSearch();
|
||||
}, SEARCH_THROTTLE_DELAY_MS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'nodes-filter-panel': true, 'focus': hasFocus}" @click="focusInput">
|
||||
<div class="nodes-filter-input">
|
||||
<div class="selected-filters">
|
||||
<span
|
||||
v-for="filter in selectedFilters"
|
||||
class="selected-filter"
|
||||
@click="removeSelectedFilter(filter)"
|
||||
:title="renderFilter(filter)">
|
||||
{{ renderFilter(filter) }}
|
||||
<i
|
||||
class="fa fa-times remove-filter"
|
||||
aria-hidden="true"
|
||||
title="Filter entfernen"/>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
@focus="setFocus(true)"
|
||||
@blur="setFocus(false)"
|
||||
@keyup="doThrottledSearch()"
|
||||
maxlength="64"
|
||||
type="search"
|
||||
placeholder="Knoten durchsuchen..."/>
|
||||
<i class="fa fa-search search" @click="doSearch()"/>
|
||||
</div>
|
||||
<div class="suggested-filters" v-if="suggestedFiltersExpanded">
|
||||
<div class="suggested-filter-group" v-for="filterGroup in suggestedFilters">
|
||||
<span
|
||||
class="suggested-filter"
|
||||
v-for="filter in filterGroup"
|
||||
@click="addSelectedFilter(filter)"
|
||||
:title="renderFilter(filter)">
|
||||
{{ renderFilter(filter) }}
|
||||
<i
|
||||
class="fa fa-plus add-filter"
|
||||
aria-hidden="true"
|
||||
title="Filter hinzufügen"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="suggestedFiltersExpanded"
|
||||
class="toggle-suggested-filters"
|
||||
href="javascript:"
|
||||
@click="showSuggestedFilters(false)">
|
||||
Erweiterte Suche ausblenden
|
||||
</a>
|
||||
<a
|
||||
v-if="!suggestedFiltersExpanded"
|
||||
class="toggle-suggested-filters"
|
||||
href="javascript:"
|
||||
@click="showSuggestedFilters(true)">
|
||||
Erweiterte Suche einblenden
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../../scss/variables";
|
||||
|
||||
.nodes-filter-panel {
|
||||
position: relative;
|
||||
background-color: $input-background-color;
|
||||
border-radius: $input-border-radius;
|
||||
border: $input-border;
|
||||
|
||||
&.focus {
|
||||
outline: $input-focus-outline;
|
||||
}
|
||||
|
||||
.nodes-filter-input {
|
||||
display: flex;
|
||||
margin: 0 0.25em;
|
||||
}
|
||||
|
||||
.selected-filters {
|
||||
margin: 0.3em 0.25em 0.3em 0;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.selected-filter,
|
||||
.suggested-filter {
|
||||
cursor: pointer;
|
||||
margin: 0.1em 0.25em 0.1em 0;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.75em;
|
||||
font-size: 0.75em;
|
||||
color: $nodes-filter-panel-pill-text-color;
|
||||
background-color: $nodes-filter-panel-pill-background-color;
|
||||
font-weight: $nodes-filter-panel-pill-font-weight;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
.add-filter, .remove-filter {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-filters, .suggested-filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.suggested-filters {
|
||||
border-top: 0.1em solid $page-background-color;
|
||||
}
|
||||
|
||||
.suggested-filter-group {
|
||||
margin: 0.5em 0.25em;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin: 0.5em 0.25em 0 0;
|
||||
color: $page-background-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0.75em 0.25em 0.6em 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
flex-grow: 1;
|
||||
height: 1em;
|
||||
outline: none;
|
||||
background-color: $input-background-color;
|
||||
color: $input-text-color;
|
||||
|
||||
&::placeholder {
|
||||
opacity: $input-placeholder-opacity;
|
||||
color: $input-placeholder-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-suggested-filters {
|
||||
display: block;
|
||||
margin: 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -4,13 +4,6 @@ import {useVersionStore} from "@/stores/version";
|
|||
|
||||
const config = useConfigStore();
|
||||
const version = useVersionStore();
|
||||
|
||||
function refresh(): void {
|
||||
config.refresh();
|
||||
version.refresh();
|
||||
}
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -2,12 +2,6 @@
|
|||
import { useConfigStore } from "@/stores/config";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
function refresh(): void {
|
||||
config.refresh();
|
||||
}
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue