feat(filters): add "not in" operator for filters
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
Resolves https://community.vikunja.io/t/feature-requests-regarding-view-ordering-project-reference-in-kanban-and-a-notin-operator/2728
This commit is contained in:
parent
aad9d8dffc
commit
57c6f2cd10
@ -133,7 +133,7 @@ const highlightedFilterQuery = computed(() => {
|
||||
}
|
||||
|
||||
let labelTitles = [value.trim()]
|
||||
if (operator === 'in' || operator === '?=') {
|
||||
if (operator === 'in' || operator === '?=' || operator === 'not in' || operator === '?!=') {
|
||||
labelTitles = value.split(',').map(v => v.trim())
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,7 @@ const showDocs = ref(false)
|
||||
<li><code><=</code>: {{ $t('filters.query.help.operators.lessThanOrEqual') }}</li>
|
||||
<li><code>like</code>: {{ $t('filters.query.help.operators.like') }}</li>
|
||||
<li><code>in</code>: {{ $t('filters.query.help.operators.in') }}</li>
|
||||
<li><code>not in</code>: {{ $t('filters.query.help.operators.notIn') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('filters.query.help.logicalOperators.intro') }}</p>
|
||||
<ul>
|
||||
|
@ -67,6 +67,16 @@ describe('Filter Transformation', () => {
|
||||
|
||||
expect(transformed).toBe('labels in 1, 2 && due_date = now')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple labels with a not in clause', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
'labels not in lorem, ipsum && dueDate = now',
|
||||
multipleDummyResolver,
|
||||
nullTitleToIdResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels not in 1, 2 && due_date = now')
|
||||
})
|
||||
|
||||
it('should correctly resolve projects', () => {
|
||||
const transformed = transformFilterStringForApi(
|
||||
@ -218,7 +228,17 @@ describe('Filter Transformation', () => {
|
||||
|
||||
expect(transformed).toBe('labels in lorem, ipsum')
|
||||
})
|
||||
|
||||
it('should correctly resolve multiple labels not in', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels not in 1, 2',
|
||||
multipleIdToTitleResolver,
|
||||
nullIdToTitleResolver,
|
||||
)
|
||||
|
||||
expect(transformed).toBe('labels not in lorem, ipsum')
|
||||
})
|
||||
|
||||
it('should not touch the label value when it is undefined', () => {
|
||||
const transformed = transformFilterStringFromApi(
|
||||
'labels = one',
|
||||
|
@ -46,6 +46,7 @@ export const FILTER_OPERATORS = [
|
||||
'<',
|
||||
'<=',
|
||||
'like',
|
||||
'not in',
|
||||
'in',
|
||||
'?=',
|
||||
]
|
||||
@ -57,7 +58,7 @@ export const FILTER_JOIN_OPERATOR = [
|
||||
')',
|
||||
]
|
||||
|
||||
export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=|in)'
|
||||
export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=|not in|in)'
|
||||
|
||||
export function getFilterFieldRegexPattern(field: string): RegExp {
|
||||
return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&|()<]+\\1?)?', 'ig')
|
||||
@ -68,13 +69,13 @@ export function transformFilterStringForApi(
|
||||
labelResolver: (title: string) => number | null,
|
||||
projectResolver: (title: string) => number | null,
|
||||
): string {
|
||||
|
||||
|
||||
filter = filter.trim()
|
||||
|
||||
if (filter === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
AVAILABLE_FILTER_FIELDS.forEach(f => {
|
||||
filter = filter.replace(new RegExp(f, 'ig'), f)
|
||||
})
|
||||
@ -97,10 +98,10 @@ export function transformFilterStringForApi(
|
||||
}
|
||||
|
||||
let keywords = [keyword.trim()]
|
||||
if (operator === 'in' || operator === '?=') {
|
||||
if (operator === 'in' || operator === '?=' || operator === 'not in' || operator === '?!=') {
|
||||
keywords = keyword.trim().split(',').map(k => k.trim())
|
||||
}
|
||||
|
||||
|
||||
let replaced = keyword
|
||||
|
||||
keywords.forEach(k => {
|
||||
@ -162,7 +163,7 @@ export function transformFilterStringFromApi(
|
||||
const [matched, prefix, operator, space, keyword] = match
|
||||
if (keyword) {
|
||||
let keywords = [keyword.trim()]
|
||||
if (operator === 'in' || operator === '?=') {
|
||||
if (operator === 'in' || operator === '?=' || operator === 'not in' || operator === '?!=') {
|
||||
keywords = keyword.trim().split(',').map(k => k.trim())
|
||||
}
|
||||
|
||||
|
@ -472,7 +472,8 @@
|
||||
"lessThan": "Less than",
|
||||
"lessThanOrEqual": "Less than or equal to",
|
||||
"like": "Matches a pattern (using wildcard %)",
|
||||
"in": "Matches any value in a comma-seperated list of values"
|
||||
"in": "Matches any value in a comma-seperated list of values",
|
||||
"notIn": "Matches any value not present in a comma-seperated list of values"
|
||||
},
|
||||
"logicalOperators": {
|
||||
"intro": "To combine multiple conditions, you can use the following logical operators:",
|
||||
|
@ -47,6 +47,7 @@ const (
|
||||
taskFilterComparatorNotEquals taskFilterComparator = "!="
|
||||
taskFilterComparatorLike taskFilterComparator = "like"
|
||||
taskFilterComparatorIn taskFilterComparator = "in"
|
||||
taskFilterComparatorNotIn taskFilterComparator = "not in"
|
||||
)
|
||||
|
||||
// Guess what you get back if you ask Safari for a rfc 3339 formatted date?
|
||||
@ -153,11 +154,12 @@ func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filte
|
||||
return
|
||||
}
|
||||
|
||||
filter = strings.ReplaceAll(filter, " not in ", " "+string(fexpr.SignAnyNeq)+" ")
|
||||
filter = strings.ReplaceAll(filter, " in ", " ?= ")
|
||||
filter = strings.ReplaceAll(filter, " like ", " ~ ")
|
||||
|
||||
// Regex pattern to match filter expressions
|
||||
re := regexp.MustCompile(`(\w+)\s*(>=|<=|!=|~|\?=|=|>|<)\s*([^&|()]+)`)
|
||||
re := regexp.MustCompile(`(\w+)\s*(>=|<=|!=|~|\?=|\?!=|=|>|<)\s*([^&|()]+)`)
|
||||
|
||||
filter = re.ReplaceAllStringFunc(filter, func(match string) string {
|
||||
parts := re.FindStringSubmatch(match)
|
||||
@ -221,7 +223,8 @@ func validateTaskFieldComparator(comparator taskFilterComparator) error {
|
||||
taskFilterComparatorLessEquals,
|
||||
taskFilterComparatorNotEquals,
|
||||
taskFilterComparatorLike,
|
||||
taskFilterComparatorIn:
|
||||
taskFilterComparatorIn,
|
||||
taskFilterComparatorNotIn:
|
||||
return nil
|
||||
case taskFilterComparatorInvalid:
|
||||
fallthrough
|
||||
@ -250,6 +253,10 @@ func getFilterComparatorFromOp(op fexpr.SignOp) (taskFilterComparator, error) {
|
||||
fallthrough
|
||||
case "in":
|
||||
return taskFilterComparatorIn, nil
|
||||
case fexpr.SignAnyNeq:
|
||||
fallthrough
|
||||
case "not in":
|
||||
return taskFilterComparatorNotIn, nil
|
||||
default:
|
||||
return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(op)}
|
||||
}
|
||||
@ -337,7 +344,7 @@ func getNativeValueForTaskField(fieldName string, comparator taskFilterComparato
|
||||
}
|
||||
}
|
||||
|
||||
if comparator == taskFilterComparatorIn {
|
||||
if comparator == taskFilterComparatorIn || comparator == taskFilterComparatorNotIn {
|
||||
vals := strings.Split(value, ",")
|
||||
valueSlice := []interface{}{}
|
||||
for _, val := range vals {
|
||||
|
@ -74,6 +74,18 @@ func TestParseFilter(t *testing.T) {
|
||||
assert.Equal(t, int64(2), result[0].value.([]interface{})[1])
|
||||
assert.Equal(t, int64(3), result[0].value.([]interface{})[2])
|
||||
})
|
||||
t.Run("not in", func(t *testing.T) {
|
||||
result, err := getTaskFiltersFromFilterString("project_id not in 1,2,3", "UTC")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "project_id", result[0].field)
|
||||
assert.Equal(t, taskFilterComparatorNotIn, result[0].comparator)
|
||||
require.Len(t, result[0].value, 3)
|
||||
assert.Equal(t, int64(1), result[0].value.([]interface{})[0])
|
||||
assert.Equal(t, int64(2), result[0].value.([]interface{})[1])
|
||||
assert.Equal(t, int64(3), result[0].value.([]interface{})[2])
|
||||
})
|
||||
t.Run("use project for project_id", func(t *testing.T) {
|
||||
result, err := getTaskFiltersFromFilterString("project in 1,2,3", "UTC")
|
||||
|
||||
|
@ -26,6 +26,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
|
||||
"gopkg.in/d4l3k/messagediff.v1"
|
||||
)
|
||||
|
||||
@ -1037,6 +1038,45 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter not in",
|
||||
fields: fields{
|
||||
Filter: "id not in '1,2,3,4'",
|
||||
},
|
||||
args: defaultArgs,
|
||||
want: []*Task{
|
||||
task5,
|
||||
task6,
|
||||
task7,
|
||||
task8,
|
||||
task9,
|
||||
task10,
|
||||
task11,
|
||||
task12,
|
||||
task15,
|
||||
task16,
|
||||
task17,
|
||||
task18,
|
||||
task19,
|
||||
task20,
|
||||
task21,
|
||||
task22,
|
||||
task23,
|
||||
task24,
|
||||
task25,
|
||||
task26,
|
||||
task27,
|
||||
task28,
|
||||
task29,
|
||||
task30,
|
||||
task31,
|
||||
task32,
|
||||
task33,
|
||||
task35,
|
||||
task39,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "filter assignees by username",
|
||||
fields: fields{
|
||||
|
@ -488,6 +488,8 @@ func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string,
|
||||
filter += ":"
|
||||
case taskFilterComparatorIn:
|
||||
filter += ":["
|
||||
case taskFilterComparatorNotIn:
|
||||
filter += ":!["
|
||||
case taskFilterComparatorInvalid:
|
||||
// Nothing to do
|
||||
default:
|
||||
@ -496,7 +498,7 @@ func convertParsedFilterToTypesense(rawFilters []*taskFilter) (filterBy string,
|
||||
|
||||
filter += convertFilterValues(f.value)
|
||||
|
||||
if f.comparator == taskFilterComparatorIn {
|
||||
if f.comparator == taskFilterComparatorIn || f.comparator == taskFilterComparatorNotIn {
|
||||
filter += "]"
|
||||
}
|
||||
|
||||
|
@ -235,6 +235,8 @@ func getFilterCond(f *taskFilter, includeNulls bool) (cond builder.Cond, err err
|
||||
cond = &builder.Like{field, "%" + val + "%"}
|
||||
case taskFilterComparatorIn:
|
||||
cond = builder.In(field, f.value)
|
||||
case taskFilterComparatorNotIn:
|
||||
cond = builder.NotIn(field, f.value)
|
||||
case taskFilterComparatorInvalid:
|
||||
// Nothing to do
|
||||
}
|
||||
|
Reference in New Issue
Block a user