From e97b629d6c35319bf46a8f1c8779a11a43e5d57d Mon Sep 17 00:00:00 2001 From: Timh Date: Fri, 24 Apr 2026 11:34:24 +0200 Subject: [PATCH] feat: support filter_include_nulls in project view configuration --- .../components/project/views/ViewEditForm.vue | 45 +++++++++++++++---- pkg/db/fixtures/project_views.yml | 8 ++++ pkg/models/task_collection.go | 4 ++ pkg/models/task_collection_test.go | 33 ++++++++++++++ 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/project/views/ViewEditForm.vue b/frontend/src/components/project/views/ViewEditForm.vue index aaa791547..5184932b0 100644 --- a/frontend/src/components/project/views/ViewEditForm.vue +++ b/frontend/src/components/project/views/ViewEditForm.vue @@ -9,6 +9,7 @@ import {useLabelStore} from '@/stores/labels' import {useProjectStore} from '@/stores/projects' import XButton from '@/components/input/Button.vue' +import FancyCheckbox from '@/components/input/FancyCheckbox.vue' import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue' import FilterInput from '@/components/input/filter/FilterInput.vue' import FormField from '@/components/input/FormField.vue' @@ -58,6 +59,16 @@ onBeforeMount(() => { filter.filter = filter.s } + // AbstractModel.assignData() runs objectToCamelCase recursively on all + // nested objects, which converts filter_include_nulls to filterIncludeNulls + // inside the filter object. IFilters intentionally uses snake_case keys to + // match the API query param format. We check both key forms here to handle + // data coming from either the API response (camelCased by assignData) or + // from a freshly constructed filter object (snake_case). + filter.filter_include_nulls = filterInput.filter_include_nulls + ?? (filterInput as Record).filterIncludeNulls as boolean + ?? false + return filter } @@ -76,16 +87,18 @@ onBeforeMount(() => { }) function save() { - const transformFilterForApi = (filterQuery: string): IFilters => { + const transformFilterForApi = (filterInput: IFilters): IFilters => { const filterString = transformFilterStringForApi( - filterQuery, + filterInput?.filter || '', labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null, projectTitle => { const found = projectStore.findProjectByExactname(projectTitle) return found?.id || null }, ) - const filter: IFilters = {} + const filter: IFilters = { + filter_include_nulls: filterInput?.filter_include_nulls ?? false, + } if (hasFilterQuery(filterString)) { filter.filter = filterString } else { @@ -97,10 +110,10 @@ function save() { emit('update:modelValue', { ...view.value, - filter: transformFilterForApi(view.value?.filter?.filter || ''), + filter: transformFilterForApi(view.value?.filter), bucketConfiguration: view.value?.bucketConfiguration.map(bc => ({ title: bc.title, - filter: transformFilterForApi(bc.filter?.filter || ''), + filter: transformFilterForApi(bc.filter), })), }) } @@ -172,10 +185,18 @@ function handleBubbleSave() { class="mbe-1" /> -
+
+
+ + {{ $t('filters.attributes.includeNulls') }} + +
+
-
+
+ +
+ + {{ $t('filters.attributes.includeNulls') }} + +
{{ $t('project.kanban.addBucket') }} diff --git a/pkg/db/fixtures/project_views.yml b/pkg/db/fixtures/project_views.yml index 918b1595f..584df49d8 100644 --- a/pkg/db/fixtures/project_views.yml +++ b/pkg/db/fixtures/project_views.yml @@ -1007,3 +1007,11 @@ updated: '2024-03-18 15:14:13' created: '2018-03-18 15:14:13' bucket_configuration_mode: 1 +- id: 161 + title: List with include nulls + project_id: 1 + view_kind: 0 + position: 5 + filter: '{"filter":"start_date > ''2018-12-11T03:46:40+00:00'' || end_date < ''2018-12-13T11:20:01+00:00''","filter_include_nulls":true}' + updated: '2024-03-18 15:14:13' + created: '2018-03-18 15:14:13' diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 06a1799e1..4651f72bc 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -335,6 +335,10 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa if view.Filter.Search != "" { search = view.Filter.Search } + + if view.Filter.FilterIncludeNulls { + tf.FilterIncludeNulls = true + } } if strings.Contains(tf.Filter, taskPropertyBucketID) { diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index 07f7c26d1..943181f17 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -1007,6 +1007,39 @@ func TestTaskCollection_ReadAll(t *testing.T) { }, wantErr: false, }, + { + // Tests that FilterIncludeNulls set on a view's saved filter config + // is properly applied when loading tasks through that view. + name: "range with nulls from view filter", + fields: fields{ + ProjectViewID: 161, + ProjectID: 1, + }, + args: defaultArgs, + want: []*Task{ + task1, // has nil dates + task2, // has nil dates + task3, // has nil dates + task4, // has nil dates + task5, // has nil dates + task6, // has nil dates + task7, // matches start_date filter + task8, // matches end_date filter + task9, // matches both + task10, // has nil dates + task11, // has nil dates + task12, // has nil dates + task27, // has start_date, matches filter + task28, // has dates, matches filter + task29, // has nil dates + task30, // has nil dates + task31, // has nil dates + task33, // has nil dates + task47, // has nil dates + task48, // has nil dates + }, + wantErr: false, + }, { name: "favorited tasks", args: defaultArgs,