diff --git a/frontend/src/components/project/partials/filters.vue b/frontend/src/components/project/partials/filters.vue index 68a6a70be..bb3fed41c 100644 --- a/frontend/src/components/project/partials/filters.vue +++ b/frontend/src/components/project/partials/filters.vue @@ -43,7 +43,7 @@ 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' +import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters' const props = defineProps({ hasTitle: { @@ -52,7 +52,7 @@ const props = defineProps({ }, }) -const modelValue = defineModel() +const modelValue = defineModel() const route = useRoute() const projectId = computed(() => { @@ -72,12 +72,16 @@ const params = ref({ }) // Using watchDebounced to prevent the filter re-triggering itself. -// FIXME: Only here until this whole component changes a lot with the new filter syntax. watchDebounced( - modelValue, - (value) => { - // FIXME: filters should only be converted to snake case in the last moment - params.value = objectToSnakeCase(value) + () => modelValue.value, + (value: TaskFilterParams) => { + const val = value + val.filter = transformFilterStringFromApi( + val?.filter || '', + labelId => labelStore.getLabelById(labelId), + projectId => projectStore.projects.value[projectId] || null, + ) + params.value = val }, {immediate: true, debounce: 500, maxWait: 1000}, ) diff --git a/frontend/src/helpers/filters.test.ts b/frontend/src/helpers/filters.test.ts index 1ad9fb9fe..a1910ff26 100644 --- a/frontend/src/helpers/filters.test.ts +++ b/frontend/src/helpers/filters.test.ts @@ -1,10 +1,11 @@ import {describe, expect, it} from 'vitest' -import {transformFilterStringForApi} from '@/helpers/filters' +import {transformFilterStringForApi, transformFilterStringFromApi} from '@/helpers/filters' -const nullResolver = (title: string) => null +const nullTitleToIdResolver = (title: string) => null +const nullIdToTitleResolver = (id: number) => null describe('Filter Transformation', () => { - const cases = { + const fieldCases = { 'done': 'done', 'priority': 'priority', 'percentDone': 'percent_done', @@ -16,70 +17,140 @@ describe('Filter Transformation', () => { 'assignees': 'assignees', 'labels': 'labels', } + + describe('For api', () => { + for (const c in fieldCases) { + it('should transform all filter params for ' + c + ' to snake_case', () => { + const transformed = transformFilterStringForApi(c + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver) - 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(fieldCases[c] + ' = ipsum') + }) + } - expect(transformed).toBe(cases[c] + ' = ipsum') + it('should correctly resolve labels', () => { + const transformed = transformFilterStringForApi( + 'labels = lorem', + (title: string) => 1, + nullTitleToIdResolver, + ) + + 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 + } + }, + nullTitleToIdResolver, + ) + + expect(transformed).toBe('labels = 1&& due_date = now && labels = 2') + }) + + it('should correctly resolve projects', () => { + const transformed = transformFilterStringForApi( + 'project = lorem', + nullTitleToIdResolver, + (title: string) => 1, + ) + + expect(transformed).toBe('project = 1') + }) + + it('should correctly resolve multiple projects', () => { + const transformed = transformFilterStringForApi( + 'project = lorem && dueDate = now || project = ipsum', + nullTitleToIdResolver, + (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') + }) + }) + + describe('To API', () => { + for (const c in fieldCases) { + it('should transform all filter params for ' + c + ' to snake_case', () => { + const transformed = transformFilterStringFromApi(fieldCases[c] + ' = ipsum', nullTitleToIdResolver, nullTitleToIdResolver) + + expect(transformed).toBe(c + ' = ipsum') + }) + } + + it('should correctly resolve labels', () => { + const transformed = transformFilterStringFromApi( + 'labels = 1', + (id: number) => 'lorem', + nullIdToTitleResolver, + ) + + expect(transformed).toBe('labels = lorem') }) - } - - 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 = transformFilterStringFromApi( + 'labels = 1 && due_date = now && labels = 2', + (id: number) => { + switch (id) { + case 1: + return 'lorem' + case 2: + return 'ipsum' + default: + return null + } + }, + nullIdToTitleResolver, + ) - 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 = lorem&& dueDate = now && labels = ipsum') + }) - expect(transformed).toBe('labels = 1&& due_date = now && labels = 2') - }) + it('should correctly resolve projects', () => { + const transformed = transformFilterStringFromApi( + 'project = 1', + nullIdToTitleResolver, + (id: number) => 'lorem', + ) - it('should correctly resolve projects', () => { - const transformed = transformFilterStringForApi( - 'project = lorem', - nullResolver, - (title: string) => 1, - ) + expect(transformed).toBe('project = lorem') + }) - 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 - } - }, - ) + it('should correctly resolve multiple projects', () => { + const transformed = transformFilterStringFromApi( + 'project = lorem && due_date = now || project = ipsum', + nullIdToTitleResolver, + (id: number) => { + switch (id) { + case 1: + return 'lorem' + case 2: + return 'ipsum' + default: + return null + } + }, + ) - expect(transformed).toBe('project = 1&& due_date = now || project = 2') + expect(transformed).toBe('project = lorem && dueDate = now || project = ipsum') + }) }) }) diff --git a/frontend/src/helpers/filters.ts b/frontend/src/helpers/filters.ts index cb7a8e099..5f8a5c389 100644 --- a/frontend/src/helpers/filters.ts +++ b/frontend/src/helpers/filters.ts @@ -1,4 +1,5 @@ import {snakeCase} from 'snake-case' +import {camelCase} from 'camel-case' export const DATE_FIELDS = [ 'dueDate', @@ -56,6 +57,10 @@ export const FILTER_JOIN_OPERATOR = [ export const FILTER_OPERATORS_REGEX = '(<|>|<=|>=|=|!=)' +function getFieldPattern(field: string): RegExp { + return new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig') +} + export function transformFilterStringForApi( filter: string, labelResolver: (title: string) => number | null, @@ -63,7 +68,7 @@ export function transformFilterStringForApi( ): string { // Transform labels to ids LABEL_FIELDS.forEach(field => { - const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig') + const pattern = getFieldPattern(field) let match: RegExpExecArray | null while ((match = pattern.exec(filter)) !== null) { @@ -78,7 +83,7 @@ export function transformFilterStringForApi( }) // Transform projects to ids PROJECT_FIELDS.forEach(field => { - const pattern = new RegExp('(' + field + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?', 'ig') + const pattern = getFieldPattern(field) let match: RegExpExecArray | null while ((match = pattern.exec(filter)) !== null) { @@ -99,3 +104,48 @@ export function transformFilterStringForApi( return filter } + +export function transformFilterStringFromApi( + filter: string, + labelResolver: (id: number) => string | null, + projectResolver: (id: number) => string | null, +): string { + // Transform all attributes from snake case + AVAILABLE_FILTER_FIELDS.forEach(f => { + filter = filter.replace(snakeCase(f), f) + }) + + // Transform labels to their titles + LABEL_FIELDS.forEach(field => { + const pattern = getFieldPattern(field) + + let match: RegExpExecArray | null + while ((match = pattern.exec(filter)) !== null) { + const [matched, prefix, operator, space, keyword] = match + if (keyword) { + const labelTitle = labelResolver(Number(keyword.trim())) + if (labelTitle !== null) { + filter = filter.replace(keyword, labelTitle) + } + } + } + }) + + // Transform projects to ids + PROJECT_FIELDS.forEach(field => { + const pattern = getFieldPattern(field) + + let match: RegExpExecArray | null + while ((match = pattern.exec(filter)) !== null) { + const [matched, prefix, operator, space, keyword] = match + if (keyword) { + const project = projectResolver(Number(keyword.trim())) + if (project !== null) { + filter = filter.replace(keyword, project) + } + } + } + }) + + return filter +} diff --git a/frontend/src/stores/labels.ts b/frontend/src/stores/labels.ts index b617e4dcc..5d8c48221 100644 --- a/frontend/src/stores/labels.ts +++ b/frontend/src/stores/labels.ts @@ -33,6 +33,10 @@ export const useLabelStore = defineStore('label', () => { const getLabelsByIds = computed(() => { return (ids: ILabel['id'][]) => Object.values(labels.value).filter(({id}) => ids.includes(id)) }) + + const getLabelById = computed(() => { + return (labelId: ILabel['id']) => Object.values(labels.value).find(({id}) => id === labelId) + }) // ** // * Checks if a project of labels is available in the store and filters them then query @@ -138,6 +142,7 @@ export const useLabelStore = defineStore('label', () => { isLoading, getLabelsByIds, + getLabelById, filterLabelsByQuery, getLabelsByExactTitles,