feat(filter): resolve labels and projects to ids before filtering

This commit is contained in:
kolaente 2024-03-08 14:35:47 +01:00
parent 0c947790e8
commit 55b806d311
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
4 changed files with 218 additions and 61 deletions

View File

@ -11,6 +11,15 @@ import XLabel from '@/components/tasks/partials/label.vue'
import User from '@/components/misc/user.vue'
import ProjectUserService from '@/services/projectUsers'
import {useProjectStore} from '@/stores/projects'
import {
DATE_FIELDS,
ASSIGNEE_FIELDS,
AUTOCOMPLETE_FIELDS,
AVAILABLE_FILTER_FIELDS,
FILTER_JOIN_OPERATOR,
FILTER_OPERATORS,
FILTER_OPERATORS_REGEX,
} from '@/helpers/filters'
const {
modelValue,
@ -48,60 +57,6 @@ watch(
const userService = new UserService()
const projectUserService = new ProjectUserService()
const dateFields = [
'dueDate',
'startDate',
'endDate',
'doneAt',
'reminders',
]
const assigneeFields = [
'assignees',
]
const labelFields = [
'labels',
]
const autocompleteFields = [
...labelFields,
...assigneeFields,
'project',
]
const availableFilterFields = [
'done',
'priority',
'usePriority',
'percentDone',
...dateFields,
...assigneeFields,
...labelFields,
]
const filterOperators = [
'!=',
'=',
'>',
'>=',
'<',
'<=',
'like',
'in',
'?=',
]
const filterJoinOperators = [
'&&',
'||',
'(',
')',
]
const FILTER_OPERATORS_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=)'
const FILTER_JOIN_OPERATORS_REGEX = '(&amp;&amp;|\|\||\(|\))'
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
@ -122,7 +77,7 @@ function unEscapeHtml(unsafe: string): string {
const highlightedFilterQuery = computed(() => {
let highlighted = escapeHtml(filterQuery.value)
dateFields
DATE_FIELDS
.forEach(o => {
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
@ -133,7 +88,7 @@ const highlightedFilterQuery = computed(() => {
return `${o}${spacesBefore}${token}${spacesAfter}<button class="is-primary filter-query__date_value" data-position="${position}">${value}</button><span class="filter-query__date_value_placeholder">${value}</span>`
})
})
assigneeFields
ASSIGNEE_FIELDS
.forEach(f => {
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
@ -169,17 +124,17 @@ const highlightedFilterQuery = computed(() => {
return `${f} ${token} <span class="filter-query__assignee_value" id="${id}">${value}<span>`
})
})
filterOperators
FILTER_OPERATORS
.map(o => ` ${escapeHtml(o)} `)
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
})
filterJoinOperators
FILTER_JOIN_OPERATOR
.map(o => escapeHtml(o))
.forEach(o => {
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
})
availableFilterFields.forEach(f => {
AVAILABLE_FILTER_FIELDS.forEach(f => {
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
})
return highlighted
@ -234,7 +189,7 @@ function handleFieldInput(e, autocompleteOnInput) {
const cursorPosition = filterInput.value.selectionStart
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
autocompleteFields.forEach(field => {
AUTOCOMPLETE_FIELDS.forEach(field => {
const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?$', 'ig')
const match = pattern.exec(textUpToCursor)

View File

@ -41,6 +41,9 @@ import {objectToSnakeCase} from '@/helpers/case'
import FilterInput from '@/components/project/partials/FilterInput.vue'
import {useRoute} from 'vue-router'
import type {TaskFilterParams} from '@/services/taskCollection'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {transformFilterStringForApi} from '@/helpers/filters'
const props = defineProps({
hasTitle: {
@ -78,8 +81,21 @@ watchDebounced(
},
{immediate: true, debounce: 500, maxWait: 1000},
)
const labelStore = useLabelStore()
const projectStore = useProjectStore()
function change() {
modelValue.value = params.value
const filter = transformFilterStringForApi(
params.value.filter,
labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null,
projectTitle => projectStore.searchProject(projectTitle)[0]?.id || null,
)
modelValue.value = {
...params.value,
filter,
}
}
</script>

View File

@ -0,0 +1,85 @@
import {describe, expect, it} from 'vitest'
import {transformFilterStringForApi} from '@/helpers/filters'
const nullResolver = (title: string) => null
describe('Filter Transformation', () => {
const cases = {
'done': 'done',
'priority': 'priority',
'percentDone': 'percent_done',
'dueDate': 'due_date',
'startDate': 'start_date',
'endDate': 'end_date',
'doneAt': 'done_at',
'reminders': 'reminders',
'assignees': 'assignees',
'labels': 'labels',
}
for (const c in cases) {
it('should transform all filter params for ' + c + ' to snake_case', () => {
const transformed = transformFilterStringForApi(c + ' = ipsum', nullResolver, nullResolver)
expect(transformed).toBe(cases[c] + ' = ipsum')
})
}
it('should correctly resolve labels', () => {
const transformed = transformFilterStringForApi(
'labels = lorem',
(title: string) => 1,
nullResolver,
)
expect(transformed).toBe('labels = 1')
})
it('should correctly resolve multiple labels', () => {
const transformed = transformFilterStringForApi(
'labels = lorem && dueDate = now && labels = ipsum',
(title: string) => {
switch (title) {
case 'lorem':
return 1
case 'ipsum':
return 2
default:
return null
}
},
nullResolver,
)
expect(transformed).toBe('labels = 1&& due_date = now && labels = 2')
})
it('should correctly resolve projects', () => {
const transformed = transformFilterStringForApi(
'project = lorem',
nullResolver,
(title: string) => 1,
)
expect(transformed).toBe('project = 1')
})
it('should correctly resolve multiple projects', () => {
const transformed = transformFilterStringForApi(
'project = lorem && dueDate = now || project = ipsum',
nullResolver,
(title: string) => {
switch (title) {
case 'lorem':
return 1
case 'ipsum':
return 2
default:
return null
}
},
)
expect(transformed).toBe('project = 1&& due_date = now || project = 2')
})
})

View File

@ -0,0 +1,101 @@
import {snakeCase} from 'snake-case'
export const DATE_FIELDS = [
'dueDate',
'startDate',
'endDate',
'doneAt',
'reminders',
]
export const ASSIGNEE_FIELDS = [
'assignees',
]
export const LABEL_FIELDS = [
'labels',
]
export const PROJECT_FIELDS = [
'project',
]
export const AUTOCOMPLETE_FIELDS = [
...LABEL_FIELDS,
...ASSIGNEE_FIELDS,
...PROJECT_FIELDS,
]
export const AVAILABLE_FILTER_FIELDS = [
'done',
'priority',
'percentDone',
...DATE_FIELDS,
...ASSIGNEE_FIELDS,
...LABEL_FIELDS,
]
export const FILTER_OPERATORS = [
'!=',
'=',
'>',
'>=',
'<',
'<=',
'like',
'in',
'?=',
]
export const FILTER_JOIN_OPERATOR = [
'&&',
'||',
'(',
')',
]
export const FILTER_OPERATORS_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=)'
export function transformFilterStringForApi(
filter: string,
labelResolver: (title: string) => number | null,
projectResolver: (title: string) => number | null,
): string {
// Transform labels to ids
LABEL_FIELDS.forEach(field => {
const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig')
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
const labelId = labelResolver(keyword.trim())
if (labelId !== null) {
filter = filter.replace(keyword, String(labelId))
}
}
}
})
// Transform projects to ids
PROJECT_FIELDS.forEach(field => {
const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig')
let match: RegExpExecArray | null
while ((match = pattern.exec(filter)) !== null) {
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
const projectId = projectResolver(keyword.trim())
if (projectId !== null) {
filter = filter.replace(keyword, String(projectId))
}
}
}
})
// Transform all attributes to snake case
AVAILABLE_FILTER_FIELDS.forEach(f => {
filter = filter.replace(f, snakeCase(f))
})
return filter
}