feat(filter): resolve labels and projects to ids before filtering
This commit is contained in:
parent
0c947790e8
commit
55b806d311
@ -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 = '(<|>|<=|>=|=|!=)'
|
||||
const FILTER_JOIN_OPERATORS_REGEX = '(&&|\|\||\(|\))'
|
||||
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
@ -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)
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
85
frontend/src/helpers/filters.test.ts
Normal file
85
frontend/src/helpers/filters.test.ts
Normal 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')
|
||||
})
|
||||
})
|
101
frontend/src/helpers/filters.ts
Normal file
101
frontend/src/helpers/filters.ts
Normal 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 = '(<|>|<=|>=|=|!=)'
|
||||
|
||||
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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user