feat: filter tasks by creator username

Add a `creator` filter field matching tasks by the creator's username,
mirroring the existing `assignees` filter.

Backend (pkg/models, shared by /api/v1 and /api/v2): allow `creator` in
the filter-field whitelist, resolve its value as usernames, and add a
`users` sub-table EXISTS subquery (tasks.created_by_id = users.id AND
username IN ...). `created_by_id` numeric filtering is unaffected.

Frontend: recognise `creator` as a filter field with username
autocomplete (reusing the assignee user-search path) and document it in
the filter help panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
benshi 2026-06-15 09:11:41 +00:00
parent a8a53c9581
commit e4b4a10bb6
11 changed files with 119 additions and 4 deletions

View File

@ -8,6 +8,7 @@ import FilterCommandsList from './FilterCommandsList.vue'
import {
ASSIGNEE_FIELDS,
AUTOCOMPLETE_FIELDS,
CREATOR_FIELDS,
FILTER_OPERATORS_REGEX,
isMultiValueOperator,
LABEL_FIELDS,
@ -338,7 +339,7 @@ export default Extension.create<FilterAutocompleteOptions>({
if (LABEL_FIELDS.includes(field)) {
fieldType = 'labels'
} else if (ASSIGNEE_FIELDS.includes(field)) {
} else if (ASSIGNEE_FIELDS.includes(field) || CREATOR_FIELDS.includes(field)) {
fieldType = 'assignees'
} else if (PROJECT_FIELDS.includes(field)) {
fieldType = 'projects'

View File

@ -28,6 +28,7 @@ const showDocs = ref(false)
<li><code>endDate</code>: {{ $t('filters.query.help.fields.endDate') }}</li>
<li><code>doneAt</code>: {{ $t('filters.query.help.fields.doneAt') }}</li>
<li><code>assignees</code>: {{ $t('filters.query.help.fields.assignees') }}</li>
<li><code>creator</code>: {{ $t('filters.query.help.fields.creator') }}</li>
<li><code>labels</code>: {{ $t('filters.query.help.fields.labels') }}</li>
<li><code>project</code>: {{ $t('filters.query.help.fields.project') }}</li>
<li><code>reminders</code>: {{ $t('filters.query.help.fields.reminders') }}</li>
@ -62,6 +63,7 @@ const showDocs = ref(false)
{{ $t('filters.query.help.examples.undoneHighPriority') }}
</li>
<li><code>assignees in user1, user2</code>: {{ $t('filters.query.help.examples.assigneesIn') }}</li>
<li><code>creator = user1</code>: {{ $t('filters.query.help.examples.creatorEqual') }}</li>
<li>
<code>(priority = 1 || priority = 2) &amp;&amp; dueDate &lt;= now</code>:
{{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }}

View File

@ -15,6 +15,7 @@ describe('Filter Transformation', () => {
'doneAt': 'done_at',
'reminders': 'reminders',
'assignees': 'assignees',
'creator': 'creator',
'labels': 'labels',
}

View File

@ -25,6 +25,11 @@ export const ASSIGNEE_FIELDS = [
'assignees',
]
// Creator is matched by username and autocompletes to users, exactly like assignees.
export const CREATOR_FIELDS = [
'creator',
]
export const LABEL_FIELDS = [
'labels',
]
@ -36,12 +41,14 @@ export const PROJECT_FIELDS = [
export const AUTOCOMPLETE_FIELDS = [
...LABEL_FIELDS,
...ASSIGNEE_FIELDS,
...CREATOR_FIELDS,
...PROJECT_FIELDS,
]
export const AVAILABLE_FILTER_FIELDS = [
...DATE_FIELDS,
...ASSIGNEE_FIELDS,
...CREATOR_FIELDS,
...LABEL_FIELDS,
...PROJECT_FIELDS,
'done',

View File

@ -608,6 +608,7 @@
"endDate": "The end date of the task",
"doneAt": "The date and time when the task was completed",
"assignees": "The assignees of the task",
"creator": "The user who created the task (matched by username)",
"labels": "The labels associated with the task",
"project": "The project the task belongs to (only available for saved filters, not on a project level)",
"reminders": "The reminders of the task as a date field, will return all tasks with at least one reminder matching the query",
@ -638,6 +639,7 @@
"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\"",
"creatorEqual": "Matches tasks created by \"user1\"",
"priorityOneOrTwoPastDue": "Matches tasks with priority level 1 or 2 and a due date in the past"
}
}

View File

@ -105,7 +105,8 @@ func validateTaskField(fieldName string) error {
case
taskPropertyAssignees,
taskPropertyLabels,
taskPropertyReminders:
taskPropertyReminders,
taskPropertyCreator:
return nil
}

View File

@ -360,7 +360,7 @@ func getNativeValueForTaskField(fieldName string, comparator taskFilterComparato
realFieldName := strings.ReplaceAll(strcase.ToCamel(fieldName), "Id", "ID")
if realFieldName == "Assignees" {
if realFieldName == "Assignees" || realFieldName == "Creator" {
vals := strings.Split(value, ",")
valueSlice := append([]string{}, vals...)
return nil, valueSlice, nil

View File

@ -229,6 +229,15 @@ func TestParseFilter(t *testing.T) {
assert.Equal(t, taskFilterComparatorNotEquals, result[0].comparator)
assert.Equal(t, int64(3), result[0].value)
})
t.Run("creator resolves to usernames, not an int64 id", func(t *testing.T) {
result, err := getTaskFiltersFromFilterString("creator in 'user1,user6'", "UTC")
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "creator", result[0].field)
assert.Equal(t, taskFilterComparatorIn, result[0].comparator)
assert.Equal(t, []string{"user1", "user6"}, result[0].value)
})
t.Run("less than or equal comparator", func(t *testing.T) {
result, err := getTaskFiltersFromFilterString("percent_done <= 50", "UTC")

View File

@ -51,6 +51,7 @@ const (
taskPropertyAssignees string = "assignees"
taskPropertyLabels string = "labels"
taskPropertyReminders string = "reminders"
taskPropertyCreator string = "creator"
)
const (

View File

@ -1327,6 +1327,91 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
{
name: "filter by creator username",
fields: fields{
Filter: "creator = 'user6'",
},
args: defaultArgs,
want: []*Task{
task15,
task16,
task17,
task18,
task19,
task20,
task21,
task22,
task23,
task24,
task25,
task26,
},
wantErr: false,
},
{
name: "filter by creator username in list",
fields: fields{
Filter: "creator ?= 'user6'",
},
args: defaultArgs,
want: []*Task{
task15,
task16,
task17,
task18,
task19,
task20,
task21,
task22,
task23,
task24,
task25,
task26,
},
wantErr: false,
},
{
name: "filter by creator username not equals",
fields: fields{
Filter: "creator != 'user6'",
},
args: defaultArgs,
want: []*Task{
task1,
task2,
task3,
task4,
task5,
task6,
task7,
task8,
task9,
task10,
task11,
task12,
task27,
task28,
task29,
task30,
task31,
task32,
task33,
task39,
task47,
task48,
},
wantErr: false,
},
{
name: "filter by creator unknown username",
fields: fields{
Filter: "creator = 'nonexistentuser'",
},
args: defaultArgs,
want: []*Task{},
wantErr: false,
},
{
name: "filter labels",
fields: fields{

View File

@ -62,6 +62,12 @@ var subTableFilters = SubTableFilters{
FilterableField: "username",
AllowNullCheck: true,
},
"creator": {
Table: "users",
BaseFilter: "tasks.created_by_id = users.id",
FilterableField: "username",
AllowNullCheck: false,
},
"parent_project": {
Table: "projects",
BaseFilter: "tasks.project_id = id",
@ -182,7 +188,7 @@ func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (
subTableFilterParams, ok := subTableFilters[f.field]
if ok {
if f.field == "assignees" && (f.comparator == taskFilterComparatorLike) {
if (f.field == "assignees" || f.field == "creator") && (f.comparator == taskFilterComparatorLike) {
continue
}