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 User from '@/components/misc/user.vue'
|
||||||
import ProjectUserService from '@/services/projectUsers'
|
import ProjectUserService from '@/services/projectUsers'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
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 {
|
const {
|
||||||
modelValue,
|
modelValue,
|
||||||
@ -48,60 +57,6 @@ watch(
|
|||||||
const userService = new UserService()
|
const userService = new UserService()
|
||||||
const projectUserService = new ProjectUserService()
|
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 {
|
function escapeHtml(unsafe: string): string {
|
||||||
return unsafe
|
return unsafe
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@ -122,7 +77,7 @@ function unEscapeHtml(unsafe: string): string {
|
|||||||
|
|
||||||
const highlightedFilterQuery = computed(() => {
|
const highlightedFilterQuery = computed(() => {
|
||||||
let highlighted = escapeHtml(filterQuery.value)
|
let highlighted = escapeHtml(filterQuery.value)
|
||||||
dateFields
|
DATE_FIELDS
|
||||||
.forEach(o => {
|
.forEach(o => {
|
||||||
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||||
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
|
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>`
|
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 => {
|
.forEach(f => {
|
||||||
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
|
||||||
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
|
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>`
|
return `${f} ${token} <span class="filter-query__assignee_value" id="${id}">${value}<span>`
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
filterOperators
|
FILTER_OPERATORS
|
||||||
.map(o => ` ${escapeHtml(o)} `)
|
.map(o => ` ${escapeHtml(o)} `)
|
||||||
.forEach(o => {
|
.forEach(o => {
|
||||||
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
|
highlighted = highlighted.replaceAll(o, `<span class="filter-query__operator">${o}</span>`)
|
||||||
})
|
})
|
||||||
filterJoinOperators
|
FILTER_JOIN_OPERATOR
|
||||||
.map(o => escapeHtml(o))
|
.map(o => escapeHtml(o))
|
||||||
.forEach(o => {
|
.forEach(o => {
|
||||||
highlighted = highlighted.replaceAll(o, `<span class="filter-query__join-operator">${o}</span>`)
|
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>`)
|
highlighted = highlighted.replaceAll(f, `<span class="filter-query__field">${f}</span>`)
|
||||||
})
|
})
|
||||||
return highlighted
|
return highlighted
|
||||||
@ -234,7 +189,7 @@ function handleFieldInput(e, autocompleteOnInput) {
|
|||||||
const cursorPosition = filterInput.value.selectionStart
|
const cursorPosition = filterInput.value.selectionStart
|
||||||
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
|
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 pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?$', 'ig')
|
||||||
const match = pattern.exec(textUpToCursor)
|
const match = pattern.exec(textUpToCursor)
|
||||||
|
|
||||||
|
@ -41,6 +41,9 @@ import {objectToSnakeCase} from '@/helpers/case'
|
|||||||
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
import FilterInput from '@/components/project/partials/FilterInput.vue'
|
||||||
import {useRoute} from 'vue-router'
|
import {useRoute} from 'vue-router'
|
||||||
import type {TaskFilterParams} from '@/services/taskCollection'
|
import type {TaskFilterParams} from '@/services/taskCollection'
|
||||||
|
import {useLabelStore} from '@/stores/labels'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {transformFilterStringForApi} from '@/helpers/filters'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
hasTitle: {
|
hasTitle: {
|
||||||
@ -78,8 +81,21 @@ watchDebounced(
|
|||||||
},
|
},
|
||||||
{immediate: true, debounce: 500, maxWait: 1000},
|
{immediate: true, debounce: 500, maxWait: 1000},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const labelStore = useLabelStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
function change() {
|
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>
|
</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