diff --git a/frontend/src/components/project/partials/FilterInput.vue b/frontend/src/components/project/partials/FilterInput.vue
index f6b1f8a79..a9d2d411e 100644
--- a/frontend/src/components/project/partials/FilterInput.vue
+++ b/frontend/src/components/project/partials/FilterInput.vue
@@ -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}${value}`
})
})
- 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} ${value}`
})
})
- filterOperators
+ FILTER_OPERATORS
.map(o => ` ${escapeHtml(o)} `)
.forEach(o => {
highlighted = highlighted.replaceAll(o, `${o}`)
})
- filterJoinOperators
+ FILTER_JOIN_OPERATOR
.map(o => escapeHtml(o))
.forEach(o => {
highlighted = highlighted.replaceAll(o, `${o}`)
})
- availableFilterFields.forEach(f => {
+ AVAILABLE_FILTER_FIELDS.forEach(f => {
highlighted = highlighted.replaceAll(f, `${f}`)
})
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)
diff --git a/frontend/src/components/project/partials/filters.vue b/frontend/src/components/project/partials/filters.vue
index ce43e0707..68a6a70be 100644
--- a/frontend/src/components/project/partials/filters.vue
+++ b/frontend/src/components/project/partials/filters.vue
@@ -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,
+ }
}
diff --git a/frontend/src/helpers/filters.test.ts b/frontend/src/helpers/filters.test.ts
new file mode 100644
index 000000000..1ad9fb9fe
--- /dev/null
+++ b/frontend/src/helpers/filters.test.ts
@@ -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')
+ })
+})
diff --git a/frontend/src/helpers/filters.ts b/frontend/src/helpers/filters.ts
new file mode 100644
index 000000000..cb7a8e099
--- /dev/null
+++ b/frontend/src/helpers/filters.ts
@@ -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
+}