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:
parent
a8a53c9581
commit
e4b4a10bb6
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) && dueDate <= now</code>:
|
||||
{{ $t('filters.query.help.examples.priorityOneOrTwoPastDue') }}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe('Filter Transformation', () => {
|
|||
'doneAt': 'done_at',
|
||||
'reminders': 'reminders',
|
||||
'assignees': 'assignees',
|
||||
'creator': 'creator',
|
||||
'labels': 'labels',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ func validateTaskField(fieldName string) error {
|
|||
case
|
||||
taskPropertyAssignees,
|
||||
taskPropertyLabels,
|
||||
taskPropertyReminders:
|
||||
taskPropertyReminders,
|
||||
taskPropertyCreator:
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const (
|
|||
taskPropertyAssignees string = "assignees"
|
||||
taskPropertyLabels string = "labels"
|
||||
taskPropertyReminders string = "reminders"
|
||||
taskPropertyCreator string = "creator"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue