Skip to content
Snippets Groups Projects

Refactor facet autocomplete

Merged Remi PLANEL requested to merge refactor-facet-autocomplete into dev
Compare and Show latest version
1 file
+ 1
1
Compare changes
  • Side-by-side
  • Inline
+ 156
206
<script setup lang="ts">
// import type { FacetDistribution } from "meilisearch";
import { useCsvDownload } from "@/composables/useCsvDownload"
import { useMeiliFilters } from "@/composables/useMeiliFilters"
import { useSlots } from 'vue'
import { useDisplay } from "vuetify";
import { useThrottleFn } from '@vueuse/core'
import type { FacetInputItem, FilterItem } from '@/components/AutocompleteMeiliFacets.vue'
import { useMeiliSearch } from "#imports"
import type { Filter } from "meilisearch";
// import { saveAs } from "file-saver";
import { useFileSystemAccess } from '@vueuse/core'
export interface SortItem {
key: string,
order: boolean | 'asc' | 'desc'
@@ -22,47 +23,57 @@ export interface NumericalFilter {
export interface NumericalFilterModel extends NumericalFilter {
model: [number, number]
}
export interface AutocompleteMeiliFacetProps {
db: string
facets: FacetInputItem[] | undefined
facetDistribution: Record<string, Record<string, number>> | undefined
}
export interface Props {
title?: string
db?: string
sortBy?: SortItem[]
facets: MaybeRef<string[]>
numericalFilters?: MaybeRef<string | undefined>
numericalFilters?: Ref<string[] | undefined>
dataTableServerProps: Record<string, any>
columnsToDownload?: MaybeRef<string[] | undefined>
autocompleteMeiliFacetsProps: AutocompleteMeiliFacetProps
}
export interface FilterItem {
type: 'facet' | 'operator' | 'value' | 'text'
value: string
title: string
count?: number
deletable: boolean
props: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
title: '',
db: 'refseq',
columnsToDownload: undefined,
sortBy: () => [{ key: "type", order: "asc" }],
numericalFilters: undefined
numericalFilters: () => ref(undefined),
autocompleteMeiliFacetsProps: () => {
return {
db: 'refseq',
facetDistribution: undefined,
facets: undefined
}
}
});
// const facetDistribution: Ref<Record<string, Record<string, number>> | undefined> = useState(`refseqFacetDistribution`)
const slots = useSlots()
const sortByRef = toRef(props.sortBy)
const facetsRef = toRef(props.facets)
const emit = defineEmits(["refresh:search"])
const { search: msSearch, result: msResult } = useMeiliSearch(props.db)
const { search: msSearch, result: msResult } = useMeiliSearch(props.autocompleteMeiliFacetsProps.db)
const search: Ref<string> = ref("");
const filterOrSearch: Ref<FilterItem[] | null> = ref(null)
const hitsPerPage: Ref<number> = ref(25)
const itemsPerPage: Ref<number[]> = ref([25, 50, 100])
const filterError: Ref<string | null> = ref(null)
const msFilter: Ref<string | undefined> = ref(undefined)
// const msFilter: Ref<string | undefined> = ref(undefined)
const page = ref(1)
let loading = ref(false)
const expanded = ref([])
@@ -75,6 +86,9 @@ const computedTableHeight = computed(() => {
const pendingDownloadData = ref(false)
const toRefNumericalFilters = toRef(props.numericalFilters)
// const meiliFilters = ref<string | undefined>(undefined)
const filterInputValues = computed(() => {
if (filterOrSearch.value != null) {
return filterOrSearch.value.filter(({ props }) => props.type !== 'text')
@@ -83,9 +97,6 @@ const filterInputValues = computed(() => {
}
})
const isFilter = computed(() => {
return Array.isArray(filterOrSearch.value)
})
const msSortBy = computed(() => {
if (sortByRef.value.length > 0) {
@@ -114,26 +125,67 @@ const notPaginatedParams = computed(() => {
watch([paginationParams, msSortBy, page], ([newParams, newSort, newPage]) => {
searchOrFilter()
if (toValue(isValidFilters)) {
searchOrFilter()
}
})
onMounted(async () => {
onBeforeMount(async () => {
searchOrFilter()
emitRefreshRes()
})
const msFilterCompo = ref<FilterItem[] | undefined>(undefined)
const computedFilterStr = computed(() => {
const toValFilters = toValue(msFilterCompo)
let filtersStr: string | undefined = undefined
if (toValFilters !== undefined && toValFilters.length > 0) {
const tmpFilterItems = [...toValFilters]
if (tmpFilterItems.length % 4 === 0) {
tmpFilterItems.splice(-1)
}
filtersStr = "(" + tmpFilterItems.map((it, index) => {
const sanitizedValue = it.value.split("-").slice(0, -1).join("-")
if ((index + 1) % 4 === 3) {
return `"${sanitizedValue}"`
} else if ((index + 1) % 4 === 0) {
return ` ${sanitizedValue} `
}
else {
return `${sanitizedValue}`
}
}).join("") + ")"
}
return [filtersStr, props.numericalFilters].filter(f => f !== undefined && f !== null).join(" AND ")
const computedFilter = computed(() => {
return [toValue(msFilter), props.numericalFilters].filter(f => f !== undefined).join(" AND ")
})
const computedF = computed(() => toValue(props.numericalFilters))
const { arrayFilters: computedFilter } = useMeiliFilters(msFilterCompo, computedF)
// const computedFilter = computed(() => {
// const toValFilters = toValue(msFilterCompo)
// if (toValFilters !== undefined && toValFilters.length > 0) {
// meiliFilterAsArray
// }
// })
watch(computedFilter, () => {
searchOrFilter()
emitRefreshRes()
console.log(toValue(computedFilter))
if (toValue(isValidFilters) && (toValue(computedFilter) !== undefined || toValue(filterInputValues) === null)) {
searchOrFilter()
emitRefreshRes()
}
})
@@ -147,154 +199,86 @@ const throttleSearch = useThrottleFn(async () => {
searchOrFilter()
emitRefreshRes()
}, 300)
async function searchOrFilter() {
// do something, it will be called at most 1 time per second
try {
loading.value = true
// const q = queryInputValue.value === null ? "" : queryInputValue.value
const q = search.value
await msSearch(q, { ...paginationParams.value, filter: toValue(computedFilter), sort: msSortBy.value })
} catch (error: any) {
filterError.value = error
console.log(error)
const lastFilterItem = computed(() => {
const toValFilterItems = toValue(msFilterCompo)
if (toValFilterItems !== undefined && Array.isArray(toValFilterItems)) {
return toValFilterItems.slice(-1)[0]
}
})
const isValidFilters = computed(() => {
const toValFilterItems = toValue(msFilterCompo)
if (toValFilterItems === undefined || Array.isArray(toValFilterItems) && toValFilterItems?.length === 0) {
return true
}
finally {
loading.value = false
else {
const toValLastFilterItem = toValue(lastFilterItem)
if (toValLastFilterItem !== undefined) {
console.log(toValLastFilterItem.type)
console.log(toValLastFilterItem.type === 'value')
return toValLastFilterItem.type === 'value'
// && isAutocompleteFocused.value === false
// || (toValFilterStep === 0 && toValLastFilterItem.type === "outerOperator" && toValLastFilterItem.value.split("-")[0] === "AND")
}
}
return false
})
async function searchOrFilter() {
if (toValue(isValidFilters)) {
// do something, it will be called at most 1 time per second
try {
loading.value = true
// const q = queryInputValue.value === null ? "" : queryInputValue.value
const q = search.value
await msSearch(q, { ...paginationParams.value, filter: toValue(computedFilter), sort: msSortBy.value })
} catch (error: any) {
filterError.value = error
console.log(error)
}
finally {
loading.value = false
}
}
}
function emitRefreshRes() {
console.log("emit refresh:search")
const q = search.value
emit("refresh:search", {
index: props.db,
index: props.autocompleteMeiliFacetsProps.db,
query: q,
params: { ...notPaginatedParams.value, filter: toValue(computedFilter), sort: msSortBy.value }
})
}
function clearFilterOrSearch() {
filterOrSearch.value = null
// searchOrFilter()
// emitRefreshRes()
}
// watch(msFilter, async (fos) => {
// searchOrFilter()
// emitRefreshRes()
// search.value = ''
// })
const totalHits = computed(() => {
return toValue(msResult)?.totalHits ?? toValue(msResult)?.estimatedTotalHits ?? 0
})
watch(filterInputValues, (newSoF) => {
if (isFilter.value && filterInputValues.value !== null && filterInputValues.value?.length % 3 === 0) {
msFilter.value = filterInputValues.value.map((it, index) => {
const sanitizedValue = it.value.split("-").slice(0, -1).join("-")
if (index >= 1 && (index + 1) % 3 === 1) {
return ` AND ${sanitizedValue}`
} else if ((index + 1) % 3 === 0) {
return `"${sanitizedValue}"`
} else {
return `${sanitizedValue}`
}
}).join("")
}
})
watch(search, () => {
searchOrFilter()
emitRefreshRes()
// emitRefreshRes()
})
const filterStep = computed(() => {
return filterInputValues.value !== null && filterInputValues.value.length > 0 ? filterInputValues.value?.length % 3 : null
})
const operatorItems = ref([
{
type: "operator", value: '=', title: "is", deletable: false, props: {
type: "operator", deletable: false
}
}, {
type: "operator", value: '!=', title: "is not", deletable: false, props: {
type: "operator",
deletable: false
}
},
])
const autocompleteItems = computed(() => {
const index = filterOrSearch.value?.length ?? 0
// console.log(index)
if (filterStep.value === null || filterStep.value === 0) {
return toValue(facetsRef).map(value => {
return {
type: "facet",
value: `${value}-${index}`,
title: value,
deletable: false,
props: {
deletable: false,
type: "facet"
}
}
})
}
if (filterStep.value === 1) {
return operatorItems.value.map(it => { return { ...it, value: `${it.value}-${index}`, } })
}
if (filterStep.value === 2) {
// get the facet value
if (Array.isArray(filterOrSearch.value)) {
const { type, value } = filterOrSearch.value?.slice(-2, -1)[0]
const sanitizedValue = value.split("-")[0]
// console.log("compute new facets")
const facetDistri = msResult.value?.facetDistribution
// console.log(facetDistri)
return facetDistri?.[sanitizedValue] ? Object.entries(facetDistri[sanitizedValue]).map(([key, val]) => {
return {
type: "value", value: `${key}-${index}`, title: key, count: val, deletable: true, props: {
type: "value", count: val, deletable: true
}
}
}) : []
}
}
})
function selectItem(item) {
filterOrSearch.value = Array.isArray(filterOrSearch.value) ? [...filterOrSearch.value, item] : [item]
}
function deleteOneFilter(index: number) {
if (isFilter.value) {
filterOrSearch.value?.splice(index - 2, 2)
}
}
// watch(msFilterCompo, () => {
// searchOrFilter()
function clearSearch() {
search.value = ""
}
// })
async function downloadData() {
pendingDownloadData.value = true
try {
const { data } = await useAsyncMeiliSearch({
index: props.db,
index: props.autocompleteMeiliFacetsProps.db,
params: { ...toValue(notPaginatedParams), filter: toValue(computedFilter), sort: toValue(msSortBy) },
query: toValue(search),
})
@@ -304,84 +288,50 @@ async function downloadData() {
pendingDownloadData.value = false
}
}
function focusedOrBlur(isFocused: boolean) {
if (!isFocused) {
emitRefreshRes()
}
}
</script>
<template>
<v-card flat color="transparent">
<v-card-text>
</v-card-text>
<v-card-text>
<slot name="numerical-filters" :search="throttleSearch"></slot>
</v-card-text>
<slot name="numerical-filters" :search="throttleSearch"></slot>
<v-data-table-server v-if="!msError" v-model:page="page" color="primary" v-bind="dataTableServerProps"
v-model:items-per-page="hitsPerPage" v-model:sortBy="sortByRef" v-model:expanded="expanded" fixed-header
:loading="loading" :items="msResult?.hits ?? []" :items-length="totalHits" density="compact"
:items-per-page-options="itemsPerPage" :height="computedTableHeight" class="elevation-1 mt-2">
<template #top>
<template v-if="mobile">
<v-toolbar> <v-badge :content="totalHits" color="primary" class="mx-2">
<v-btn prepend-icon="md:download" :loading="pendingDownloadData" variant="text" color="primary"
@click="downloadData()">{{
props.title }}
</v-btn>
</v-badge></v-toolbar>
<v-toolbar><v-text-field v-model="search" label="Search..." hide-details="auto"
prepend-inner-icon="mdi-magnify" single-line clearable class="mx-2"></v-text-field></v-toolbar>
<v-toolbar><v-autocomplete ref="autocompleteInput" hide-details v-model:model-value="filterOrSearch"
auto-select-first chips clearable label="Filter results..." :items="autocompleteItems"
single-line item-value="value" item-title="title" multiple return-object
prepend-inner-icon="md:search" @click:appendInner="searchOrFilter" class="mx-2"
@click:clear="clearFilterOrSearch" @update:modelValue="() => clearSearch()">
<template #chip="{ props, item, index }">
<v-chip v-bind="props" :text="item.raw.title" :closable="item.props.deletable"
@click:close="item.props.type === deleteOneFilter(index)"></v-chip>
</template>
<template #item="{ props, item }">
<v-list-item v-bind="{ ...props, active: false, onClick: () => selectItem(item) }"
:title="item.title" :subtitle="item.raw?.count ? item.raw.count : ''"
:value="props.value">
</v-list-item>
</template>
</v-autocomplete></v-toolbar>
</template>
<template v-else>
<v-toolbar>
<v-card variant="flat" color="transparent">
<v-card-title>
<v-badge :content="totalHits" color="primary" class="mr-3">
<v-btn prepend-icon="md:download" :loading="pendingDownloadData" variant="text" color="primary"
@click="downloadData()">{{
props.title }}
</v-btn>
</v-badge>
<v-spacer></v-spacer>
<v-card variant="flat" color="transparent" :min-width="400" class="mx-2" :rounded="false">
<v-text-field v-model="search" label="Search..." hide-details="auto"
prepend-inner-icon="mdi-magnify" single-line clearable></v-text-field>
</v-card>
<v-card variant="flat" color="transparent" :min-width="500" class="mx-2" :rounded="false">
<v-autocomplete ref="autocompleteInput" hide-details v-model:model-value="filterOrSearch"
auto-select-first chips clearable label="Filter results..." :items="autocompleteItems"
single-line item-value="value" item-title="title" multiple return-object
prepend-inner-icon="md:search" @click:appendInner="searchOrFilter"
@click:clear="clearFilterOrSearch" @update:modelValue="() => clearSearch()">
<template #chip="{ props, item, index }">
<v-chip v-bind="props" :text="item.raw.title" :closable="item.props.deletable"
@click:close="item.props.type === deleteOneFilter(index)"></v-chip>
</template>
<template #item="{ props, item }">
<v-list-item v-bind="{ ...props, active: false, onClick: () => selectItem(item) }"
:title="item.title" :subtitle="item.raw?.count ? item.raw.count : ''"
:value="props.value">
</v-list-item>
</template>
</v-autocomplete>
</v-card>
</v-toolbar>
</template>
</v-card-title>
<v-card-title>
<v-text-field v-model="search" label="Search..." hide-details="auto" :disabled="pendingDownloadData"
prepend-inner-icon="mdi-magnify" single-line clearable
@update:focused="focusedOrBlur"></v-text-field>
</v-card-title>
<v-card-title>
<AutocompleteMeiliFacets v-model="msFilterCompo" v-bind="props.autocompleteMeiliFacetsProps"
:is-valid-filters="isValidFilters">
</AutocompleteMeiliFacets>
</v-card-title>
</v-card>
</template>
<template v-for="(slot, index) of Object.keys(slots)" :key="index" v-slot:[slot]="data">