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

NeFilter component test #332

Closed
wants to merge 10 commits 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
24 changes: 12 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

255 changes: 255 additions & 0 deletions src/components/NeFilter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
<!--
Copyright (C) 2024 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->

<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { library } from '@fortawesome/fontawesome-svg-core'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { isEqual } from 'lodash-es'
import { v4 as uuid } from '@lukeed/uuid'
import { NeBadge, NeLink } from '@nethesis/vue-components'

export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type FilterKind = 'radio' | 'checkbox'

export type FilterOption = {
id: string
label: string
description?: string
disabled?: boolean
}

const sizeStyle: { [index: string]: string } = {
xs: 'rounded px-2 py-1 text-xs',
sm: 'rounded px-2 py-1 text-sm',
md: 'rounded-md px-2.5 py-1.5 text-sm',
lg: 'rounded-md px-3 py-2 text-sm',
xl: 'rounded-md px-3.5 py-2.5 text-sm'
}

export interface Props {
label: string
options: FilterOption[]
kind: FilterKind
clearFilterLabel: string
openMenuAriaLabel: string
showClearFilter?: boolean
showSelectionCount?: boolean
alignToRight?: boolean
size?: ButtonSize
disabled?: boolean
id?: string
}

const props = withDefaults(defineProps<Props>(), {
showClearFilter: true,
showSelectionCount: true,
alignToRight: true,
size: 'md',
disabled: false,
id: ''
})

library.add(faChevronDown)

const model = defineModel<string[]>()
const radioModel = ref('')
const checkboxModel = ref<string[]>([])
const top = ref(0)
const left = ref(0)
const right = ref(0)
const buttonRef = ref()

const componentId = computed(() => (props.id ? props.id : uuid()))

const isSelectionCountShown = computed(() => {
return props.showSelectionCount && props.kind === 'checkbox' && checkboxModel.value.length > 0
})

watch(
() => props.alignToRight,
() => {
calculatePosition()
}
)

watch(
() => radioModel.value,
() => {
model.value = [radioModel.value]
}
)

watch(
() => checkboxModel.value,
() => {
model.value = checkboxModel.value
}
)

watch(
() => model.value,
() => {
updateInternalModel()
}
)

onMounted(() => {
updateInternalModel()
})

function updateInternalModel() {
if (props.kind === 'radio') {
// update only if the value is different to avoid "Maximum recursive updates exceeded" error
if (model.value && radioModel.value !== model.value[0]) {
radioModel.value = model.value[0]
}
} else if (props.kind === 'checkbox') {
// update only if the value is different to avoid "Maximum recursive updates exceeded" error
if (model.value && !isEqual(checkboxModel.value, model.value)) {
checkboxModel.value = model.value
}
}
}

function calculatePosition() {
top.value = buttonRef.value?.$el.getBoundingClientRect().bottom + window.scrollY
left.value = buttonRef.value?.$el.getBoundingClientRect().left - window.scrollX
right.value =
document.documentElement.clientWidth -
buttonRef.value?.$el.getBoundingClientRect().right -
window.scrollX
}
</script>

<template>
<Menu as="div" class="relative inline-block text-left">
<MenuButton
ref="buttonRef"
class="flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-50"
@click="calculatePosition()"
>
<span class="sr-only">{{ openMenuAriaLabel }}</span>
<slot name="button">
<!-- default button -->
<button
class="font-medium text-gray-700 shadow-sm ring-1 ring-gray-300 transition-colors duration-200 hover:bg-gray-200/70 hover:text-gray-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-100 dark:ring-gray-500 dark:hover:bg-gray-600/30 dark:hover:text-gray-50 dark:focus:ring-primary-300 dark:focus:ring-offset-primary-950"
:class="sizeStyle[props.size]"
:disabled="disabled"
type="button"
>
<div class="flex items-center justify-center">
<slot v-if="$slots.label" name="label"></slot>
<span v-else>{{ label }}</span>
<NeBadge
v-if="isSelectionCountShown"
:text="checkboxModel.length.toString()"
size="xs"
class="ml-2"
/>
<font-awesome-icon
:icon="['fas', 'chevron-down']"
class="ml-2 h-3 w-3"
aria-hidden="true"
/>
</div>
</button>
</slot>
</MenuButton>
<Teleport to="body">
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
:style="[
{ top: top + 'px' },
alignToRight ? { right: right + 'px' } : { left: left + 'px' }
]"
class="absolute z-50 mt-2.5 max-h-[16.5rem] min-w-[10rem] overflow-y-auto rounded-md bg-white px-4 py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none dark:bg-gray-950 dark:ring-gray-500/50"
>
<MenuItem as="div" v-if="showClearFilter" class="py-2">
<NeLink @click.stop="checkboxModel = []">
{{ clearFilterLabel }}
</NeLink>
</MenuItem>
<MenuItem as="div" v-for="option in options" :key="option.id" :disabled="option.disabled">
<!-- divider -->
<hr
v-if="option.id.includes('divider')"
class="my-1 border-gray-200 dark:border-gray-700"
/>
<!-- filter option -->
<div v-if="kind === 'radio'" class="flex items-center py-2" @click.stop>
<!-- radio button -->
<input
type="radio"
:id="`${componentId}-${option.id}`"
:name="componentId"
v-model="radioModel"
:value="option.id"
:aria-describedby="`${componentId}-${option.id}-description`"
class="peer border-gray-300 text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-950 dark:text-primary-500 checked:dark:bg-primary-500 dark:focus:ring-primary-300 focus:dark:ring-primary-200 focus:dark:ring-offset-gray-900"
:disabled="option.disabled || disabled"
/>
<label
:for="`${componentId}-${option.id}`"
:disabled="option.disabled"
class="ms-2 text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 dark:text-gray-50"
>
<div>{{ option.label }}</div>
<div
v-if="option.description"
:id="`${componentId}-${option.id}-description`"
class="text-gray-500 dark:text-gray-400"
>
{{ option.description }}
</div>
</label>
</div>
<div v-else-if="kind === 'checkbox'" class="flex items-center py-2" @click.stop>
<!-- checkbox -->
<div class="flex h-6 items-center">
<input
type="checkbox"
:id="`${componentId}-${option.id}`"
v-model="checkboxModel"
:value="option.id"
:aria-describedby="`${componentId}-${option.id}-description`"
:disabled="option.disabled || disabled"
class="h-5 w-5 rounded border-gray-300 text-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:text-primary-500 dark:focus:ring-primary-300 dark:focus:ring-offset-primary-950 sm:h-4 sm:w-4"
/>
</div>
<div class="ml-3 text-sm leading-6">
<!-- show label prop or default slot -->
<label
:class="[
'font-medium text-gray-700 dark:text-gray-50',
{ 'cursor-not-allowed opacity-50': disabled }
]"
:for="`${componentId}-${option.id}`"
>
<div>{{ option.label }}</div>
<div
v-if="option.description"
:id="`${componentId}-${option.id}-description`"
class="text-gray-500 dark:text-gray-400"
>
{{ option.description }}
</div>
</label>
</div>
</div>
</MenuItem>
</MenuItems>
</transition>
</Teleport>
</Menu>
</template>
52 changes: 46 additions & 6 deletions src/components/standalone/firewall/conntrack/ConntrackContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
NeTextInput
} from '@nethesis/vue-components'
import { useNotificationsStore } from '@/stores/notifications'
import NeFilter, { type FilterOption } from '@/components/NeFilter.vue'

const notificationsStore = useNotificationsStore()

import { getAxiosErrorMessage } from '@nethesis/vue-components'
Expand Down Expand Up @@ -51,8 +53,36 @@ const error = ref({
notificationTitle: ''
})
const filter = ref('')
const protocolFilter = ref<string[]>([])
const showDeleteModal = ref(false)

const protocolFilterOptions: FilterOption[] = [
{ id: 'tcp', label: 'TCP' },
{ id: 'udp', label: 'UDP' },
{ id: 'icmp', label: 'ICMP' }
// { id: 'test1', label: 'Test 1' },
// { id: 'test2', label: 'Test 2' },
// { id: 'test3', label: 'Test 3' },
// { id: 'test4', label: 'Test 4' },
// { id: 'test5', label: 'Test 5' },
// { id: 'test6', label: 'Test 6' },
// { id: 'test7', label: 'Test 7' },
// { id: 'test8', label: 'Test 8' },
// { id: 'test9', label: 'Test 9' },
// { id: 'test10', label: 'Test 10' },
// { id: 'test11', label: 'Test 11' },
// { id: 'test12', label: 'Test 12' },
// { id: 'test13', label: 'Test 13' },
// { id: 'test14', label: 'Test 14' },
// { id: 'test15', label: 'Test 15' },
// { id: 'test16', label: 'Test 16' },
// { id: 'test17', label: 'Test 17' },
// { id: 'test18', label: 'Test 18' },
// { id: 'test19', label: 'Test 19' },
// { id: 'test20', label: 'Test 20' },
// { id: 'test99999999', label: 'Test 99999999999912345' }
]

onMounted(() => {
fetchConntrack()
})
Expand All @@ -72,9 +102,9 @@ async function fetchConntrack() {
isLoading.value = false
}
}
function applyFilterToConntrackRecords(records: ConntrackRecord[], filter: string) {
const lowerCaseFilter = filter.toLowerCase()
return records.filter(
function applyFilterToConntrackRecords() {
const lowerCaseFilter = filter.value.toLowerCase()
const textFiltered = conntrackRecords.value.filter(
(ConntrackRecord) =>
ConntrackRecord.source.toLowerCase().includes(lowerCaseFilter) ||
ConntrackRecord.destination.toLowerCase().includes(lowerCaseFilter) ||
Expand All @@ -83,6 +113,10 @@ function applyFilterToConntrackRecords(records: ConntrackRecord[], filter: strin
(ConntrackRecord.source_port ?? '').toLowerCase().includes(lowerCaseFilter) ||
(ConntrackRecord.destination_port ?? '').toLowerCase().includes(lowerCaseFilter)
)

return textFiltered.filter(
(record) => !protocolFilter.value.length || protocolFilter.value.includes(record.protocol)
)
}

function deleteAll() {
Expand All @@ -100,9 +134,7 @@ function closeDeleteModal() {
}

const filteredItems = computed(() => {
return filter.value === ''
? conntrackRecords.value
: applyFilterToConntrackRecords(conntrackRecords.value, filter.value)
return applyFilterToConntrackRecords()
})

function onRecordDeleted() {
Expand Down Expand Up @@ -159,6 +191,14 @@ function onRecordDeleted() {
</div>
<div class="flex items-center gap-4">
<NeTextInput class="max-w-xs" :placeholder="t('common.filter')" v-model="filter" />
<NeFilter
v-model="protocolFilter"
label="Protocol"
kind="checkbox"
:options="protocolFilterOptions"
clearFilterLabel="Clear filter"
openMenuAriaLabel="Open protocol filter"
/>
<NeButton kind="tertiary" @click="filter = ''" :disabled="isLoading || !filter">
{{ t('common.clear_filter') }}
</NeButton>
Expand Down
Loading
Loading