fix: list filter logic
This commit is contained in:
parent
a60301f786
commit
cd88c0d0e4
@ -3,11 +3,11 @@
|
|||||||
<template #target>
|
<template #target>
|
||||||
<Button label="Filter">
|
<Button label="Filter">
|
||||||
<template #prefix><FilterIcon class="h-4" /></template>
|
<template #prefix><FilterIcon class="h-4" /></template>
|
||||||
<template v-if="filterValues.length" #suffix>
|
<template v-if="storage.size" #suffix>
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center w-5 h-5 text-2xs font-medium pt-[1px] bg-gray-900 text-white rounded"
|
class="flex justify-center items-center w-5 h-5 text-2xs font-medium pt-[1px] bg-gray-900 text-white rounded"
|
||||||
>
|
>
|
||||||
{{ filterValues.length }}
|
{{ storage.size }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
@ -16,8 +16,9 @@
|
|||||||
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2">
|
<div class="rounded-lg border border-gray-100 bg-white shadow-xl my-2">
|
||||||
<div class="p-2 min-w-[400px]">
|
<div class="p-2 min-w-[400px]">
|
||||||
<div
|
<div
|
||||||
v-if="filterValues.length"
|
v-if="storage.size"
|
||||||
v-for="(filter, i) in filterValues"
|
v-for="(f, i) in storage"
|
||||||
|
:key="f.field.fieldname"
|
||||||
id="filter-list"
|
id="filter-list"
|
||||||
class="flex flex-col gap-2 mb-3"
|
class="flex flex-col gap-2 mb-3"
|
||||||
>
|
>
|
||||||
@ -27,20 +28,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
class="!min-w-[140px]"
|
class="!min-w-[140px]"
|
||||||
:value="filter.field"
|
:value="f.field.fieldname"
|
||||||
:options="filterableFields.data"
|
:options="filterableFields.data"
|
||||||
@change="(e) => updateFilter(e, i)"
|
@change="(e) => updateFilter(e, i)"
|
||||||
placeholder="Filter by..."
|
placeholder="Filter by..."
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="select"
|
type="select"
|
||||||
v-model="filter.operator"
|
v-model="f.operator"
|
||||||
:options="getOperators(filter.fieldtype)"
|
:options="getOperators(f.field.fieldtype)"
|
||||||
placeholder="Operator"
|
placeholder="Operator"
|
||||||
/>
|
/>
|
||||||
|
<SearchComplete
|
||||||
|
v-if="typeLink.includes(f.field.fieldtype)"
|
||||||
|
:doctype="f.field.options"
|
||||||
|
:value="f.value"
|
||||||
|
@change="(v) => (f.value = v.value)"
|
||||||
|
placeholder="Value"
|
||||||
|
class="!min-w-[140px]"
|
||||||
|
/>
|
||||||
<component
|
<component
|
||||||
:is="getValSelect(filter.fieldtype, filter.options)"
|
v-else
|
||||||
v-model="filter.value"
|
:is="getValSelect(f.field.fieldtype, f.field.options)"
|
||||||
|
v-model="f.value"
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
class="!min-w-[140px]"
|
class="!min-w-[140px]"
|
||||||
/>
|
/>
|
||||||
@ -55,10 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
:options="filterableFields.data"
|
|
||||||
value=""
|
value=""
|
||||||
placeholder="filter by"
|
:options="filterableFields.data"
|
||||||
@change="(e) => setfilter(e)"
|
@change="(e) => setfilter(e)"
|
||||||
|
placeholder="Filter by..."
|
||||||
>
|
>
|
||||||
<template #target="{ togglePopover }">
|
<template #target="{ togglePopover }">
|
||||||
<Button
|
<Button
|
||||||
@ -74,7 +84,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Autocomplete>
|
</Autocomplete>
|
||||||
<Button
|
<Button
|
||||||
v-if="filterValues.length"
|
v-if="storage.size"
|
||||||
class="!text-gray-600"
|
class="!text-gray-600"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
label="Clear all filter"
|
label="Clear all filter"
|
||||||
@ -90,14 +100,15 @@
|
|||||||
import NestedPopover from '@/components/NestedPopover.vue'
|
import NestedPopover from '@/components/NestedPopover.vue'
|
||||||
import FilterIcon from '@/components/Icons/FilterIcon.vue'
|
import FilterIcon from '@/components/Icons/FilterIcon.vue'
|
||||||
import SearchComplete from '@/components/SearchComplete.vue'
|
import SearchComplete from '@/components/SearchComplete.vue'
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { useFilter } from '@/composables/filter'
|
||||||
import {
|
import {
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
Button,
|
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
FormControl,
|
FormControl,
|
||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, h } from 'vue'
|
import { h, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doctype: {
|
doctype: {
|
||||||
@ -106,11 +117,10 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterValues = ref([])
|
|
||||||
|
|
||||||
const filterableFields = createResource({
|
const filterableFields = createResource({
|
||||||
url: 'crm.api.doc.get_filterable_fields',
|
url: 'crm.api.doc.get_filterable_fields',
|
||||||
auto: true,
|
auto: true,
|
||||||
|
cache: ['filterableFields', props.doctype],
|
||||||
params: {
|
params: {
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
},
|
},
|
||||||
@ -126,12 +136,19 @@ const filterableFields = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { apply, storage } = useFilter(() => filterableFields.data)
|
||||||
const typeCheck = ['Check']
|
const typeCheck = ['Check']
|
||||||
const typeLink = ['Link']
|
const typeLink = ['Link']
|
||||||
const typeNumber = ['Float', 'Int']
|
const typeNumber = ['Float', 'Int']
|
||||||
const typeSelect = ['Select']
|
const typeSelect = ['Select']
|
||||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
||||||
|
|
||||||
|
watch(
|
||||||
|
storage,
|
||||||
|
useDebounceFn(() => apply(), 300),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
function getOperators(fieldtype) {
|
function getOperators(fieldtype) {
|
||||||
let options = []
|
let options = []
|
||||||
if (typeString.includes(fieldtype)) {
|
if (typeString.includes(fieldtype)) {
|
||||||
@ -171,9 +188,7 @@ function getOperators(fieldtype) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getValSelect(fieldtype, options) {
|
function getValSelect(fieldtype, options) {
|
||||||
if (typeLink.includes(fieldtype)) {
|
if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
|
||||||
return h(SearchComplete, { doctype: options })
|
|
||||||
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
|
|
||||||
const _options =
|
const _options =
|
||||||
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
|
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
|
||||||
return h(FormControl, {
|
return h(FormControl, {
|
||||||
@ -213,36 +228,40 @@ function getSelectOptions(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setfilter(data) {
|
function setfilter(data) {
|
||||||
filterValues.value = [
|
storage.value.add({
|
||||||
...filterValues.value,
|
field: {
|
||||||
{
|
|
||||||
field: data.value,
|
|
||||||
operator: getDefaultOperator(data.fieldtype),
|
|
||||||
value: getDefaultValue(data),
|
|
||||||
label: data.label,
|
label: data.label,
|
||||||
|
fieldname: data.value,
|
||||||
fieldtype: data.fieldtype,
|
fieldtype: data.fieldtype,
|
||||||
options: data.options,
|
options: data.options,
|
||||||
},
|
},
|
||||||
]
|
fieldname: data.value,
|
||||||
|
operator: getDefaultOperator(data.fieldtype),
|
||||||
|
value: getDefaultValue(data),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFilter(data, index) {
|
function updateFilter(data, index) {
|
||||||
filterValues.value[index] = {
|
storage.value.delete(Array.from(storage.value)[index])
|
||||||
field: data.value,
|
storage.value.add({
|
||||||
|
fieldname: data.value,
|
||||||
operator: getDefaultOperator(data.fieldtype),
|
operator: getDefaultOperator(data.fieldtype),
|
||||||
value: getDefaultValue(data),
|
value: getDefaultValue(data),
|
||||||
label: data.label,
|
field: {
|
||||||
fieldtype: data.fieldtype,
|
label: data.label,
|
||||||
options: data.options,
|
fieldname: data.value,
|
||||||
}
|
fieldtype: data.fieldtype,
|
||||||
|
options: data.options,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFilter(index) {
|
function removeFilter(index) {
|
||||||
filterValues.value.splice(index, 1)
|
storage.value.delete(Array.from(storage.value)[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearfilter(close) {
|
function clearfilter(close) {
|
||||||
filterValues.value = []
|
storage.value.clear()
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,10 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Autocomplete, createListResource } from 'frappe-ui'
|
||||||
Autocomplete,
|
|
||||||
createListResource,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
104
frontend/src/composables/filter.js
Normal file
104
frontend/src/composables/filter.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { ref, watchEffect } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { toValue } from '@vueuse/core'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
|
|
||||||
|
const operatorMap = {
|
||||||
|
is: '=',
|
||||||
|
'is not': '!=',
|
||||||
|
equals: '=',
|
||||||
|
'not equals': '!=',
|
||||||
|
yes: true,
|
||||||
|
no: false,
|
||||||
|
like: 'LIKE',
|
||||||
|
'not like': 'NOT LIKE',
|
||||||
|
'>': '>',
|
||||||
|
'<': '<',
|
||||||
|
'>=': '>=',
|
||||||
|
'<=': '<=',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFilter(fields) {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { getUser } = usersStore()
|
||||||
|
const storage = ref(new Set())
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const f__ = toValue(fields)
|
||||||
|
if (fields && !f__) return
|
||||||
|
storage.value = new Set()
|
||||||
|
const q = route.query.q || ''
|
||||||
|
q.split(' ')
|
||||||
|
.map((f) => {
|
||||||
|
const [fieldname, operator, value] = f
|
||||||
|
.split(':')
|
||||||
|
.map(decodeURIComponent)
|
||||||
|
const field = (f__ || []).find((f) => f.fieldname === fieldname)
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
fieldname,
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((f) => !f__ || (f__ && f.field))
|
||||||
|
.filter((f) => operatorMap[f.operator])
|
||||||
|
.forEach((f) => storage.value.add(f))
|
||||||
|
})
|
||||||
|
|
||||||
|
function getArgs(old) {
|
||||||
|
old = old || {}
|
||||||
|
const l__ = Array.from(storage.value)
|
||||||
|
const obj = l__.map(transformIn).reduce((p, c) => {
|
||||||
|
p[c.fieldname] = [operatorMap[c.operator.toLowerCase()], c.value]
|
||||||
|
return p
|
||||||
|
}, {})
|
||||||
|
const merged = { ...old, ...obj }
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(r) {
|
||||||
|
r = r || route
|
||||||
|
const l__ = Array.from(storage.value)
|
||||||
|
const q = l__
|
||||||
|
.map(transformOut)
|
||||||
|
.map((f) =>
|
||||||
|
[f.fieldname, f.operator.toLowerCase(), f.value]
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join(':')
|
||||||
|
)
|
||||||
|
.join(' ')
|
||||||
|
if (!q && !r.query.q) {
|
||||||
|
router.push({ ...r, query: { ...r.query } })
|
||||||
|
} else {
|
||||||
|
router.push({ ...r, query: { ...r.query, q } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set fields internally. These will not reflect in URL.
|
||||||
|
* Can be used for APIs
|
||||||
|
*/
|
||||||
|
function transformIn(f) {
|
||||||
|
if (f.fieldname === '_assign') {
|
||||||
|
f.operator = f.operator === 'is' ? 'like' : 'not like'
|
||||||
|
}
|
||||||
|
if (f.operator.includes('like') && !f.value.includes('%')) {
|
||||||
|
f.value = `%${f.value}%`
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set fields in URL query
|
||||||
|
*/
|
||||||
|
function transformOut(f) {
|
||||||
|
if (f.value === '@me') {
|
||||||
|
f.value = getUser()
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply, getArgs, storage }
|
||||||
|
}
|
||||||
@ -25,7 +25,7 @@
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
<template #right-subheader>
|
<template #right-subheader>
|
||||||
<Filter doctype="CRM Lead"/>
|
<Filter doctype="CRM Lead" />
|
||||||
<SortBy doctype="CRM Lead" />
|
<SortBy doctype="CRM Lead" />
|
||||||
<Button icon="more-horizontal" />
|
<Button icon="more-horizontal" />
|
||||||
</template>
|
</template>
|
||||||
@ -59,6 +59,8 @@ import SortBy from '@/components/SortBy.vue'
|
|||||||
import Filter from '@/components/Filter.vue'
|
import Filter from '@/components/Filter.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { useOrderBy } from '@/composables/orderby'
|
import { useOrderBy } from '@/composables/orderby'
|
||||||
|
import { useFilter } from '@/composables/filter'
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -77,6 +79,7 @@ const list = {
|
|||||||
}
|
}
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { get: getOrderBy } = useOrderBy()
|
const { get: getOrderBy } = useOrderBy()
|
||||||
|
const { getArgs, storage } = useFilter()
|
||||||
|
|
||||||
const currentView = ref({
|
const currentView = ref({
|
||||||
label: 'List',
|
label: 'List',
|
||||||
@ -99,6 +102,7 @@ const leads = createListResource({
|
|||||||
'lead_owner',
|
'lead_owner',
|
||||||
'modified',
|
'modified',
|
||||||
],
|
],
|
||||||
|
filters: getArgs() || {},
|
||||||
orderBy: 'modified desc',
|
orderBy: 'modified desc',
|
||||||
cache: 'Leads',
|
cache: 'Leads',
|
||||||
pageLength: 20,
|
pageLength: 20,
|
||||||
@ -107,10 +111,22 @@ const leads = createListResource({
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => getOrderBy(),
|
() => getOrderBy(),
|
||||||
(value) => {
|
(value, old_value) => {
|
||||||
leads.orderBy = value || 'modified desc'
|
if (!value && !old_value) return
|
||||||
|
leads.orderBy = getOrderBy() || 'modified desc'
|
||||||
leads.reload()
|
leads.reload()
|
||||||
}
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
storage,
|
||||||
|
useDebounceFn((value, old_value) => {
|
||||||
|
if (JSON.stringify([...value]) === JSON.stringify([...old_value])) return
|
||||||
|
leads.filters = getArgs() || {}
|
||||||
|
leads.reload()
|
||||||
|
}, 300),
|
||||||
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user