This commit is contained in:
shiben 2026-06-29 18:45:08 +02:00 committed by GitHub
commit 42a5dc6928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 161 additions and 21 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,
@ -45,7 +46,7 @@ interface SuggestionItem {
name?: string
}
export type AutocompleteField = 'labels' | 'assignees' | 'projects'
export type AutocompleteField = 'labels' | 'users' | 'projects'
/**
* Calculates the replacement range for autocomplete selection.
@ -222,7 +223,7 @@ export default Extension.create<FilterAutocompleteOptions>({
return labelStore.filterLabelsByQuery([], autocompleteContext.search).filter((label): label is ILabel => label !== undefined) as SuggestionItem[]
}
if (fieldType === 'assignees') {
if (fieldType === 'users') {
if (debounceTimer) {
clearTimeout(debounceTimer)
@ -230,23 +231,23 @@ export default Extension.create<FilterAutocompleteOptions>({
return new Promise((resolve) => {
debounceTimer = setTimeout(async () => {
let assigneeSuggestions: SuggestionItem[] = []
let userSuggestions: SuggestionItem[] = []
try {
if (this.options.projectId) {
// @ts-expect-error - projectId is used for URL replacement but not part of IAbstract
assigneeSuggestions = await projectUserService.getAll({projectId: this.options.projectId}, {s: autocompleteContext.search}) as SuggestionItem[]
userSuggestions = await projectUserService.getAll({projectId: this.options.projectId}, {s: autocompleteContext.search}) as SuggestionItem[]
} else {
assigneeSuggestions = await userService.getAll({} as IUser, {s: autocompleteContext.search}) as SuggestionItem[]
userSuggestions = await userService.getAll({} as IUser, {s: autocompleteContext.search}) as SuggestionItem[]
}
// For assignees, show suggestions even with empty search, but limit if we have many
if (autocompleteContext.search === '' && assigneeSuggestions.length > 10) {
assigneeSuggestions = assigneeSuggestions.slice(0, 10)
// Show suggestions even with empty search, but limit if we have many
if (autocompleteContext.search === '' && userSuggestions.length > 10) {
userSuggestions = userSuggestions.slice(0, 10)
}
} catch (error) {
console.error('Error fetching assignee suggestions:', error)
assigneeSuggestions = []
console.error('Error fetching user suggestions:', error)
userSuggestions = []
}
resolve(assigneeSuggestions)
resolve(userSuggestions)
}, 300)
})
}
@ -338,8 +339,8 @@ export default Extension.create<FilterAutocompleteOptions>({
if (LABEL_FIELDS.includes(field)) {
fieldType = 'labels'
} else if (ASSIGNEE_FIELDS.includes(field)) {
fieldType = 'assignees'
} else if (ASSIGNEE_FIELDS.includes(field) || CREATOR_FIELDS.includes(field)) {
fieldType = 'users'
} else if (PROJECT_FIELDS.includes(field)) {
fieldType = 'projects'
}
@ -363,8 +364,8 @@ export default Extension.create<FilterAutocompleteOptions>({
const items = suggestions.map(item => ({
id: item.id,
title: fieldType === 'assignees' ? item.username : item.title,
description: fieldType === 'assignees' ? `${item.name || item.username}` : item.title,
title: fieldType === 'users' ? item.username : item.title,
description: fieldType === 'users' ? `${item.name || item.username}` : item.title,
item,
fieldType,
context: autocompleteContext,
@ -388,7 +389,7 @@ export default Extension.create<FilterAutocompleteOptions>({
items,
command: (item: AutocompleteItem) => {
// Handle selection
const newValue = item.fieldType === 'assignees'
const newValue = item.fieldType === 'users'
? (item.item as IUser).username
: (item.item as IProject | ILabel).title
// Use currentAutocompleteContext (outer variable) for up-to-date positions

View File

@ -15,7 +15,7 @@
class="filter-autocomplete__label"
/>
<User
v-else-if="item.fieldType === 'assignees'"
v-else-if="item.fieldType === 'users'"
:user="(item.item as unknown as IUser)"
:avatar-size="20"
class="filter-autocomplete__user"

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

@ -610,6 +610,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",
@ -640,6 +641,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,9 +360,16 @@ 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...)
valueSlice := make([]string, 0, len(vals))
for _, val := range vals {
val = strings.TrimSpace(val)
if val == "" {
continue
}
valueSlice = append(valueSlice, val)
}
return nil, valueSlice, nil
}

View File

@ -229,6 +229,33 @@ 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("creator usernames are trimmed of surrounding whitespace", 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("creator usernames drop empty entries from stray commas", 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
}