diff --git a/frontend/src/components/project/partials/FilterInput.vue b/frontend/src/components/project/partials/FilterInput.vue index f6b1f8a79..a9d2d411e 100644 --- a/frontend/src/components/project/partials/FilterInput.vue +++ b/frontend/src/components/project/partials/FilterInput.vue @@ -11,6 +11,15 @@ import XLabel from '@/components/tasks/partials/label.vue' import User from '@/components/misc/user.vue' import ProjectUserService from '@/services/projectUsers' import {useProjectStore} from '@/stores/projects' +import { + DATE_FIELDS, + ASSIGNEE_FIELDS, + AUTOCOMPLETE_FIELDS, + AVAILABLE_FILTER_FIELDS, + FILTER_JOIN_OPERATOR, + FILTER_OPERATORS, + FILTER_OPERATORS_REGEX, +} from '@/helpers/filters' const { modelValue, @@ -48,60 +57,6 @@ watch( const userService = new UserService() const projectUserService = new ProjectUserService() -const dateFields = [ - 'dueDate', - 'startDate', - 'endDate', - 'doneAt', - 'reminders', -] - -const assigneeFields = [ - 'assignees', -] - -const labelFields = [ - 'labels', -] - -const autocompleteFields = [ - ...labelFields, - ...assigneeFields, - 'project', -] - -const availableFilterFields = [ - 'done', - 'priority', - 'usePriority', - 'percentDone', - ...dateFields, - ...assigneeFields, - ...labelFields, -] - -const filterOperators = [ - '!=', - '=', - '>', - '>=', - '<', - '<=', - 'like', - 'in', - '?=', -] - -const filterJoinOperators = [ - '&&', - '||', - '(', - ')', -] - -const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=)' -const FILTER_JOIN_OPERATORS_REGEX = '(&&|\|\||\(|\))' - function escapeHtml(unsafe: string): string { return unsafe .replace(/&/g, '&') @@ -122,7 +77,7 @@ function unEscapeHtml(unsafe: string): string { const highlightedFilterQuery = computed(() => { let highlighted = escapeHtml(filterQuery.value) - dateFields + DATE_FIELDS .forEach(o => { const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig') highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => { @@ -133,7 +88,7 @@ const highlightedFilterQuery = computed(() => { return `${o}${spacesBefore}${token}${spacesAfter}${value}` }) }) - assigneeFields + ASSIGNEE_FIELDS .forEach(f => { const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig') highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => { @@ -169,17 +124,17 @@ const highlightedFilterQuery = computed(() => { return `${f} ${token} ${value}` }) }) - filterOperators + FILTER_OPERATORS .map(o => ` ${escapeHtml(o)} `) .forEach(o => { highlighted = highlighted.replaceAll(o, `${o}`) }) - filterJoinOperators + FILTER_JOIN_OPERATOR .map(o => escapeHtml(o)) .forEach(o => { highlighted = highlighted.replaceAll(o, `${o}`) }) - availableFilterFields.forEach(f => { + AVAILABLE_FILTER_FIELDS.forEach(f => { highlighted = highlighted.replaceAll(f, `${f}`) }) return highlighted @@ -234,7 +189,7 @@ function handleFieldInput(e, autocompleteOnInput) { const cursorPosition = filterInput.value.selectionStart const textUpToCursor = filterQuery.value.substring(0, cursorPosition) - autocompleteFields.forEach(field => { + AUTOCOMPLETE_FIELDS.forEach(field => { const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?$', 'ig') const match = pattern.exec(textUpToCursor) diff --git a/frontend/src/components/project/partials/filters.vue b/frontend/src/components/project/partials/filters.vue index ce43e0707..68a6a70be 100644 --- a/frontend/src/components/project/partials/filters.vue +++ b/frontend/src/components/project/partials/filters.vue @@ -41,6 +41,9 @@ import {objectToSnakeCase} from '@/helpers/case' import FilterInput from '@/components/project/partials/FilterInput.vue' import {useRoute} from 'vue-router' import type {TaskFilterParams} from '@/services/taskCollection' +import {useLabelStore} from '@/stores/labels' +import {useProjectStore} from '@/stores/projects' +import {transformFilterStringForApi} from '@/helpers/filters' const props = defineProps({ hasTitle: { @@ -78,8 +81,21 @@ watchDebounced( }, {immediate: true, debounce: 500, maxWait: 1000}, ) + +const labelStore = useLabelStore() +const projectStore = useProjectStore() + function change() { - modelValue.value = params.value + const filter = transformFilterStringForApi( + params.value.filter, + labelTitle => labelStore.filterLabelsByQuery([], labelTitle)[0]?.id || null, + projectTitle => projectStore.searchProject(projectTitle)[0]?.id || null, + ) + + modelValue.value = { + ...params.value, + filter, + } } diff --git a/frontend/src/helpers/filters.test.ts b/frontend/src/helpers/filters.test.ts new file mode 100644 index 000000000..1ad9fb9fe --- /dev/null +++ b/frontend/src/helpers/filters.test.ts @@ -0,0 +1,85 @@ +import {describe, expect, it} from 'vitest' +import {transformFilterStringForApi} from '@/helpers/filters' + +const nullResolver = (title: string) => null +describe('Filter Transformation', () => { + + const cases = { + 'done': 'done', + 'priority': 'priority', + 'percentDone': 'percent_done', + 'dueDate': 'due_date', + 'startDate': 'start_date', + 'endDate': 'end_date', + 'doneAt': 'done_at', + 'reminders': 'reminders', + 'assignees': 'assignees', + 'labels': 'labels', + } + + for (const c in cases) { + it('should transform all filter params for ' + c + ' to snake_case', () => { + const transformed = transformFilterStringForApi(c + ' = ipsum', nullResolver, nullResolver) + + expect(transformed).toBe(cases[c] + ' = ipsum') + }) + } + + it('should correctly resolve labels', () => { + const transformed = transformFilterStringForApi( + 'labels = lorem', + (title: string) => 1, + nullResolver, + ) + + expect(transformed).toBe('labels = 1') + }) + + it('should correctly resolve multiple labels', () => { + const transformed = transformFilterStringForApi( + 'labels = lorem && dueDate = now && labels = ipsum', + (title: string) => { + switch (title) { + case 'lorem': + return 1 + case 'ipsum': + return 2 + default: + return null + } + }, + nullResolver, + ) + + expect(transformed).toBe('labels = 1&& due_date = now && labels = 2') + }) + + it('should correctly resolve projects', () => { + const transformed = transformFilterStringForApi( + 'project = lorem', + nullResolver, + (title: string) => 1, + ) + + expect(transformed).toBe('project = 1') + }) + + it('should correctly resolve multiple projects', () => { + const transformed = transformFilterStringForApi( + 'project = lorem && dueDate = now || project = ipsum', + nullResolver, + (title: string) => { + switch (title) { + case 'lorem': + return 1 + case 'ipsum': + return 2 + default: + return null + } + }, + ) + + expect(transformed).toBe('project = 1&& due_date = now || project = 2') + }) +}) diff --git a/frontend/src/helpers/filters.ts b/frontend/src/helpers/filters.ts new file mode 100644 index 000000000..cb7a8e099 --- /dev/null +++ b/frontend/src/helpers/filters.ts @@ -0,0 +1,101 @@ +import {snakeCase} from 'snake-case' + +export const DATE_FIELDS = [ + 'dueDate', + 'startDate', + 'endDate', + 'doneAt', + 'reminders', +] + +export const ASSIGNEE_FIELDS = [ + 'assignees', +] + +export const LABEL_FIELDS = [ + 'labels', +] + +export const PROJECT_FIELDS = [ + 'project', +] + +export const AUTOCOMPLETE_FIELDS = [ + ...LABEL_FIELDS, + ...ASSIGNEE_FIELDS, + ...PROJECT_FIELDS, +] + +export const AVAILABLE_FILTER_FIELDS = [ + 'done', + 'priority', + 'percentDone', + ...DATE_FIELDS, + ...ASSIGNEE_FIELDS, + ...LABEL_FIELDS, +] + +export const FILTER_OPERATORS = [ + '!=', + '=', + '>', + '>=', + '<', + '<=', + 'like', + 'in', + '?=', +] + +export const FILTER_JOIN_OPERATOR = [ + '&&', + '||', + '(', + ')', +] + +export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=)' + +export function transformFilterStringForApi( + filter: string, + labelResolver: (title: string) => number | null, + projectResolver: (title: string) => number | null, +): string { + // Transform labels to ids + LABEL_FIELDS.forEach(field => { + const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig') + + let match: RegExpExecArray | null + while ((match = pattern.exec(filter)) !== null) { + const [matched, prefix, operator, space, keyword] = match + if (keyword) { + const labelId = labelResolver(keyword.trim()) + if (labelId !== null) { + filter = filter.replace(keyword, String(labelId)) + } + } + } + }) + // Transform projects to ids + PROJECT_FIELDS.forEach(field => { + const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig') + + let match: RegExpExecArray | null + while ((match = pattern.exec(filter)) !== null) { + const [matched, prefix, operator, space, keyword] = match + if (keyword) { + const projectId = projectResolver(keyword.trim()) + if (projectId !== null) { + filter = filter.replace(keyword, String(projectId)) + } + } + } + }) + + // Transform all attributes to snake case + AVAILABLE_FILTER_FIELDS.forEach(f => { + filter = filter.replace(f, snakeCase(f)) + }) + + return filter +}