diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md
index 1ff2ea801..fc364ee7e 100644
--- a/docs/content/doc/usage/errors.md
+++ b/docs/content/doc/usage/errors.md
@@ -73,29 +73,30 @@ This document describes the different errors Vikunja can return.
| ErrorCode | HTTP Status Code | Description |
|-----------|------------------|----------------------------------------------------------------------------|
-| 4001 | 400 | The project task text cannot be empty. |
-| 4002 | 404 | The project task does not exist. |
-| 4003 | 403 | All bulk editing tasks must belong to the same project. |
-| 4004 | 403 | Need at least one task when bulk editing tasks. |
-| 4005 | 403 | The user does not have the right to see the task. |
-| 4006 | 403 | The user tried to set a parent task as the task itself. |
-| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
-| 4008 | 409 | The user tried to create a task relation which already exists. |
-| 4009 | 404 | The task relation does not exist. |
-| 4010 | 400 | Cannot relate a task with itself. |
-| 4011 | 404 | The task attachment does not exist. |
-| 4012 | 400 | The task attachment is too large. |
-| 4013 | 400 | The task sort param is invalid. |
-| 4014 | 400 | The task sort order is invalid. |
-| 4015 | 404 | The task comment does not exist. |
-| 4016 | 400 | Invalid task field. |
-| 4017 | 400 | Invalid task filter comparator. |
-| 4018 | 400 | Invalid task filter concatinator. |
-| 4019 | 400 | Invalid task filter value. |
-| 4020 | 400 | The provided attachment does not belong to that task. |
-| 4021 | 400 | This user is already assigned to that task. |
-| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
-| 4023 | 409 | Tried to create a task relation which would create a cycle. |
+| 4001 | 400 | The project task text cannot be empty. |
+| 4002 | 404 | The project task does not exist. |
+| 4003 | 403 | All bulk editing tasks must belong to the same project. |
+| 4004 | 403 | Need at least one task when bulk editing tasks. |
+| 4005 | 403 | The user does not have the right to see the task. |
+| 4006 | 403 | The user tried to set a parent task as the task itself. |
+| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. |
+| 4008 | 409 | The user tried to create a task relation which already exists. |
+| 4009 | 404 | The task relation does not exist. |
+| 4010 | 400 | Cannot relate a task with itself. |
+| 4011 | 404 | The task attachment does not exist. |
+| 4012 | 400 | The task attachment is too large. |
+| 4013 | 400 | The task sort param is invalid. |
+| 4014 | 400 | The task sort order is invalid. |
+| 4015 | 404 | The task comment does not exist. |
+| 4016 | 400 | Invalid task field. |
+| 4017 | 400 | Invalid task filter comparator. |
+| 4018 | 400 | Invalid task filter concatinator. |
+| 4019 | 400 | Invalid task filter value. |
+| 4020 | 400 | The provided attachment does not belong to that task. |
+| 4021 | 400 | This user is already assigned to that task. |
+| 4022 | 400 | The task has a relative reminder which does not specify relative to what. |
+| 4023 | 409 | Tried to create a task relation which would create a cycle. |
+| 4024 | 400 | The provided filter expression is invalid. |
## Team
diff --git a/docs/content/doc/usage/filters.md b/docs/content/doc/usage/filters.md
new file mode 100644
index 000000000..b7885efc9
--- /dev/null
+++ b/docs/content/doc/usage/filters.md
@@ -0,0 +1,67 @@
+---
+title: "Filters"
+date: 2024-03-09T19:51:32+02:00
+draft: false
+type: doc
+menu:
+ sidebar:
+ parent: "usage"
+---
+
+# Filter Syntax
+
+To filter tasks via the api, you can use a query syntax similar to SQL.
+
+This document is about filtering via the api. To filter in Vikunja's web ui, check out the help text below the filter query input.
+
+{{< table_of_contents >}}
+
+## Available fields
+
+The available fields for filtering include:
+
+* `done`: Whether the task is completed or not
+* `priority`: The priority level of the task (1-5)
+* `percentDone`: The percentage of completion for the task (0-100)
+* `dueDate`: The due date of the task
+* `startDate`: The start date of the task
+* `endDate`: The end date of the task
+* `doneAt`: The date and time when the task was completed
+* `assignees`: The assignees of the task
+* `labels`: The labels associated with the task
+* `project`: The project the task belongs to (only available for saved filters, not on a project level)
+
+You can date math to set relative dates. Click on the date value in a query to find out more.
+
+All strings must be either single-word or enclosed in `"` or `'`. This extends to date values like `2024-03-11`.
+
+## Operators
+
+The available operators for filtering include:
+
+* `!=`: Not equal to
+* `=`: Equal to
+* `>`: Greater than
+* `>=`: Greater than or equal to
+* `<`: Less than
+* `<=`: Less than or equal to
+* `like`: Matches a pattern (using wildcard `%`)
+* `in`: Matches any value in a list
+
+To combine multiple conditions, you can use the following logical operators:
+
+* `&&`: AND operator, matches if all conditions are true
+* `||`: OR operator, matches if any of the conditions are true
+* `(` and `)`: Parentheses for grouping conditions
+
+## Examples
+
+Here are some examples of filter queries:
+
+* `priority = 4`: Matches tasks with priority level 4
+* `dueDate < now`: Matches tasks with a due date in the past
+* `done = false && priority >= 3`: Matches undone tasks with priority level 3 or higher
+* `assignees in [user1, user2]`: Matches tasks assigned to either "user1" or "user2
+* `(priority = 1 || priority = 2) && dueDate <= now`: Matches tasks with priority level 1 or 2 and a due date in the past
+
+
diff --git a/frontend/src/components/date/dateRanges.ts b/frontend/src/components/date/dateRanges.ts
index 648f001b4..556c21c1f 100644
--- a/frontend/src/components/date/dateRanges.ts
+++ b/frontend/src/components/date/dateRanges.ts
@@ -19,3 +19,28 @@ export const DATE_RANGES = {
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}
+
+export const DATE_VALUES = {
+ 'now': 'now',
+ 'startOfToday': 'now/d',
+ 'endOfToday': 'now/d+1d',
+
+ 'beginningOflastWeek': 'now/w-1w',
+ 'endOfLastWeek': 'now/w-2w',
+ 'beginningOfThisWeek': 'now/w',
+ 'endOfThisWeek': 'now/w+1w',
+ 'startOfNextWeek': 'now/w+1w',
+ 'endOfNextWeek': 'now/w+2w',
+ 'in7Days': 'now+7d',
+
+ 'beginningOfLastMonth': 'now/M-1M',
+ 'endOfLastMonth': 'now/M-2M',
+ 'startOfThisMonth': 'now/M',
+ 'endOfThisMonth': 'now/M+1M',
+ 'startOfNextMonth': 'now/M+1M',
+ 'endOfNextMonth': 'now/M+2M',
+ 'in30Days': 'now+30d',
+
+ 'startOfThisYear': 'now/y',
+ 'endOfThisYear': 'now/y+1y',
+}
diff --git a/frontend/src/components/date/datepickerWithRange.vue b/frontend/src/components/date/datepickerWithRange.vue
index be9e70683..da35d013f 100644
--- a/frontend/src/components/date/datepickerWithRange.vue
+++ b/frontend/src/components/date/datepickerWithRange.vue
@@ -75,14 +75,15 @@
{{ $t('input.datemathHelp.canuse') }}
-
- {{ $t('input.datemathHelp.learnhow') }}
-
+
+ {{ $t('input.datemathHelp.learnhow') }}
+
+
+
+
emit('close')"
+ >
+
+
+
+
+ {{ $t('misc.custom') }}
+
+
+ {{ $t(`input.datepickerRange.values.${text}`) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/input/AutocompleteDropdown.vue b/frontend/src/components/input/AutocompleteDropdown.vue
new file mode 100644
index 000000000..64545214d
--- /dev/null
+++ b/frontend/src/components/input/AutocompleteDropdown.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/misc/popup.vue b/frontend/src/components/misc/popup.vue
index 1607cb713..24b8235e8 100644
--- a/frontend/src/components/misc/popup.vue
+++ b/frontend/src/components/misc/popup.vue
@@ -1,7 +1,7 @@
@@ -9,13 +9,13 @@
ref="popup"
class="popup"
:class="{
- 'is-open': open,
- 'has-overflow': props.hasOverflow && open
+ 'is-open': openValue,
+ 'has-overflow': props.hasOverflow && openValue
}"
>
@@ -23,7 +23,7 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/project/partials/FilterInput.vue b/frontend/src/components/project/partials/FilterInput.vue
new file mode 100644
index 000000000..e4221a1fc
--- /dev/null
+++ b/frontend/src/components/project/partials/FilterInput.vue
@@ -0,0 +1,354 @@
+
+
+
+
+
{{ $t('filters.query.title') }}
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/project/partials/FilterInputDocs.vue b/frontend/src/components/project/partials/FilterInputDocs.vue
new file mode 100644
index 000000000..3ed58008f
--- /dev/null
+++ b/frontend/src/components/project/partials/FilterInputDocs.vue
@@ -0,0 +1,80 @@
+
+
+
+
+ {{ $t('filters.query.help.link') }}
+
+
+
+
+
{{ $t('filters.query.help.intro') }}
+
+ done
: {{ $t('filters.query.help.fields.done') }}
+ priority
: {{ $t('filters.query.help.fields.priority') }}
+ percentDone
: {{ $t('filters.query.help.fields.percentDone') }}
+ dueDate
: {{ $t('filters.query.help.fields.dueDate') }}
+ startDate
: {{ $t('filters.query.help.fields.startDate') }}
+ endDate
: {{ $t('filters.query.help.fields.endDate') }}
+ doneAt
: {{ $t('filters.query.help.fields.doneAt') }}
+ assignees
: {{ $t('filters.query.help.fields.assignees') }}
+ labels
: {{ $t('filters.query.help.fields.labels') }}
+ project
: {{ $t('filters.query.help.fields.project') }}
+
+
{{ $t('filters.query.help.canUseDatemath') }}
+
{{ $t('filters.query.help.operators.intro') }}
+
+ !=
: {{ $t('filters.query.help.operators.notEqual') }}
+ =
: {{ $t('filters.query.help.operators.equal') }}
+ >
: {{ $t('filters.query.help.operators.greaterThan') }}
+ >=
: {{ $t('filters.query.help.operators.greaterThanOrEqual') }}
+ <
: {{ $t('filters.query.help.operators.lessThan') }}
+ <=
: {{ $t('filters.query.help.operators.lessThanOrEqual') }}
+ like
: {{ $t('filters.query.help.operators.like') }}
+ in
: {{ $t('filters.query.help.operators.in') }}
+
+
{{ $t('filters.query.help.logicalOperators.intro') }}
+
+ &&
: {{ $t('filters.query.help.logicalOperators.and') }}
+ ||
: {{ $t('filters.query.help.logicalOperators.or') }}
+ (
and )
: {{ $t('filters.query.help.logicalOperators.parentheses') }}
+
+
{{ $t('filters.query.help.examples.intro') }}
+
+ priority = 4
: {{ $t('filters.query.help.examples.priorityEqual') }}
+ dueDate < now
: {{ $t('filters.query.help.examples.dueDatePast') }}
+
+ done = false && priority >= 3
:
+ {{ $t('filters.query.help.examples.undoneHighPriority') }}
+
+ assignees in [user1, user2]
: {{ $t('filters.query.help.examples.assigneesIn') }}
+
+ (priority = 1 || priority = 2) && dueDate <= now
:
+ {{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/project/partials/filter-popup.vue b/frontend/src/components/project/partials/filter-popup.vue
index aec51390b..4c4d3eb5c 100644
--- a/frontend/src/components/project/partials/filter-popup.vue
+++ b/frontend/src/components/project/partials/filter-popup.vue
@@ -25,6 +25,8 @@
v-model="value"
:has-title="true"
class="filter-popup"
+ @update:modelValue="emitChanges"
+ @showResultsButtonClicked="() => modalOpen = false"
/>
@@ -34,47 +36,38 @@ import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue'
-import {getDefaultParams} from '@/composables/useTaskList'
+import {getDefaultTaskFilterParams, type TaskFilterParams} from '@/services/taskCollection'
-const props = defineProps({
- modelValue: {
- required: true,
- },
-})
-const emit = defineEmits(['update:modelValue'])
+const modelValue = defineModel({})
-const value = computed({
- get() {
- return props.modelValue
- },
- set(value) {
- if(props.modelValue === value) {
- return
- }
- emit('update:modelValue', value)
- },
-})
+const value = ref({})
watch(
- () => props.modelValue,
- (modelValue) => {
+ () => modelValue.value,
+ (modelValue: TaskFilterParams) => {
value.value = modelValue
},
{immediate: true},
)
+function emitChanges(newValue: TaskFilterParams) {
+ if (modelValue.value?.filter === newValue.filter && modelValue.value?.s === newValue.s) {
+ return
+ }
+
+ modelValue.value.filter = newValue.filter
+ modelValue.value.s = newValue.s
+}
+
const hasFilters = computed(() => {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
- const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
- const def = {...getDefaultParams()}
+ const {filter, s} = value.value
+ const def = {...getDefaultTaskFilterParams()}
- const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
+ const params = {filter, s}
const defaultParams = {
- filter_by: def.filter_by,
- filter_value: def.filter_value,
- filter_comparator: def.filter_comparator,
- filter_concat: def.filter_concat,
+ filter: def.filter,
s: s ? def.s : undefined,
}
@@ -84,7 +77,7 @@ const hasFilters = computed(() => {
const modalOpen = ref(false)
function clearFilters() {
- value.value = {...getDefaultParams()}
+ value.value = {...getDefaultTaskFilterParams()}
}
diff --git a/frontend/src/components/project/partials/filters.vue b/frontend/src/components/project/partials/filters.vue
index a9058fb21..7b5b87418 100644
--- a/frontend/src/components/project/partials/filters.vue
+++ b/frontend/src/components/project/partials/filters.vue
@@ -2,195 +2,35 @@
+
+
{{ $t('filters.attributes.includeNulls') }}
-
- {{ $t('filters.attributes.requireAll') }}
-
-
- {{ $t('filters.attributes.showDoneTasks') }}
-
-
- {{ $t('filters.attributes.sortAlphabetically') }}
-
-
-
-
{{ $t('misc.search') }}
-
-
-
-
-
-
{{ $t('task.attributes.priority') }}
-
-
-
- {{ $t('filters.attributes.enablePriority') }}
-
-
-
-
-
{{ $t('task.attributes.percentDone') }}
-
-
-
- {{ $t('filters.attributes.enablePercentDone') }}
-
-
-
-
-
{{ $t('task.attributes.dueDate') }}
-
- setDateFilter('due_date', values)"
- >
-
-
- {{ buttonText }}
-
-
-
-
-
-
-
{{ $t('task.attributes.startDate') }}
-
- setDateFilter('start_date', values)"
- >
-
-
- {{ buttonText }}
-
-
-
-
-
-
-
{{ $t('task.attributes.endDate') }}
-
- setDateFilter('end_date', values)"
- >
-
-
- {{ buttonText }}
-
-
-
-
-
-
-
{{ $t('task.attributes.reminders') }}
-
- setDateFilter('reminders', values)"
- >
-
-
- {{ buttonText }}
-
-
-
-
-
-
{{ $t('task.attributes.assignees') }}
-
-
-
-
-
-
-
{{ $t('task.attributes.labels') }}
-
-
-
-
+
-
-
{{ $t('project.projects') }}
-
-
-
-
+
+ {{ $t('filters.showResults') }}
+
@@ -200,419 +40,94 @@ export const ALPHABETICAL_SORT = 'title'
-
-
diff --git a/frontend/src/components/tasks/add-task.vue b/frontend/src/components/tasks/add-task.vue
index 9168a90f0..ffc5c6430 100644
--- a/frontend/src/components/tasks/add-task.vue
+++ b/frontend/src/components/tasks/add-task.vue
@@ -77,7 +77,7 @@ const props = defineProps({
const emit = defineEmits(['taskAdded'])
const newTaskTitle = ref('')
-const newTaskInput = useAutoHeightTextarea(newTaskTitle)
+const {textarea: newTaskInput} = useAutoHeightTextarea(newTaskTitle)
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
diff --git a/frontend/src/composables/useAutoHeightTextarea.ts b/frontend/src/composables/useAutoHeightTextarea.ts
index b1f1d0484..6527ae2bd 100644
--- a/frontend/src/composables/useAutoHeightTextarea.ts
+++ b/frontend/src/composables/useAutoHeightTextarea.ts
@@ -6,6 +6,7 @@ import {debouncedWatch, tryOnMounted, useWindowSize, type MaybeRef} from '@vueus
export function useAutoHeightTextarea(value: MaybeRef) {
const textarea = ref(null)
const minHeight = ref(0)
+ const height = ref('')
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLTextAreaElement | null) {
@@ -19,18 +20,17 @@ export function useAutoHeightTextarea(value: MaybeRef) {
textareaEl.value = textareaEl.placeholder
}
- const cs = getComputedStyle(textareaEl)
+ // const cs = getComputedStyle(textareaEl)
textareaEl.style.minHeight = ''
textareaEl.style.height = '0'
- const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
- const height = textareaEl.scrollHeight + offset + 'px'
+ height.value = textareaEl.scrollHeight + 'px'
- textareaEl.style.height = height
+ textareaEl.style.height = height.value
// calculate min-height for the first time
if (!minHeight.value) {
- minHeight.value = parseFloat(height)
+ minHeight.value = parseFloat(height.value)
}
textareaEl.style.minHeight = minHeight.value.toString()
@@ -68,5 +68,8 @@ export function useAutoHeightTextarea(value: MaybeRef) {
},
)
- return textarea
+ return {
+ textarea,
+ height,
+ }
}
\ No newline at end of file
diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts
index e1be96460..51e874c4d 100644
--- a/frontend/src/composables/useTaskList.ts
+++ b/frontend/src/composables/useTaskList.ts
@@ -2,7 +2,7 @@ import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRoute} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
-import TaskCollectionService from '@/services/taskCollection'
+import TaskCollectionService, {getDefaultTaskFilterParams} from '@/services/taskCollection'
import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
@@ -24,16 +24,6 @@ export interface SortBy {
done_at?: Order,
}
-// FIXME: merge with DEFAULT_PARAMS in filters.vue
-export const getDefaultParams = () => ({
- sort_by: ['position', 'id'],
- order_by: ['asc', 'desc'],
- filter_by: ['done'],
- filter_value: ['false'],
- filter_comparator: ['equals'],
- filter_concat: 'and',
-})
-
const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
@@ -67,7 +57,7 @@ export function useTaskList(projectIdGetter: ComputedGetter, sor
const projectId = computed(() => projectIdGetter())
- const params = ref({...getDefaultParams()})
+ const params = ref({...getDefaultTaskFilterParams()})
const search = ref('')
const page = useRouteQuery('page', '1', { transform: Number })
diff --git a/frontend/src/helpers/filters.test.ts b/frontend/src/helpers/filters.test.ts
new file mode 100644
index 000000000..a1910ff26
--- /dev/null
+++ b/frontend/src/helpers/filters.test.ts
@@ -0,0 +1,156 @@
+import {describe, expect, it} from 'vitest'
+import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters'
+
+const nullTitleToIdResolver = (title: string) => null
+const nullIdToTitleResolver = (id: number) => null
+describe('Filter Transformation', () => {
+
+ const fieldCases = {
+ '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',
+ }
+
+ describe('For api', () => {
+ for (const c in fieldCases) {
+ it('should transform all filter params for ' + c + ' to snake_case', () => {
+ const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
+
+ expect(transformed).toBe(fieldCases[c] + ' = ipsum')
+ })
+ }
+
+ it('should correctly resolve labels', () => {
+ const transformed = transformFilterStringForApi(
+ 'labels = lorem',
+ (title: string) => 1,
+ nullTitleToIdResolver,
+ )
+
+ 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
+ }
+ },
+ nullTitleToIdResolver,
+ )
+
+ expect(transformed).toBe('labels = 1&& due_date = now && labels = 2')
+ })
+
+ it('should correctly resolve projects', () => {
+ const transformed = transformFilterStringForApi(
+ 'project = lorem',
+ nullTitleToIdResolver,
+ (title: string) => 1,
+ )
+
+ expect(transformed).toBe('project = 1')
+ })
+
+ it('should correctly resolve multiple projects', () => {
+ const transformed = transformFilterStringForApi(
+ 'project = lorem && dueDate = now || project = ipsum',
+ nullTitleToIdResolver,
+ (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')
+ })
+ })
+
+ describe('To API', () => {
+ for (const c in fieldCases) {
+ it('should transform all filter params for ' + c + ' to snake_case', () => {
+ const transformed = transformFilterStringFromApi(fieldCases[c] + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver)
+
+ expect(transformed).toBe(c + ' = ipsum')
+ })
+ }
+
+ it('should correctly resolve labels', () => {
+ const transformed = transformFilterStringFromApi(
+ 'labels = 1',
+ (id: number) => 'lorem',
+ nullIdToTitleResolver,
+ )
+
+ expect(transformed).toBe('labels = lorem')
+ })
+
+ it('should correctly resolve multiple labels', () => {
+ const transformed = transformFilterStringFromApi(
+ 'labels = 1 && due_date = now && labels = 2',
+ (id: number) => {
+ switch (id) {
+ case 1:
+ return 'lorem'
+ case 2:
+ return 'ipsum'
+ default:
+ return null
+ }
+ },
+ nullIdToTitleResolver,
+ )
+
+ expect(transformed).toBe('labels = lorem&& dueDate = now && labels = ipsum')
+ })
+
+ it('should correctly resolve projects', () => {
+ const transformed = transformFilterStringFromApi(
+ 'project = 1',
+ nullIdToTitleResolver,
+ (id: number) => 'lorem',
+ )
+
+ expect(transformed).toBe('project = lorem')
+ })
+
+ it('should correctly resolve multiple projects', () => {
+ const transformed = transformFilterStringFromApi(
+ 'project = lorem && due_date = now || project = ipsum',
+ nullIdToTitleResolver,
+ (id: number) => {
+ switch (id) {
+ case 1:
+ return 'lorem'
+ case 2:
+ return 'ipsum'
+ default:
+ return null
+ }
+ },
+ )
+
+ expect(transformed).toBe('project = lorem && dueDate = now || project = ipsum')
+ })
+ })
+})
diff --git a/frontend/src/helpers/filters.ts b/frontend/src/helpers/filters.ts
new file mode 100644
index 000000000..d8e4b2bd5
--- /dev/null
+++ b/frontend/src/helpers/filters.ts
@@ -0,0 +1,164 @@
+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 = '(<|>|<=|>=|=|!=)'
+
+function getFieldPattern(field: string): RegExp {
+ return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()]+\\1?)?', 'ig')
+}
+
+export function transformFilterStringForApi(
+ filter: string,
+ labelResolver: (title: string) => number | null,
+ projectResolver: (title: string) => number | null,
+): string {
+
+ if (filter.trim() === '') {
+ return ''
+ }
+
+ // Transform labels to ids
+ LABEL_FIELDS.forEach(field => {
+ const pattern = getFieldPattern(field)
+
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(filter)) !== null) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ 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 = getFieldPattern(field)
+
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(filter)) !== null) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ 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
+}
+
+export function transformFilterStringFromApi(
+ filter: string,
+ labelResolver: (id: number) => string | null,
+ projectResolver: (id: number) => string | null,
+): string {
+
+ if (filter.trim() === '') {
+ return ''
+ }
+
+ // Transform all attributes from snake case
+ AVAILABLE_FILTER_FIELDS.forEach(f => {
+ filter = filter.replace(snakeCase(f), f)
+ })
+
+ // Transform labels to their titles
+ LABEL_FIELDS.forEach(field => {
+ const pattern = getFieldPattern(field)
+
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(filter)) !== null) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [matched, prefix, operator, space, keyword] = match
+ if (keyword) {
+ const labelTitle = labelResolver(Number(keyword.trim()))
+ if (labelTitle !== null) {
+ filter = filter.replace(keyword, labelTitle)
+ }
+ }
+ }
+ })
+
+ // Transform projects to ids
+ PROJECT_FIELDS.forEach(field => {
+ const pattern = getFieldPattern(field)
+
+ let match: RegExpExecArray | null
+ while ((match = pattern.exec(filter)) !== null) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [matched, prefix, operator, space, keyword] = match
+ if (keyword) {
+ const project = projectResolver(Number(keyword.trim()))
+ if (project !== null) {
+ filter = filter.replace(keyword, project)
+ }
+ }
+ }
+ })
+
+ return filter
+}
diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json
index fdc09d2cb..652b43450 100644
--- a/frontend/src/i18n/lang/en.json
+++ b/frontend/src/i18n/lang/en.json
@@ -385,6 +385,7 @@
"filters": {
"title": "Filters",
"clear": "Clear Filters",
+ "showResults": "Show results",
"attributes": {
"title": "Title",
"titlePlaceholder": "The saved filter title goes here…",
@@ -415,6 +416,52 @@
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
+ },
+ "query": {
+ "title": "Query",
+ "placeholder": "Type a search or filter query…",
+ "help": {
+ "intro": "To filter tasks, you can use a query syntax similar to SQL. The available fields for filtering include:",
+ "link": "How does this work?",
+ "canUseDatemath": "You can date math to set relative dates. Click on the date value in a query to find out more.",
+ "fields": {
+ "done": "Whether the task is completed or not",
+ "priority": "The priority level of the task (1-5)",
+ "percentDone": "The percentage of completion for the task (0-100)",
+ "dueDate": "The due date of the task",
+ "startDate": "The start date of the task",
+ "endDate": "The end date of the task",
+ "doneAt": "The date and time when the task was completed",
+ "assignees": "The assignees of the task",
+ "labels": "The labels associated with the task",
+ "project": "The project the task belongs to (only available for saved filters, not on a project level)"
+ },
+ "operators": {
+ "intro": "The available operators for filtering include:",
+ "notEqual": "Not equal to",
+ "equal": "Equal to",
+ "greaterThan": "Greater than",
+ "greaterThanOrEqual": "Greater than or equal to",
+ "lessThan": "Less than",
+ "lessThanOrEqual": "Less than or equal to",
+ "like": "Matches a pattern (using wildcard %)",
+ "in": "Matches any value in a list"
+ },
+ "logicalOperators": {
+ "intro": "To combine multiple conditions, you can use the following logical operators:",
+ "and": "AND operator, matches if all conditions are true",
+ "or": "OR operator, matches if any of the conditions are true",
+ "parentheses": "Parentheses for grouping conditions"
+ },
+ "examples": {
+ "intro": "Here are some examples of filter queries:",
+ "priorityEqual": "Matches tasks with priority level 4",
+ "dueDatePast": "Matches tasks with a due date in the past",
+ "undoneHighPriority": "Matches undone tasks with priority level 3 or higher",
+ "assigneesIn": "Matches tasks assigned to either \"user1\" or \"user2\"",
+ "priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
+ }
+ }
}
},
"migrate": {
@@ -585,23 +632,42 @@
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
+ "date": "Date",
"ranges": {
"today": "Today",
-
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
-
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
-
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
+ },
+ "values": {
+ "now": "Now",
+ "startOfToday": "Start of today",
+ "endOfToday": "End of today",
+ "beginningOflastWeek": "Beginning of last week",
+ "endOfLastWeek": "End of last week",
+ "beginningOfThisWeek": "Beginning of this week",
+ "endOfThisWeek": "End of this week",
+ "startOfNextWeek": "Start of next week",
+ "endOfNextWeek": "End of next week",
+ "in7Days": "In 7 days",
+ "beginningOfLastMonth": "Beginning of last month",
+ "endOfLastMonth": "End of last month",
+ "startOfThisMonth": "Start of this month",
+ "endOfThisMonth": "End of this month",
+ "startOfNextMonth": "Start of next month",
+ "endOfNextMonth": "End of next month",
+ "in30Days": "In 30 days",
+ "startOfThisYear": "Beginning of this year",
+ "endOfThisYear": "End of this year"
}
},
"datemathHelp": {
diff --git a/frontend/src/modelTypes/ISavedFilter.ts b/frontend/src/modelTypes/ISavedFilter.ts
index 5f206efa9..f4f75f31d 100644
--- a/frontend/src/modelTypes/ISavedFilter.ts
+++ b/frontend/src/modelTypes/ISavedFilter.ts
@@ -1,12 +1,19 @@
import type {IAbstract} from './IAbstract'
import type {IUser} from './IUser'
-import type {IFilter} from '@/types/IFilter'
+
+interface Filters {
+ sortBy: ('start_date' | 'done' | 'id' | 'position')[],
+ orderBy: ('asc' | 'desc')[],
+ filter: string,
+ filterIncludeNulls: boolean,
+ s: string,
+}
export interface ISavedFilter extends IAbstract {
id: number
title: string
description: string
- filters: IFilter
+ filters: Filters
owner: IUser
created: Date
diff --git a/frontend/src/models/savedFilter.ts b/frontend/src/models/savedFilter.ts
index 35f3c08b7..8b8b74b48 100644
--- a/frontend/src/models/savedFilter.ts
+++ b/frontend/src/models/savedFilter.ts
@@ -11,11 +11,9 @@ export default class SavedFilterModel extends AbstractModel implem
filters: ISavedFilter['filters'] = {
sortBy: ['done', 'id'],
orderBy: ['asc', 'desc'],
- filterBy: ['done'],
- filterValue: ['false'],
- filterComparator: ['equals'],
- filterConcat: 'and',
+ filter: 'done = false',
filterIncludeNulls: true,
+ s: '',
}
owner: IUser = {}
diff --git a/frontend/src/services/savedFilter.ts b/frontend/src/services/savedFilter.ts
index 31c9a0fdc..6214028b7 100644
--- a/frontend/src/services/savedFilter.ts
+++ b/frontend/src/services/savedFilter.ts
@@ -63,9 +63,6 @@ export default class SavedFilterService extends AbstractService {
// the filter values in snake_sćase for url parameters.
model.filters = objectToCamelCase(model.filters)
- // Make sure all filterValues are passes as strings. This is a requirement of the api.
- model.filters.filterValue = model.filters.filterValue.map(v => String(v))
-
return model
}
diff --git a/frontend/src/services/taskCollection.ts b/frontend/src/services/taskCollection.ts
index 71862481d..0c8a13f06 100644
--- a/frontend/src/services/taskCollection.ts
+++ b/frontend/src/services/taskCollection.ts
@@ -3,15 +3,22 @@ import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
-// FIXME: unite with other filter params types
-export interface GetAllTasksParams {
- sort_by: ('start_date' | 'done' | 'id')[],
- order_by: ('asc' | 'asc' | 'desc')[],
- filter_by: 'start_date'[],
- filter_comparator: ('greater_equals' | 'less_equals')[],
- filter_value: [string, string] // [dateFrom, dateTo],
- filter_concat: 'and',
+export interface TaskFilterParams {
+ sort_by: ('start_date' | 'done' | 'id' | 'position')[],
+ order_by: ('asc' | 'desc')[],
+ filter: string,
filter_include_nulls: boolean,
+ s: string,
+}
+
+export function getDefaultTaskFilterParams(): TaskFilterParams {
+ return {
+ sort_by: ['position', 'id'],
+ order_by: ['asc', 'desc'],
+ filter: '',
+ filter_include_nulls: false,
+ s: '',
+ }
}
export default class TaskCollectionService extends AbstractService {
diff --git a/frontend/src/stores/labels.ts b/frontend/src/stores/labels.ts
index b617e4dcc..5d8c48221 100644
--- a/frontend/src/stores/labels.ts
+++ b/frontend/src/stores/labels.ts
@@ -33,6 +33,10 @@ export const useLabelStore = defineStore('label', () => {
const getLabelsByIds = computed(() => {
return (ids: ILabel['id'][]) => Object.values(labels.value).filter(({id}) => ids.includes(id))
})
+
+ const getLabelById = computed(() => {
+ return (labelId: ILabel['id']) => Object.values(labels.value).find(({id}) => id === labelId)
+ })
// **
// * Checks if a project of labels is available in the store and filters them then query
@@ -138,6 +142,7 @@ export const useLabelStore = defineStore('label', () => {
isLoading,
getLabelsByIds,
+ getLabelById,
filterLabelsByQuery,
getLabelsByExactTitles,
diff --git a/frontend/src/types/IFilter.ts b/frontend/src/types/IFilter.ts
deleted file mode 100644
index aca000ab6..000000000
--- a/frontend/src/types/IFilter.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export interface IFilter {
- sortBy: ('done' | 'id')[]
- orderBy: ('asc' | 'desc')[]
- filterBy: 'done'[]
- filterValue: 'false'[]
- filterComparator: 'equals'[]
- filterConcat: 'and'
- filterIncludeNulls: boolean
-}
\ No newline at end of file
diff --git a/frontend/src/views/filters/FilterEdit.vue b/frontend/src/views/filters/FilterEdit.vue
index 7957460a3..692c1253d 100644
--- a/frontend/src/views/filters/FilterEdit.vue
+++ b/frontend/src/views/filters/FilterEdit.vue
@@ -59,6 +59,7 @@
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
class="has-no-shadow has-no-border"
+ :has-footer="false"
/>
diff --git a/frontend/src/views/project/ProjectKanban.vue b/frontend/src/views/project/ProjectKanban.vue
index d5acd2076..ab46a5cb1 100644
--- a/frontend/src/views/project/ProjectKanban.vue
+++ b/frontend/src/views/project/ProjectKanban.vue
@@ -296,6 +296,7 @@ import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {isSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
+import type {TaskFilterParams} from '@/services/taskCollection'
const {
projectId = undefined,
@@ -347,11 +348,12 @@ const collapsedBuckets = ref({})
const taskUpdating = ref<{[id: ITask['id']]: boolean}>({})
const oneTaskUpdating = ref(false)
-const params = ref({
- filter_by: [],
- filter_value: [],
- filter_comparator: [],
- filter_concat: 'and',
+const params = ref({
+ sort_by: [],
+ order_by: [],
+ filter: '',
+ filter_include_nulls: false,
+ s: '',
})
const getTaskDraggableTaskComponentData = computed(() => (bucket: IBucket) => {
diff --git a/frontend/src/views/project/helpers/useGanttFilters.ts b/frontend/src/views/project/helpers/useGanttFilters.ts
index d0fd63b91..dfe8ccd8b 100644
--- a/frontend/src/views/project/helpers/useGanttFilters.ts
+++ b/frontend/src/views/project/helpers/useGanttFilters.ts
@@ -8,7 +8,7 @@ import {useRouteFilters} from '@/composables/useRouteFilters'
import {useGanttTaskList} from './useGanttTaskList'
import type {IProject} from '@/modelTypes/IProject'
-import type {GetAllTasksParams} from '@/services/taskCollection'
+import type {TaskFilterParams} from '@/services/taskCollection'
import type {DateISO} from '@/types/DateISO'
import type {DateKebab} from '@/types/DateKebab'
@@ -75,14 +75,11 @@ function ganttFiltersToRoute(filters: GanttFilters): RouteLocationRaw {
}
}
-function ganttFiltersToApiParams(filters: GanttFilters): GetAllTasksParams {
+function ganttFiltersToApiParams(filters: GanttFilters): TaskFilterParams {
return {
sort_by: ['start_date', 'done', 'id'],
order_by: ['asc', 'asc', 'desc'],
- filter_by: ['start_date', 'start_date'],
- filter_comparator: ['greater_equals', 'less_equals'],
- filter_value: [isoToKebabDate(filters.dateFrom), isoToKebabDate(filters.dateTo)],
- filter_concat: 'and',
+ filter: 'start_date >= "' + isoToKebabDate(filters.dateFrom) + '" && start_date <= "' + isoToKebabDate(filters.dateTo) + '"',
filter_include_nulls: filters.showTasksWithoutDates,
}
}
diff --git a/frontend/src/views/project/helpers/useGanttTaskList.ts b/frontend/src/views/project/helpers/useGanttTaskList.ts
index a7a13e706..7f094252c 100644
--- a/frontend/src/views/project/helpers/useGanttTaskList.ts
+++ b/frontend/src/views/project/helpers/useGanttTaskList.ts
@@ -4,7 +4,7 @@ import {klona} from 'klona/lite'
import type {Filters} from '@/composables/useRouteFilters'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
-import TaskCollectionService, {type GetAllTasksParams} from '@/services/taskCollection'
+import TaskCollectionService, {type TaskFilterParams} from '@/services/taskCollection'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
@@ -13,7 +13,7 @@ import {error, success} from '@/message'
// FIXME: unify with general `useTaskList`
export function useGanttTaskList(
filters: Ref,
- filterToApiParams: (filters: F) => GetAllTasksParams,
+ filterToApiParams: (filters: F) => TaskFilterParams,
options: {
loadAll?: boolean,
} = {
@@ -26,7 +26,7 @@ export function useGanttTaskList(
const tasks = ref>(new Map())
- async function fetchTasks(params: GetAllTasksParams, page = 1): Promise {
+ async function fetchTasks(params: TaskFilterParams, page = 1): Promise {
const tasks = await taskCollectionService.getAll({projectId: filters.value.projectId}, params, page) as ITask[]
if (options.loadAll && page < taskCollectionService.totalPages) {
const nextTasks = await fetchTasks(params, page + 1)
@@ -40,7 +40,7 @@ export function useGanttTaskList(
* Normally there is no need to trigger this manually
*/
async function loadTasks() {
- const params: GetAllTasksParams = filterToApiParams(filters.value)
+ const params: TaskFilterParams = filterToApiParams(filters.value)
const loadedTasks = await fetchTasks(params)
tasks.value = new Map()
diff --git a/go.mod b/go.mod
index f65642e78..33d34fa71 100644
--- a/go.mod
+++ b/go.mod
@@ -31,6 +31,7 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2
github.com/gabriel-vasile/mimetype v1.4.3
+ github.com/ganigeorgiev/fexpr v0.4.0
github.com/getsentry/sentry-go v0.27.0
github.com/go-sql-driver/mysql v1.8.0
github.com/go-testfixtures/testfixtures/v3 v3.10.0
diff --git a/go.sum b/go.sum
index 1fed95db1..9cf20eabf 100644
--- a/go.sum
+++ b/go.sum
@@ -109,6 +109,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/ganigeorgiev/fexpr v0.4.0 h1:ojitI+VMNZX/odeNL1x3RzTTE8qAIVvnSSYPNAnQFDI=
+github.com/ganigeorgiev/fexpr v0.4.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -150,8 +152,6 @@ github.com/go-playground/validator/v10 v10.15.1 h1:BSe8uhN+xQ4r5guV/ywQI4gO59C2r
github.com/go-playground/validator/v10 v10.15.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
-github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
-github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4=
github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -167,8 +167,6 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
-github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
-github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
diff --git a/pkg/db/fixtures/saved_filters.yml b/pkg/db/fixtures/saved_filters.yml
index 844ceb1a4..43cc486ca 100644
--- a/pkg/db/fixtures/saved_filters.yml
+++ b/pkg/db/fixtures/saved_filters.yml
@@ -1,5 +1,5 @@
- id: 1
- filters: '{"sort_by":null,"order_by":null,"filter_by":["start_date","end_date","due_date"],"filter_value":["2018-12-11T03:46:40+00:00","2018-12-13T11:20:01+00:00","2018-11-29T14:00:00+00:00"],"filter_comparator":["greater","less","greater"],"filter_concat":"","filter_include_nulls":false}'
+ filters: '{"sort_by":null,"order_by":null,"filter":"start_date > \u00272018-12-11T03:46:40+00:00\u0027 || end_date < \u00272018-12-13T11:20:01+00:00\u0027 || due_date > \u00272018-11-29T14:00:00+00:00\u0027","filter_include_nulls":false}'
title: testfilter1
owner_id: 1
updated: 2020-09-08 15:13:12
diff --git a/pkg/integrations/task_collection_test.go b/pkg/integrations/task_collection_test.go
index f727489e0..40f133eb6 100644
--- a/pkg/integrations/task_collection_test.go
+++ b/pkg/integrations/task_collection_test.go
@@ -184,9 +184,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start and end date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"start_date", "end_date", "due_date"},
- "filter_value": []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00", "2018-11-29T14:00:00+00:00"},
- "filter_comparator": []string{"greater", "less", "greater"},
+ "filter": []string{"start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00' || due_date > '2018-11-29T14:00:00+00:00'"},
},
urlParams,
)
@@ -209,9 +207,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"start_date"},
- "filter_value": []string{"2018-10-20T01:46:40+00:00"},
- "filter_comparator": []string{"greater"},
+ "filter": []string{"start_date > '2018-10-20T01:46:40+00:00'"},
},
urlParams,
)
@@ -234,9 +230,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("end date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"end_date"},
- "filter_value": []string{"2018-12-13T11:20:01+00:00"},
- "filter_comparator": []string{"greater"},
+ "filter": []string{"end_date > '2018-12-13T11:20:01+00:00'"},
},
urlParams,
)
@@ -249,9 +243,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("unix timestamps", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"start_date", "end_date", "due_date"},
- "filter_value": []string{"1544500000", "1513164001", "1543500000"},
- "filter_comparator": []string{"greater", "less", "greater"},
+ "filter": []string{"start_date > 1544500000 || end_date < 1513164001 || due_date > 1543500000"},
},
urlParams,
)
@@ -275,9 +267,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("invalid date", func(t *testing.T) {
_, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"due_date"},
- "filter_value": []string{"invalid"},
- "filter_comparator": []string{"greater"},
+ "filter": []string{"due_date > invalid"},
},
nil,
)
@@ -411,9 +401,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start and end date", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"start_date", "end_date", "due_date"},
- "filter_value": []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00", "2018-11-29T14:00:00+00:00"},
- "filter_comparator": []string{"greater", "less", "greater"},
+ "filter": []string{"start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00' || due_date > '2018-11-29T14:00:00+00:00'"},
},
nil,
)
@@ -436,9 +424,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("start date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"start_date"},
- "filter_value": []string{"2018-10-20T01:46:40+00:00"},
- "filter_comparator": []string{"greater"},
+ "filter": []string{"start_date > '2018-10-20T01:46:40+00:00'"},
},
nil,
)
@@ -461,9 +447,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("end date only", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"end_date"},
- "filter_value": []string{"2018-12-13T11:20:01+00:00"},
- "filter_comparator": []string{"greater"},
+ "filter": []string{"end_date > '2018-12-13T11:20:01+00:00'"},
},
nil,
)
@@ -477,9 +461,7 @@ func TestTaskCollection(t *testing.T) {
t.Run("invalid date", func(t *testing.T) {
_, err := testHandler.testReadAllWithUser(
url.Values{
- "filter_by": []string{"due_date"},
- "filter_value": []string{"invalid"},
- "filter_comparator": []string{"greater"},
+ "filter": []string{"due_date > invalid"},
},
nil,
)
diff --git a/pkg/migration/20231121191822.go b/pkg/migration/20231121191822.go
new file mode 100644
index 000000000..db7868ae1
--- /dev/null
+++ b/pkg/migration/20231121191822.go
@@ -0,0 +1,107 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public Licensee as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public Licensee for more details.
+//
+// You should have received a copy of the GNU Affero General Public Licensee
+// along with this program. If not, see .
+
+package migration
+
+import (
+ "strings"
+
+ "src.techknowlogick.com/xormigrate"
+ "xorm.io/xorm"
+)
+
+type taskCollectionFilter20231121191822 struct {
+ SortBy []string `query:"sort_by" json:"sort_by"`
+ OrderBy []string `query:"order_by" json:"order_by"`
+
+ FilterBy []string `query:"filter_by" json:"filter_by,omitempty"`
+ FilterValue []string `query:"filter_value" json:"filter_value,omitempty"`
+ FilterComparator []string `query:"filter_comparator" json:"filter_comparator,omitempty"`
+ FilterConcat string `query:"filter_concat" json:"filter_concat,omitempty"`
+
+ Filter string `query:"filter" json:"filter"`
+ FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
+}
+
+type savedFilter20231121191822 struct {
+ ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"`
+ Filters *taskCollectionFilter20231121191822 `xorm:"JSON not null" json:"filters" valid:"required"`
+}
+
+func (savedFilter20231121191822) TableName() string {
+ return "saved_filters"
+}
+
+func init() {
+ migrations = append(migrations, &xormigrate.Migration{
+ ID: "20231121191822",
+ Description: "Migrate saved filter structure",
+ Migrate: func(tx *xorm.Engine) (err error) {
+ allFilters := []*savedFilter20231121191822{}
+ err = tx.Find(&allFilters)
+ if err != nil {
+ return
+ }
+
+ for _, filter := range allFilters {
+ var filterStrings []string
+ for i, f := range filter.Filters.FilterBy {
+ var comparator string
+ switch filter.Filters.FilterComparator[i] {
+ case "equals":
+ comparator = "="
+ case "greater":
+ comparator = ">"
+ case "greater_equals":
+ comparator = ">="
+ case "less":
+ comparator = "<"
+ case "less_equals":
+ comparator = "<="
+ case "not_equals":
+ comparator = "!="
+ case "like":
+ comparator = "~"
+ case "in":
+ comparator = "?="
+ }
+ filterStrings = append(filterStrings, f+" "+comparator+" "+filter.Filters.FilterValue[i])
+ }
+
+ filter.Filters.FilterConcat = " || "
+ if filter.Filters.FilterConcat == "and" {
+ filter.Filters.FilterConcat = " && "
+ }
+ filter.Filters.Filter = strings.Join(filterStrings, filter.Filters.FilterConcat)
+
+ filter.Filters.FilterBy = nil
+ filter.Filters.FilterComparator = nil
+ filter.Filters.FilterValue = nil
+ filter.Filters.FilterConcat = ""
+
+ _, err = tx.Where("id = ?", filter.ID).Update(filter)
+ if err != nil {
+ return
+ }
+ }
+
+ return nil
+ },
+ Rollback: func(tx *xorm.Engine) error {
+ return nil
+ },
+ })
+}
diff --git a/pkg/models/error.go b/pkg/models/error.go
index a3b78c445..431d9e7d5 100644
--- a/pkg/models/error.go
+++ b/pkg/models/error.go
@@ -1021,7 +1021,7 @@ func (err ErrTaskRelationCycle) Error() string {
}
// ErrCodeTaskRelationCycle holds the unique world-error code of this error
-const ErrCodeTaskRelationCycle = 4022
+const ErrCodeTaskRelationCycle = 4023
// HTTPError holds the http error description
func (err ErrTaskRelationCycle) HTTPError() web.HTTPError {
@@ -1032,6 +1032,34 @@ func (err ErrTaskRelationCycle) HTTPError() web.HTTPError {
}
}
+// ErrInvalidFilterExpression represents an error where the task filter expression was invalid
+type ErrInvalidFilterExpression struct {
+ Expression string
+ ExpressionError error
+}
+
+// IsErrInvalidFilterExpression checks if an error is ErrInvalidFilterExpression.
+func IsErrInvalidFilterExpression(err error) bool {
+ _, ok := err.(ErrInvalidFilterExpression)
+ return ok
+}
+
+func (err ErrInvalidFilterExpression) Error() string {
+ return fmt.Sprintf("Task filter expression '%s' is invalid [ExpressionError: %v]", err.Expression, err.ExpressionError)
+}
+
+// ErrCodeInvalidFilterExpression holds the unique world-error code of this error
+const ErrCodeInvalidFilterExpression = 4024
+
+// HTTPError holds the http error description
+func (err ErrInvalidFilterExpression) HTTPError() web.HTTPError {
+ return web.HTTPError{
+ HTTPCode: http.StatusBadRequest,
+ Code: ErrCodeInvalidFilterExpression,
+ Message: fmt.Sprintf("The filter expression '%s' is invalid: %v", err.Expression, err.ExpressionError),
+ }
+}
+
// ============
// Team errors
// ============
diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go
index 3da2ebe20..24a307c44 100644
--- a/pkg/models/kanban.go
+++ b/pkg/models/kanban.go
@@ -17,6 +17,8 @@
package models
import (
+ "strconv"
+ "strings"
"time"
"code.vikunja.io/api/pkg/log"
@@ -106,10 +108,7 @@ func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err
// @Param page query int false "The page number for tasks. Used for pagination. If not provided, the first page of results is returned."
// @Param per_page query int false "The maximum number of tasks per bucket per page. This parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search tasks by task text."
-// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
-// @Param filter_value query string false "The value to filter for."
-// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`"
-// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
+// @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature."
// @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`."
// @Success 200 {array} models.Bucket "The buckets with their tasks"
// @Failure 500 {object} models.Message "Internal server error"
@@ -173,28 +172,36 @@ func (b *Bucket) ReadAll(s *xorm.Session, auth web.Auth, search string, page int
opts.page = page
opts.perPage = perPage
opts.search = search
- opts.filterConcat = filterConcatAnd
- var bucketFilterIndex int
- for i, filter := range opts.filters {
+ for _, filter := range opts.parsedFilters {
if filter.field == taskPropertyBucketID {
- bucketFilterIndex = i
+
+ // Limiting the map to the one filter we're looking for is the easiest way to ensure we only
+ // get tasks in this bucket
+ bucketID := filter.value.(int64)
+ bucket := bucketMap[bucketID]
+
+ bucketMap = make(map[int64]*Bucket, 1)
+ bucketMap[bucketID] = bucket
break
}
}
- if bucketFilterIndex == 0 {
- opts.filters = append(opts.filters, &taskFilter{
- field: taskPropertyBucketID,
- value: 0,
- comparator: taskFilterComparatorEquals,
- })
- bucketFilterIndex = len(opts.filters) - 1
- }
-
+ originalFilter := opts.filter
for id, bucket := range bucketMap {
- opts.filters[bucketFilterIndex].value = id
+ if !strings.Contains(originalFilter, "bucket_id") {
+ var filterString string
+ if originalFilter == "" {
+ filterString = "bucket_id = " + strconv.FormatInt(id, 10)
+ } else {
+ filterString = "(" + originalFilter + ") && bucket_id = " + strconv.FormatInt(id, 10)
+ }
+ opts.parsedFilters, err = getTaskFiltersFromFilterString(filterString)
+ if err != nil {
+ return
+ }
+ }
ts, _, total, err := getRawTasksForProjects(s, []*Project{{ID: bucket.ProjectID}}, auth, opts)
if err != nil {
diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go
index 8ff69a9df..e972b126b 100644
--- a/pkg/models/kanban_test.go
+++ b/pkg/models/kanban_test.go
@@ -81,9 +81,7 @@ func TestBucket_ReadAll(t *testing.T) {
b := &Bucket{
ProjectID: 1,
TaskCollection: TaskCollection{
- FilterBy: []string{"title"},
- FilterComparator: []string{"like"},
- FilterValue: []string{"done"},
+ Filter: "title ~ 'done'",
},
}
bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
@@ -94,6 +92,30 @@ func TestBucket_ReadAll(t *testing.T) {
assert.Equal(t, int64(2), buckets[0].Tasks[0].ID)
assert.Equal(t, int64(33), buckets[0].Tasks[1].ID)
})
+ t.Run("filtered by bucket", func(t *testing.T) {
+ db.LoadAndAssertFixtures(t)
+ s := db.NewSession()
+ defer s.Close()
+
+ testuser := &user.User{ID: 1}
+ b := &Bucket{
+ ProjectID: 1,
+ TaskCollection: TaskCollection{
+ Filter: "title ~ 'task' && bucket_id = 2",
+ },
+ }
+ bucketsInterface, _, _, err := b.ReadAll(s, testuser, "", -1, 0)
+ require.NoError(t, err)
+
+ buckets := bucketsInterface.([]*Bucket)
+ assert.Len(t, buckets, 3)
+ assert.Empty(t, buckets[0].Tasks, 0)
+ assert.Len(t, buckets[1].Tasks, 3)
+ assert.Empty(t, buckets[2].Tasks, 0)
+ assert.Equal(t, int64(3), buckets[1].Tasks[0].ID)
+ assert.Equal(t, int64(4), buckets[1].Tasks[1].ID)
+ assert.Equal(t, int64(5), buckets[1].Tasks[2].ID)
+ })
t.Run("accessed by link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
diff --git a/pkg/models/saved_filters_test.go b/pkg/models/saved_filters_test.go
index 3943d0864..0fa2932f6 100644
--- a/pkg/models/saved_filters_test.go
+++ b/pkg/models/saved_filters_test.go
@@ -65,7 +65,7 @@ func TestSavedFilter_Create(t *testing.T) {
vals := map[string]interface{}{
"title": "'test'",
"description": "'Lorem Ipsum dolor sit amet'",
- "filters": "'{\"sort_by\":null,\"order_by\":null,\"filter_by\":null,\"filter_value\":null,\"filter_comparator\":null,\"filter_concat\":\"\",\"filter_include_nulls\":false}'",
+ "filters": "'{\"sort_by\":null,\"order_by\":null,\"filter\":\"\",\"filter_include_nulls\":false}'",
"owner_id": 1,
}
// Postgres can't compare json values directly, see https://dba.stackexchange.com/a/106290/210721
diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go
index 25b78e463..bb844086e 100644
--- a/pkg/models/task_collection.go
+++ b/pkg/models/task_collection.go
@@ -33,17 +33,8 @@ type TaskCollection struct {
OrderBy []string `query:"order_by" json:"order_by"`
OrderByArr []string `query:"order_by[]" json:"-"`
- // The field name of the field to filter by
- FilterBy []string `query:"filter_by" json:"filter_by"`
- FilterByArr []string `query:"filter_by[]" json:"-"`
- // The value of the field name to filter by
- FilterValue []string `query:"filter_value" json:"filter_value"`
- FilterValueArr []string `query:"filter_value[]" json:"-"`
- // The comparator for field and value
- FilterComparator []string `query:"filter_comparator" json:"filter_comparator"`
- FilterComparatorArr []string `query:"filter_comparator[]" json:"-"`
- // The way all filter conditions are concatenated together, can be either "and" or "or".,
- FilterConcat string `query:"filter_concat" json:"filter_concat"`
+ Filter string `query:"filter" json:"filter"`
+
// If set to true, the result will also include null values
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
@@ -110,11 +101,11 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption
opts = &taskSearchOptions{
sortby: sort,
- filterConcat: taskFilterConcatinator(tf.FilterConcat),
filterIncludeNulls: tf.FilterIncludeNulls,
+ filter: tf.Filter,
}
- opts.filters, err = getTaskFiltersByCollections(tf)
+ opts.parsedFilters, err = getTaskFiltersFromFilterString(tf.Filter)
return opts, err
}
@@ -130,10 +121,7 @@ func getTaskFilterOptsFromCollection(tf *TaskCollection) (opts *taskSearchOption
// @Param s query string false "Search tasks by task text."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
// @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`."
-// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
-// @Param filter_value query string false "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like `due_date`, `start_date`, `end_date`, etc."
-// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`"
-// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
+// @Param filter query string false "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature."
// @Param filter_include_nulls query string false "If set to true the result will include filtered fields whose value is set to `null`. Available values are `true` or `false`. Defaults to `false`."
// @Security JWTKeyAuth
// @Success 200 {array} models.Task "The tasks"
diff --git a/pkg/models/task_collection_filter.go b/pkg/models/task_collection_filter.go
index c9db96210..0e69fb2f9 100644
--- a/pkg/models/task_collection_filter.go
+++ b/pkg/models/task_collection_filter.go
@@ -23,6 +23,8 @@ import (
"strings"
"time"
+ "github.com/ganigeorgiev/fexpr"
+
"code.vikunja.io/api/pkg/config"
"github.com/iancoleman/strcase"
@@ -54,6 +56,7 @@ type taskFilter struct {
value interface{} // Needs to be an interface to be able to hold the field's native value
comparator taskFilterComparator
isNumeric bool
+ join taskFilterConcatinator
}
func parseTimeFromUserInput(timeString string) (value time.Time, err error) {
@@ -88,61 +91,83 @@ func parseTimeFromUserInput(timeString string) (value time.Time, err error) {
return value.In(config.GetTimeZone()), err
}
-func getTaskFiltersByCollections(c *TaskCollection) (filters []*taskFilter, err error) {
-
- if len(c.FilterByArr) > 0 {
- c.FilterBy = append(c.FilterBy, c.FilterByArr...)
+func parseFilterFromExpression(f fexpr.ExprGroup) (filter *taskFilter, err error) {
+ filter = &taskFilter{
+ join: filterConcatAnd,
+ }
+ if f.Join == fexpr.JoinOr {
+ filter.join = filterConcatOr
}
- if len(c.FilterValueArr) > 0 {
- c.FilterValue = append(c.FilterValue, c.FilterValueArr...)
- }
-
- if len(c.FilterComparatorArr) > 0 {
- c.FilterComparator = append(c.FilterComparator, c.FilterComparatorArr...)
- }
-
- if c.FilterConcat != "" && c.FilterConcat != filterConcatAnd && c.FilterConcat != filterConcatOr {
- return nil, ErrInvalidTaskFilterConcatinator{
- Concatinator: taskFilterConcatinator(c.FilterConcat),
- }
- }
-
- filters = make([]*taskFilter, 0, len(c.FilterBy))
- for i, f := range c.FilterBy {
- filter := &taskFilter{
- field: f,
- comparator: taskFilterComparatorEquals,
- }
-
- if len(c.FilterComparator) > i {
- filter.comparator, err = getFilterComparatorFromString(c.FilterComparator[i])
- if err != nil {
- return
- }
- }
-
- err = validateTaskFieldComparator(filter.comparator)
+ var value string
+ switch v := f.Item.(type) {
+ case fexpr.Expr:
+ filter.field = v.Left.Literal
+ value = v.Right.Literal
+ filter.comparator, err = getFilterComparatorFromOp(v.Op)
if err != nil {
return
}
-
- // Cast the field value to its native type
- var reflectValue *reflect.StructField
- if len(c.FilterValue) > i {
- reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, c.FilterValue[i])
+ case []fexpr.ExprGroup:
+ values := make([]*taskFilter, 0, len(v))
+ for _, expression := range v {
+ subfilter, err := parseFilterFromExpression(expression)
if err != nil {
- return nil, ErrInvalidTaskFilterValue{
- Value: filter.field,
- Field: c.FilterValue[i],
- }
+ return nil, err
}
+ values = append(values, subfilter)
}
- if reflectValue != nil {
- filter.isNumeric = reflectValue.Type.Kind() == reflect.Int64
- }
+ filter.value = values
+ return
+ }
- filters = append(filters, filter)
+ err = validateTaskFieldComparator(filter.comparator)
+ if err != nil {
+ return
+ }
+
+ // Cast the field value to its native type
+ var reflectValue *reflect.StructField
+ if filter.field == "project" {
+ filter.field = "project_id"
+ }
+ reflectValue, filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, value)
+ if err != nil {
+ return nil, ErrInvalidTaskFilterValue{
+ Value: filter.field,
+ Field: value,
+ }
+ }
+ if reflectValue != nil {
+ filter.isNumeric = reflectValue.Type.Kind() == reflect.Int64
+ }
+
+ return filter, nil
+}
+
+func getTaskFiltersFromFilterString(filter string) (filters []*taskFilter, err error) {
+
+ if filter == "" {
+ return
+ }
+
+ filter = strings.ReplaceAll(filter, " in ", " ?= ")
+
+ parsedFilter, err := fexpr.Parse(filter)
+ if err != nil {
+ return nil, &ErrInvalidFilterExpression{
+ Expression: filter,
+ ExpressionError: err,
+ }
+ }
+
+ filters = make([]*taskFilter, 0, len(parsedFilter))
+ for _, f := range parsedFilter {
+ parsedFilter, err := parseFilterFromExpression(f)
+ if err != nil {
+ return nil, err
+ }
+ filters = append(filters, parsedFilter)
}
return
@@ -167,26 +192,28 @@ func validateTaskFieldComparator(comparator taskFilterComparator) error {
}
}
-func getFilterComparatorFromString(comparator string) (taskFilterComparator, error) {
- switch comparator {
- case "equals":
+func getFilterComparatorFromOp(op fexpr.SignOp) (taskFilterComparator, error) {
+ switch op {
+ case fexpr.SignEq:
return taskFilterComparatorEquals, nil
- case "greater":
+ case fexpr.SignGt:
return taskFilterComparatorGreater, nil
- case "greater_equals":
+ case fexpr.SignGte:
return taskFilterComparatorGreateEquals, nil
- case "less":
+ case fexpr.SignLt:
return taskFilterComparatorLess, nil
- case "less_equals":
+ case fexpr.SignLte:
return taskFilterComparatorLessEquals, nil
- case "not_equals":
+ case fexpr.SignNeq:
return taskFilterComparatorNotEquals, nil
- case "like":
+ case fexpr.SignLike:
return taskFilterComparatorLike, nil
+ case fexpr.SignAnyEq:
+ fallthrough
case "in":
return taskFilterComparatorIn, nil
default:
- return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(comparator)}
+ return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(op)}
}
}
diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go
index d87e9da0b..a8c2e852a 100644
--- a/pkg/models/task_collection_test.go
+++ b/pkg/models/task_collection_test.go
@@ -29,6 +29,8 @@ import (
"gopkg.in/d4l3k/messagediff.v1"
)
+// To only run a selected tests: ^\QTestTaskCollection_ReadAll\E$/^\QReadAll_Tasks_with_range\E$
+
func TestTaskCollection_ReadAll(t *testing.T) {
// Dummy users
user1 := &user.User{
@@ -675,10 +677,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
SortBy []string // Is a string, since this is the place where a query string comes from the user
OrderBy []string
- FilterBy []string
- FilterValue []string
- FilterComparator []string
FilterIncludeNulls bool
+ Filter string
CRUDable web.CRUDable
Rights web.Rights
@@ -792,9 +792,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "ReadAll Tasks with range",
fields: fields{
- FilterBy: []string{"start_date", "end_date"},
- FilterValue: []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00"},
- FilterComparator: []string{"greater", "less"},
+ Filter: "start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -807,9 +805,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "ReadAll Tasks with different range",
fields: fields{
- FilterBy: []string{"start_date", "end_date"},
- FilterValue: []string{"2018-12-13T11:20:00+00:00", "2018-12-16T22:40:00+00:00"},
- FilterComparator: []string{"greater", "less"},
+ Filter: "start_date > '2018-12-13T11:20:00+00:00' || end_date < '2018-12-16T22:40:00+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -821,20 +817,16 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "ReadAll Tasks with range with start date only",
fields: fields{
- FilterBy: []string{"start_date"},
- FilterValue: []string{"2018-12-12T07:33:20+00:00"},
- FilterComparator: []string{"greater"},
+ Filter: "start_date > '2018-12-12T07:33:20+00:00'",
},
args: defaultArgs,
want: []*Task{},
wantErr: false,
},
{
- name: "ReadAll Tasks with range with start date only and greater equals",
+ name: "ReadAll Tasks with range with start date only between",
fields: fields{
- FilterBy: []string{"start_date"},
- FilterValue: []string{"2018-12-12T07:33:20+00:00"},
- FilterComparator: []string{"greater_equals"},
+ Filter: "start_date > '2018-12-12T00:00:00+00:00' && start_date < '2018-12-13T00:00:00+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -843,12 +835,35 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
+ {
+ name: "ReadAll Tasks with range with start date only and greater equals",
+ fields: fields{
+ Filter: "start_date >= '2018-12-12T07:33:20+00:00'",
+ },
+ args: defaultArgs,
+ want: []*Task{
+ task7,
+ task9,
+ },
+ wantErr: false,
+ },
+ {
+ name: "range and nesting",
+ fields: fields{
+ Filter: "(start_date > '2018-12-12T00:00:00+00:00' && start_date < '2018-12-13T00:00:00+00:00') || end_date > '2018-12-13T00:00:00+00:00'",
+ },
+ args: defaultArgs,
+ want: []*Task{
+ task7,
+ task8,
+ task9,
+ },
+ wantErr: false,
+ },
{
name: "undone tasks only",
fields: fields{
- FilterBy: []string{"done"},
- FilterValue: []string{"false"},
- FilterComparator: []string{"equals"},
+ Filter: "done = false",
},
args: defaultArgs,
want: []*Task{
@@ -892,9 +907,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "done tasks only",
fields: fields{
- FilterBy: []string{"done"},
- FilterValue: []string{"true"},
- FilterComparator: []string{"equals"},
+ Filter: "done = true",
},
args: defaultArgs,
want: []*Task{
@@ -905,9 +918,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "done tasks only - not equals done",
fields: fields{
- FilterBy: []string{"done"},
- FilterValue: []string{"false"},
- FilterComparator: []string{"not_equals"},
+ Filter: "done != false",
},
args: defaultArgs,
want: []*Task{
@@ -918,10 +929,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "range with nulls",
fields: fields{
- FilterBy: []string{"start_date", "end_date"},
- FilterValue: []string{"2018-12-11T03:46:40+00:00", "2018-12-13T11:20:01+00:00"},
- FilterComparator: []string{"greater", "less"},
FilterIncludeNulls: true,
+ Filter: "start_date > '2018-12-11T03:46:40+00:00' || end_date < '2018-12-13T11:20:01+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -976,9 +985,26 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filtered with like",
fields: fields{
- FilterBy: []string{"title"},
- FilterValue: []string{"with"},
- FilterComparator: []string{"like"},
+ Filter: "title ~ with",
+ },
+ args: defaultArgs,
+ want: []*Task{
+ task7,
+ task8,
+ task9,
+ task27,
+ task28,
+ task29,
+ task30,
+ task31,
+ task33,
+ },
+ wantErr: false,
+ },
+ {
+ name: "filtered with like and '",
+ fields: fields{
+ Filter: "title ~ 'with'",
},
args: defaultArgs,
want: []*Task{
@@ -997,9 +1023,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filtered reminder dates",
fields: fields{
- FilterBy: []string{"reminders", "reminders"},
- FilterValue: []string{"2018-10-01T00:00:00+00:00", "2018-12-10T00:00:00+00:00"},
- FilterComparator: []string{"greater", "less"},
+ Filter: "reminders > '2018-10-01T00:00:00+00:00' && reminders < '2018-12-10T00:00:00+00:00'",
},
args: defaultArgs,
want: []*Task{
@@ -1008,12 +1032,22 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
+ {
+ name: "filter in keyword",
+ fields: fields{
+ Filter: "id in '1,2,34'", // user does not have permission to access task 34
+ },
+ args: defaultArgs,
+ want: []*Task{
+ task1,
+ task2,
+ },
+ wantErr: false,
+ },
{
name: "filter in",
fields: fields{
- FilterBy: []string{"id"},
- FilterValue: []string{"1,2,34"}, // Task 34 is forbidden for user 1
- FilterComparator: []string{"in"},
+ Filter: "id ?= '1,2,34'", // user does not have permission to access task 34
},
args: defaultArgs,
want: []*Task{
@@ -1025,9 +1059,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by username",
fields: fields{
- FilterBy: []string{"assignees"},
- FilterValue: []string{"user1"},
- FilterComparator: []string{"equals"},
+ Filter: "assignees = 'user1'",
},
args: defaultArgs,
want: []*Task{
@@ -1038,9 +1070,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by username with users field name",
fields: fields{
- FilterBy: []string{"users"},
- FilterValue: []string{"user1"},
- FilterComparator: []string{"equals"},
+ Filter: "users = 'user1'",
},
args: defaultArgs,
want: nil,
@@ -1049,9 +1079,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by username with user_id field name",
fields: fields{
- FilterBy: []string{"user_id"},
- FilterValue: []string{"user1"},
- FilterComparator: []string{"equals"},
+ Filter: "user_id = 'user1'",
},
args: defaultArgs,
want: nil,
@@ -1060,9 +1088,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by multiple username",
fields: fields{
- FilterBy: []string{"assignees", "assignees"},
- FilterValue: []string{"user1", "user2"},
- FilterComparator: []string{"equals", "equals"},
+ Filter: "assignees = 'user1' || assignees = 'user2'",
},
args: defaultArgs,
want: []*Task{
@@ -1074,9 +1100,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by numbers",
fields: fields{
- FilterBy: []string{"assignees"},
- FilterValue: []string{"1"},
- FilterComparator: []string{"equals"},
+ Filter: "assignees = 1",
},
args: defaultArgs,
want: []*Task{},
@@ -1085,20 +1109,51 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees by name with like",
fields: fields{
- FilterBy: []string{"assignees"},
- FilterValue: []string{"user"},
- FilterComparator: []string{"like"},
+ Filter: "assignees ~ 'user'",
+ },
+ args: defaultArgs,
+ want: []*Task{
+ // Same as without any filter since the filter is ignored
+ task1,
+ task2,
+ task3,
+ task4,
+ task5,
+ task6,
+ task7,
+ task8,
+ task9,
+ task10,
+ task11,
+ task12,
+ task15,
+ task16,
+ task17,
+ task18,
+ task19,
+ task20,
+ task21,
+ task22,
+ task23,
+ task24,
+ task25,
+ task26,
+ task27,
+ task28,
+ task29,
+ task30,
+ task31,
+ task32,
+ task33,
+ task35,
+ task39,
},
- args: defaultArgs,
- want: []*Task{},
wantErr: false,
},
{
name: "filter assignees in by id",
fields: fields{
- FilterBy: []string{"assignees"},
- FilterValue: []string{"1,2"},
- FilterComparator: []string{"in"},
+ Filter: "assignees ?= '1,2'",
},
args: defaultArgs,
want: []*Task{},
@@ -1107,9 +1162,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter assignees in by username",
fields: fields{
- FilterBy: []string{"assignees"},
- FilterValue: []string{"user1,user2"},
- FilterComparator: []string{"in"},
+ Filter: "assignees ?= 'user1,user2'",
},
args: defaultArgs,
want: []*Task{
@@ -1121,9 +1174,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
{
name: "filter labels",
fields: fields{
- FilterBy: []string{"labels"},
- FilterValue: []string{"4"},
- FilterComparator: []string{"equals"},
+ Filter: "labels = 4",
},
args: defaultArgs,
want: []*Task{
@@ -1134,11 +1185,9 @@ func TestTaskCollection_ReadAll(t *testing.T) {
wantErr: false,
},
{
- name: "filter project",
+ name: "filter project_id",
fields: fields{
- FilterBy: []string{"project_id"},
- FilterValue: []string{"6"},
- FilterComparator: []string{"equals"},
+ Filter: "project_id = 6",
},
args: defaultArgs,
want: []*Task{
@@ -1146,13 +1195,31 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
+ {
+ name: "filter project",
+ fields: fields{
+ Filter: "project = 6",
+ },
+ args: defaultArgs,
+ want: []*Task{
+ task15,
+ },
+ wantErr: false,
+ },
+ {
+ name: "filter project forbidden",
+ fields: fields{
+ Filter: "project_id = 20", // user1 has no access to project 20
+ },
+ args: defaultArgs,
+ want: []*Task{},
+ wantErr: false,
+ },
// TODO filter parent project?
{
name: "filter by index",
fields: fields{
- FilterBy: []string{"index"},
- FilterValue: []string{"5"},
- FilterComparator: []string{"equals"},
+ Filter: "index = 5",
},
args: defaultArgs,
want: []*Task{
@@ -1321,6 +1388,8 @@ func TestTaskCollection_ReadAll(t *testing.T) {
task9,
},
},
+ // TODO unix dates
+ // TODO date magic
}
for _, tt := range tests {
@@ -1334,11 +1403,10 @@ func TestTaskCollection_ReadAll(t *testing.T) {
SortBy: tt.fields.SortBy,
OrderBy: tt.fields.OrderBy,
- FilterBy: tt.fields.FilterBy,
- FilterValue: tt.fields.FilterValue,
- FilterComparator: tt.fields.FilterComparator,
FilterIncludeNulls: tt.fields.FilterIncludeNulls,
+ Filter: tt.fields.Filter,
+
CRUDable: tt.fields.CRUDable,
Rights: tt.fields.Rights,
}
diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go
index 8a3601f3f..66949dbfb 100644
--- a/pkg/models/task_search.go
+++ b/pkg/models/task_search.go
@@ -76,23 +76,21 @@ func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error)
return
}
-//nolint:gocyclo
-func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
+func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (filterCond builder.Cond, err error) {
- orderby, err := getOrderByDBStatement(opts)
- if err != nil {
- return nil, 0, err
- }
-
- // Some filters need a special treatment since they are in a separate table
- reminderFilters := []builder.Cond{}
- assigneeFilters := []builder.Cond{}
- labelFilters := []builder.Cond{}
- projectFilters := []builder.Cond{}
-
- var filters = make([]builder.Cond, 0, len(opts.filters))
+ var dbFilters = make([]builder.Cond, 0, len(rawFilters))
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
- for _, f := range opts.filters {
+ for _, f := range rawFilters {
+
+ if nested, is := f.value.([]*taskFilter); is {
+ nestedDBFilters, err := convertFiltersToDBFilterCond(nested, includeNulls)
+ if err != nil {
+ return nil, err
+ }
+ dbFilters = append(dbFilters, nestedDBFilters)
+ continue
+ }
+
if f.field == "reminders" {
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
@@ -100,17 +98,17 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
- }, opts.filterIncludeNulls)
+ }, includeNulls)
if err != nil {
- return nil, totalCount, err
+ return nil, err
}
- reminderFilters = append(reminderFilters, filter)
+ dbFilters = append(dbFilters, getFilterCondForSeparateTable("task_reminders", filter))
continue
}
if f.field == "assignees" {
if f.comparator == taskFilterComparatorLike {
- return nil, totalCount, err
+ return
}
filter, err := getFilterCond(&taskFilter{
// recreating the struct here to avoid modifying it when reusing the opts struct
@@ -118,11 +116,17 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
- }, opts.filterIncludeNulls)
+ }, includeNulls)
if err != nil {
- return nil, totalCount, err
+ return nil, err
}
- assigneeFilters = append(assigneeFilters, filter)
+
+ assigneeFilter := builder.In("user_id",
+ builder.Select("id").
+ From("users").
+ Where(filter),
+ )
+ dbFilters = append(dbFilters, getFilterCondForSeparateTable("task_assignees", assigneeFilter))
continue
}
@@ -133,11 +137,12 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
- }, opts.filterIncludeNulls)
+ }, includeNulls)
if err != nil {
- return nil, totalCount, err
+ return nil, err
}
- labelFilters = append(labelFilters, filter)
+
+ dbFilters = append(dbFilters, getFilterCondForSeparateTable("label_tasks", filter))
continue
}
@@ -148,19 +153,60 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
value: f.value,
comparator: f.comparator,
isNumeric: f.isNumeric,
- }, opts.filterIncludeNulls)
+ }, includeNulls)
if err != nil {
- return nil, totalCount, err
+ return nil, err
}
- projectFilters = append(projectFilters, filter)
+
+ cond := builder.In(
+ "project_id",
+ builder.
+ Select("id").
+ From("projects").
+ Where(filter),
+ )
+ dbFilters = append(dbFilters, cond)
continue
}
- filter, err := getFilterCond(f, opts.filterIncludeNulls)
+ filter, err := getFilterCond(f, includeNulls)
if err != nil {
- return nil, totalCount, err
+ return nil, err
}
- filters = append(filters, filter)
+ dbFilters = append(dbFilters, filter)
+ }
+
+ if len(dbFilters) > 0 {
+ if len(dbFilters) == 1 {
+ filterCond = dbFilters[0]
+ } else {
+ for i, f := range dbFilters {
+ if len(dbFilters) > i+1 {
+ switch rawFilters[i+1].join {
+ case filterConcatOr:
+ filterCond = builder.Or(filterCond, f, dbFilters[i+1])
+ case filterConcatAnd:
+ filterCond = builder.And(filterCond, f, dbFilters[i+1])
+ }
+ }
+ }
+ }
+ }
+
+ return filterCond, nil
+}
+
+//nolint:gocyclo
+func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
+
+ orderby, err := getOrderByDBStatement(opts)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ filterCond, err := convertFiltersToDBFilterCond(opts.parsedFilters, opts.filterIncludeNulls)
+ if err != nil {
+ return nil, 0, err
}
// Then return all tasks for that projects
@@ -199,53 +245,6 @@ func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCo
favoritesCond = builder.In("id", favCond)
}
- if len(reminderFilters) > 0 {
- filters = append(filters, getFilterCondForSeparateTable("task_reminders", opts.filterConcat, reminderFilters))
- }
-
- if len(assigneeFilters) > 0 {
- assigneeFilter := []builder.Cond{
- builder.In("user_id",
- builder.Select("id").
- From("users").
- Where(builder.Or(assigneeFilters...)),
- )}
- filters = append(filters, getFilterCondForSeparateTable("task_assignees", opts.filterConcat, assigneeFilter))
- }
-
- if len(labelFilters) > 0 {
- filters = append(filters, getFilterCondForSeparateTable("label_tasks", opts.filterConcat, labelFilters))
- }
-
- if len(projectFilters) > 0 {
- var filtercond builder.Cond
- if opts.filterConcat == filterConcatOr {
- filtercond = builder.Or(projectFilters...)
- }
- if opts.filterConcat == filterConcatAnd {
- filtercond = builder.And(projectFilters...)
- }
-
- cond := builder.In(
- "project_id",
- builder.
- Select("id").
- From("projects").
- Where(filtercond),
- )
- filters = append(filters, cond)
- }
-
- var filterCond builder.Cond
- if len(filters) > 0 {
- if opts.filterConcat == filterConcatOr {
- filterCond = builder.Or(filters...)
- }
- if opts.filterConcat == filterConcatAnd {
- filterCond = builder.And(filters...)
- }
- }
-
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
cond := builder.And(builder.Or(projectIDCond, favoritesCond), where, filterCond)
@@ -316,41 +315,23 @@ func convertFilterValues(value interface{}) string {
return ""
}
-func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
+// Parsing and rebuilding the filter for Typesense has the advantage that we have more control over
+// what Typesense finally gets to see.
+func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string, err error) {
- var sortbyFields []string
- for i, param := range opts.sortby {
- // Validate the params
- if err := param.validate(); err != nil {
- return nil, totalCount, err
+ filters := []string{}
+
+ for _, f := range rawFilters {
+
+ if nested, is := f.value.([]*taskFilter); is {
+ nestedDBFilters, err := convertParsedFilterToTypesense(nested)
+ if err != nil {
+ return "", err
+ }
+ filters = append(filters, "("+nestedDBFilters+")")
+ continue
}
- // Typesense does not allow sorting by ID, so we sort by created timestamp instead
- if param.sortBy == "id" {
- param.sortBy = "created"
- }
-
- sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
-
- if i == 2 {
- // Typesense supports up to 3 sorting parameters
- // https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters
- break
- }
- }
-
- sortby := strings.Join(sortbyFields, ",")
-
- projectIDStrings := []string{}
- for _, id := range opts.projectIDs {
- projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10))
- }
- filterBy := []string{
- "project_id: [" + strings.Join(projectIDStrings, ", ") + "]",
- }
-
- for _, f := range opts.filters {
-
if f.field == "reminders" {
f.field = "reminders.reminder"
}
@@ -363,6 +344,10 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
f.field = "labels.id"
}
+ if f.field == "project" {
+ f.field = "project_id"
+ }
+
filter := f.field
switch f.comparator {
@@ -394,7 +379,67 @@ func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task,
filter += "]"
}
- filterBy = append(filterBy, filter)
+ filters = append(filters, filter)
+ }
+
+ if len(filters) > 0 {
+ if len(filters) == 1 {
+ filterBy = filters[0]
+ } else {
+ for i, f := range filters {
+ if len(filters) > i+1 {
+ switch rawFilters[i+1].join {
+ case filterConcatOr:
+ filterBy = f + " || " + filters[i+1]
+ case filterConcatAnd:
+ filterBy = f + " && " + filters[i+1]
+ }
+ }
+ }
+ }
+ }
+
+ return
+}
+
+func (t *typesenseTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
+
+ var sortbyFields []string
+ for i, param := range opts.sortby {
+ // Validate the params
+ if err := param.validate(); err != nil {
+ return nil, totalCount, err
+ }
+
+ // Typesense does not allow sorting by ID, so we sort by created timestamp instead
+ if param.sortBy == "id" {
+ param.sortBy = "created"
+ }
+
+ sortbyFields = append(sortbyFields, param.sortBy+"(missing_values:last):"+param.orderBy.String())
+
+ if i == 2 {
+ // Typesense supports up to 3 sorting parameters
+ // https://typesense.org/docs/0.25.0/api/search.html#ranking-and-sorting-parameters
+ break
+ }
+ }
+
+ sortby := strings.Join(sortbyFields, ",")
+
+ projectIDStrings := []string{}
+ for _, id := range opts.projectIDs {
+ projectIDStrings = append(projectIDStrings, strconv.FormatInt(id, 10))
+ }
+
+ filter, err := convertParsedFilterToTypesense(opts.parsedFilters)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ filterBy := []string{
+ "project_id: [" + strings.Join(projectIDStrings, ", ") + "]",
+ "(" + filter + ")",
}
////////////////
diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go
index be21e9f00..c00ce0d00 100644
--- a/pkg/models/tasks.go
+++ b/pkg/models/tasks.go
@@ -162,8 +162,8 @@ func (t *Task) GetFrontendURL() string {
type taskFilterConcatinator string
const (
- filterConcatAnd = "and"
- filterConcatOr = "or"
+ filterConcatAnd taskFilterConcatinator = "and"
+ filterConcatOr taskFilterConcatinator = "or"
)
type taskSearchOptions struct {
@@ -171,9 +171,9 @@ type taskSearchOptions struct {
page int
perPage int
sortby []*sortParam
- filters []*taskFilter
- filterConcat taskFilterConcatinator
+ parsedFilters []*taskFilter
filterIncludeNulls bool
+ filter string
projectIDs []int64
}
@@ -238,21 +238,13 @@ func getFilterCond(f *taskFilter, includeNulls bool) (cond builder.Cond, err err
return
}
-func getFilterCondForSeparateTable(table string, concat taskFilterConcatinator, conds []builder.Cond) builder.Cond {
- var filtercond builder.Cond
- if concat == filterConcatOr {
- filtercond = builder.Or(conds...)
- }
- if concat == filterConcatAnd {
- filtercond = builder.And(conds...)
- }
-
+func getFilterCondForSeparateTable(table string, cond builder.Cond) builder.Cond {
return builder.In(
"id",
builder.
Select("task_id").
From(table).
- Where(filtercond),
+ Where(cond),
)
}
@@ -273,11 +265,6 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op
return nil, 0, 0, nil
}
- // Set the default concatinator of filter variables to or if none was provided
- if opts.filterConcat == "" {
- opts.filterConcat = filterConcatOr
- }
-
// Get all project IDs and get the tasks
opts.projectIDs = []int64{}
var hasFavoritesProject bool
diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go
index fd3427bfb..3cf5d0b39 100644
--- a/pkg/swagger/docs.go
+++ b/pkg/swagger/docs.go
@@ -1,4 +1,5 @@
-// Package swagger Code generated by swaggo/swag. DO NOT EDIT
+// Code generated by swaggo/swag. DO NOT EDIT.
+
package swagger
import "github.com/swaggo/swag"
@@ -1937,26 +1938,8 @@ const docTemplate = `{
},
{
"type": "string",
- "description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
- "name": "filter_by",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The value to filter for.",
- "name": "filter_value",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The comparator to use for a filter. Available values are ` + "`" + `equals` + "`" + `, ` + "`" + `greater` + "`" + `, ` + "`" + `greater_equals` + "`" + `, ` + "`" + `less` + "`" + `, ` + "`" + `less_equals` + "`" + `, ` + "`" + `like` + "`" + ` and ` + "`" + `in` + "`" + `. ` + "`" + `in` + "`" + ` expects comma-separated values in ` + "`" + `filter_value` + "`" + `. Defaults to ` + "`" + `equals` + "`" + `",
- "name": "filter_comparator",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The concatinator to use for filters. Available values are ` + "`" + `and` + "`" + ` or ` + "`" + `or` + "`" + `. Defaults to ` + "`" + `or` + "`" + `.",
- "name": "filter_concat",
+ "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
+ "name": "filter",
"in": "query"
},
{
@@ -2169,26 +2152,8 @@ const docTemplate = `{
},
{
"type": "string",
- "description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
- "name": "filter_by",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like ` + "`" + `due_date` + "`" + `, ` + "`" + `start_date` + "`" + `, ` + "`" + `end_date` + "`" + `, etc.",
- "name": "filter_value",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The comparator to use for a filter. Available values are ` + "`" + `equals` + "`" + `, ` + "`" + `greater` + "`" + `, ` + "`" + `greater_equals` + "`" + `, ` + "`" + `less` + "`" + `, ` + "`" + `less_equals` + "`" + `, ` + "`" + `like` + "`" + ` and ` + "`" + `in` + "`" + `. ` + "`" + `in` + "`" + ` expects comma-separated values in ` + "`" + `filter_value` + "`" + `. Defaults to ` + "`" + `equals` + "`" + `",
- "name": "filter_comparator",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The concatinator to use for filters. Available values are ` + "`" + `and` + "`" + ` or ` + "`" + `or` + "`" + `. Defaults to ` + "`" + `or` + "`" + `.",
- "name": "filter_concat",
+ "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
+ "name": "filter",
"in": "query"
},
{
@@ -8132,35 +8097,13 @@ const docTemplate = `{
"models.TaskCollection": {
"type": "object",
"properties": {
- "filter_by": {
- "description": "The field name of the field to filter by",
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "filter_comparator": {
- "description": "The comparator for field and value",
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "filter_concat": {
- "description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,",
+ "filter": {
"type": "string"
},
"filter_include_nulls": {
"description": "If set to true, the result will also include null values",
"type": "boolean"
},
- "filter_value": {
- "description": "The value of the field name to filter by",
- "type": "array",
- "items": {
- "type": "string"
- }
- },
"order_by": {
"description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.",
"type": "array",
@@ -9001,8 +8944,6 @@ var SwaggerInfo = &swag.Spec{
Description: "# Pagination\nEvery endpoint capable of pagination will return two headers:\n* `x-pagination-total-pages`: The total number of available pages for this request\n* `x-pagination-result-count`: The number of items returned for this request.\n# Rights\nAll endpoints which return a single item (project, task, etc.) - no array - will also return a `x-max-right` header with the max right the user has on this item as an int where `0` is `Read Only`, `1` is `Read & Write` and `2` is `Admin`.\nThis can be used to show or hide ui elements based on the rights the user has.\n# Errors\nAll errors have an error code and a human-readable error message in addition to the http status code. You should always check for the status code in the response, not only the http status code.\nDue to limitations in the swagger library we're using for this document, only one error per http status code is documented here. Make sure to check the [error docs](https://vikunja.io/docs/errors/) in Vikunja's documentation for a full list of available error codes.\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer `-header to authenticate successfully.\n\n**API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer ` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation.\n\n**BasicAuth:** Only used when requesting tasks via CalDAV.\n",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
- LeftDelim: "{{",
- RightDelim: "}}",
}
func init() {
diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json
index 8549dd9a3..7edf42ae0 100644
--- a/pkg/swagger/swagger.json
+++ b/pkg/swagger/swagger.json
@@ -1929,26 +1929,8 @@
},
{
"type": "string",
- "description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
- "name": "filter_by",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The value to filter for.",
- "name": "filter_value",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`",
- "name": "filter_comparator",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`.",
- "name": "filter_concat",
+ "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
+ "name": "filter",
"in": "query"
},
{
@@ -2161,26 +2143,8 @@
},
{
"type": "string",
- "description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
- "name": "filter_by",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style relative dates for all date fields like `due_date`, `start_date`, `end_date`, etc.",
- "name": "filter_value",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`",
- "name": "filter_comparator",
- "in": "query"
- },
- {
- "type": "string",
- "description": "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`.",
- "name": "filter_concat",
+ "description": "The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation of the feature.",
+ "name": "filter",
"in": "query"
},
{
@@ -8124,35 +8088,13 @@
"models.TaskCollection": {
"type": "object",
"properties": {
- "filter_by": {
- "description": "The field name of the field to filter by",
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "filter_comparator": {
- "description": "The comparator for field and value",
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "filter_concat": {
- "description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,",
+ "filter": {
"type": "string"
},
"filter_include_nulls": {
"description": "If set to true, the result will also include null values",
"type": "boolean"
},
- "filter_value": {
- "description": "The value of the field name to filter by",
- "type": "array",
- "items": {
- "type": "string"
- }
- },
"order_by": {
"description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.",
"type": "array",
diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml
index 5dd6c3625..76a301bc3 100644
--- a/pkg/swagger/swagger.yaml
+++ b/pkg/swagger/swagger.yaml
@@ -783,28 +783,11 @@ definitions:
type: object
models.TaskCollection:
properties:
- filter_by:
- description: The field name of the field to filter by
- items:
- type: string
- type: array
- filter_comparator:
- description: The comparator for field and value
- items:
- type: string
- type: array
- filter_concat:
- description: The way all filter conditions are concatenated together, can
- be either "and" or "or".,
+ filter:
type: string
filter_include_nulls:
description: If set to true, the result will also include null values
type: boolean
- filter_value:
- description: The value of the field name to filter by
- items:
- type: string
- type: array
order_by:
description: The query parameter to order the items by. This can be either
asc or desc, with asc being the default.
@@ -2718,27 +2701,10 @@ paths:
in: query
name: s
type: string
- - description: The name of the field to filter by. Allowed values are all task
- properties. Task properties which are their own object require passing in
- the id of that entity. Accepts an array for multiple filters which will
- be chanied together, all supplied filter must match.
+ - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters
+ for a full explanation of the feature.
in: query
- name: filter_by
- type: string
- - description: The value to filter for.
- in: query
- name: filter_value
- type: string
- - description: The comparator to use for a filter. Available values are `equals`,
- `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in`
- expects comma-separated values in `filter_value`. Defaults to `equals`
- in: query
- name: filter_comparator
- type: string
- - description: The concatinator to use for filters. Available values are `and`
- or `or`. Defaults to `or`.
- in: query
- name: filter_concat
+ name: filter
type: string
- description: If set to true the result will include filtered fields whose
value is set to `null`. Available values are `true` or `false`. Defaults
@@ -2885,30 +2851,10 @@ paths:
in: query
name: order_by
type: string
- - description: The name of the field to filter by. Allowed values are all task
- properties. Task properties which are their own object require passing in
- the id of that entity. Accepts an array for multiple filters which will
- be chanied together, all supplied filter must match.
+ - description: The filter query to match tasks by. Check out https://vikunja.io/docs/filters
+ for a full explanation of the feature.
in: query
- name: filter_by
- type: string
- - description: The value to filter for. You can use [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls)-
- or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math)-style
- relative dates for all date fields like `due_date`, `start_date`, `end_date`,
- etc.
- in: query
- name: filter_value
- type: string
- - description: The comparator to use for a filter. Available values are `equals`,
- `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in`
- expects comma-separated values in `filter_value`. Defaults to `equals`
- in: query
- name: filter_comparator
- type: string
- - description: The concatinator to use for filters. Available values are `and`
- or `or`. Defaults to `or`.
- in: query
- name: filter_concat
+ name: filter
type: string
- description: If set to true the result will include filtered fields whose
value is set to `null`. Available values are `true` or `false`. Defaults