diff --git a/frontend/src/components/input/filter/FilterAutocomplete.ts b/frontend/src/components/input/filter/FilterAutocomplete.ts index a4b0d64fc..6133b2f96 100644 --- a/frontend/src/components/input/filter/FilterAutocomplete.ts +++ b/frontend/src/components/input/filter/FilterAutocomplete.ts @@ -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({ 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({ 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({ 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({ 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({ 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 diff --git a/frontend/src/components/input/filter/FilterCommandsList.vue b/frontend/src/components/input/filter/FilterCommandsList.vue index 34c92887b..7473f5467 100644 --- a/frontend/src/components/input/filter/FilterCommandsList.vue +++ b/frontend/src/components/input/filter/FilterCommandsList.vue @@ -15,7 +15,7 @@ class="filter-autocomplete__label" /> endDate: {{ $t('filters.query.help.fields.endDate') }}
  • doneAt: {{ $t('filters.query.help.fields.doneAt') }}
  • assignees: {{ $t('filters.query.help.fields.assignees') }}
  • +
  • creator: {{ $t('filters.query.help.fields.creator') }}
  • labels: {{ $t('filters.query.help.fields.labels') }}
  • project: {{ $t('filters.query.help.fields.project') }}
  • reminders: {{ $t('filters.query.help.fields.reminders') }}
  • @@ -62,6 +63,7 @@ const showDocs = ref(false) {{ $t('filters.query.help.examples.undoneHighPriority') }}
  • assignees in user1, user2: {{ $t('filters.query.help.examples.assigneesIn') }}
  • +
  • creator = user1: {{ $t('filters.query.help.examples.creatorEqual') }}
  • (priority = 1 || priority = 2) && dueDate <= now: {{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }} diff --git a/frontend/src/helpers/filters.test.ts b/frontend/src/helpers/filters.test.ts index ec64bf62a..cb9288fae 100644 --- a/frontend/src/helpers/filters.test.ts +++ b/frontend/src/helpers/filters.test.ts @@ -15,6 +15,7 @@ describe('Filter Transformation', () => { 'doneAt': 'done_at', 'reminders': 'reminders', 'assignees': 'assignees', + 'creator': 'creator', 'labels': 'labels', } diff --git a/frontend/src/helpers/filters.ts b/frontend/src/helpers/filters.ts index 53cb34183..5c02aa60f 100644 --- a/frontend/src/helpers/filters.ts +++ b/frontend/src/helpers/filters.ts @@ -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', diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 9d4e0336d..9740a796e 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -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" } } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index bc217f7ca..80df2e316 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -105,7 +105,8 @@ func validateTaskField(fieldName string) error { case taskPropertyAssignees, taskPropertyLabels, - taskPropertyReminders: + taskPropertyReminders, + taskPropertyCreator: return nil } diff --git a/pkg/models/task_collection_filter.go b/pkg/models/task_collection_filter.go index 6828b0e07..7bea373de 100644 --- a/pkg/models/task_collection_filter.go +++ b/pkg/models/task_collection_filter.go @@ -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 } diff --git a/pkg/models/task_collection_filter_test.go b/pkg/models/task_collection_filter_test.go index 577cf79e6..d5544a743 100644 --- a/pkg/models/task_collection_filter_test.go +++ b/pkg/models/task_collection_filter_test.go @@ -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") diff --git a/pkg/models/task_collection_sort.go b/pkg/models/task_collection_sort.go index 3bcd878b2..7e36aef5c 100644 --- a/pkg/models/task_collection_sort.go +++ b/pkg/models/task_collection_sort.go @@ -51,6 +51,7 @@ const ( taskPropertyAssignees string = "assignees" taskPropertyLabels string = "labels" taskPropertyReminders string = "reminders" + taskPropertyCreator string = "creator" ) const ( diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index e66d945b2..5cf8c739d 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -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{ diff --git a/pkg/models/task_search.go b/pkg/models/task_search.go index 07da3f809..13f1a5769 100644 --- a/pkg/models/task_search.go +++ b/pkg/models/task_search.go @@ -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 }