mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-29 19:10:51 -05:00
fix(filter): correct filter autocomplete for project names with spaces (#2012)
Filter expressions with multi-word values (such as project names with spaces) are now automatically quoted to preserve them correctly as single tokens in the filter syntax. Fixes #2010 🐰 A filter's journey, refined with care, Multi-word values now wear quotes fair, Offsets aligned by the rabbit's precision, Autocomplete flows with mathematical vision, From comma to space, each boundary divine! ✨
This commit is contained in:
266
frontend/tests/e2e/filters/filter-autocomplete.spec.ts
Normal file
266
frontend/tests/e2e/filters/filter-autocomplete.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import {test, expect} from '../../support/fixtures'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectViewFactory} from '../../factories/project_view'
|
||||
import {SavedFilterFactory} from '../../factories/saved_filter'
|
||||
|
||||
/**
|
||||
* Tests for filter autocomplete functionality, specifically for:
|
||||
* - Project names with spaces (Issue #2010)
|
||||
* - Verifying filters save correctly without corruption
|
||||
*/
|
||||
|
||||
async function createProjectWithViews(id: number, title: string, ownerId: number, truncate = false) {
|
||||
await ProjectFactory.create(1, {
|
||||
id,
|
||||
title,
|
||||
owner_id: ownerId,
|
||||
}, truncate)
|
||||
await ProjectViewFactory.create(1, {
|
||||
id: id * 4,
|
||||
project_id: id,
|
||||
view_kind: 0, // List
|
||||
}, false)
|
||||
}
|
||||
|
||||
// Helper to get the filter input ProseMirror editor
|
||||
function getFilterInput(page) {
|
||||
return page.locator('.filter-input .ProseMirror')
|
||||
}
|
||||
|
||||
test.describe('Filter Autocomplete', () => {
|
||||
test.beforeEach(async ({authenticatedPage, currentUser}) => {
|
||||
// authenticatedPage fixture triggers apiContext which sets up Factory.request
|
||||
await ProjectFactory.truncate()
|
||||
await TaskFactory.truncate()
|
||||
await ProjectViewFactory.truncate()
|
||||
await SavedFilterFactory.truncate()
|
||||
|
||||
const userId = currentUser.id
|
||||
|
||||
// Create projects - one with spaces in name (the bug case)
|
||||
await createProjectWithViews(1, 'Inbox', userId)
|
||||
await createProjectWithViews(2, 'Work To Do', userId)
|
||||
await createProjectWithViews(3, 'Personal Tasks', userId)
|
||||
|
||||
// Create tasks in each project
|
||||
await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
project_id: 1,
|
||||
title: 'Inbox Task',
|
||||
})
|
||||
await TaskFactory.create(1, {
|
||||
id: 2,
|
||||
project_id: 2,
|
||||
title: 'Work Task 1',
|
||||
})
|
||||
await TaskFactory.create(1, {
|
||||
id: 3,
|
||||
project_id: 2,
|
||||
title: 'Work Task 2',
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Saved Filter Creation with Autocomplete', () => {
|
||||
test('should replace single-word project name via autocomplete', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/filters/new')
|
||||
|
||||
// Wait for projects to be loaded
|
||||
await expect(page.getByRole('link', {name: 'Inbox', exact: true})).toBeVisible({timeout: 10000})
|
||||
|
||||
// Fill in filter name
|
||||
await page.locator('input#Title').fill('Inbox Filter')
|
||||
|
||||
// Type filter with project name to trigger autocomplete
|
||||
const filterInput = getFilterInput(page)
|
||||
await filterInput.click()
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
await filterInput.pressSequentially('project = Inb', {delay: 50})
|
||||
|
||||
// Wait for autocomplete popup and select "Inbox"
|
||||
const autocompletePopup = page.locator('#filter-autocomplete-popup')
|
||||
await expect(autocompletePopup).toBeVisible({timeout: 5000})
|
||||
await autocompletePopup.getByRole('button', {name: 'Inbox'}).click()
|
||||
|
||||
// Verify the filter text is correct after autocomplete replacement
|
||||
await expect(filterInput).toContainText('project = Inbox')
|
||||
|
||||
// Save the filter and verify no error
|
||||
await page.locator('button.is-primary.is-fullwidth').click()
|
||||
await expect(page.locator('.notification.is-danger')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should replace multi-word project name with spaces via autocomplete', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/filters/new')
|
||||
|
||||
// Wait for projects to be loaded
|
||||
await expect(page.locator('.menu-list a').filter({hasText: 'Work To Do'})).toBeVisible({timeout: 10000})
|
||||
|
||||
// Fill in filter name
|
||||
await page.locator('input#Title').fill('Work Filter')
|
||||
|
||||
// Type filter with partial project name to trigger autocomplete
|
||||
const filterInput = getFilterInput(page)
|
||||
await filterInput.click()
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
await filterInput.pressSequentially('project = Work', {delay: 50})
|
||||
|
||||
// Wait for autocomplete popup with the specific option we want to click
|
||||
const autocompletePopup = page.locator('#filter-autocomplete-popup')
|
||||
await expect(autocompletePopup).toBeVisible({timeout: 5000})
|
||||
// Wait for the specific autocomplete option to be visible (ensures context is updated)
|
||||
const workToDoButton = autocompletePopup.getByRole('button', {name: 'Work To Do'})
|
||||
await expect(workToDoButton).toBeVisible({timeout: 2000})
|
||||
await workToDoButton.click()
|
||||
|
||||
// Verify the filter text is correct after autocomplete replacement
|
||||
await expect(filterInput).toContainText('project = Work To Do')
|
||||
|
||||
// Save the filter and verify no error
|
||||
await page.locator('button.is-primary.is-fullwidth').click()
|
||||
await expect(page.locator('.notification.is-danger')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should handle autocomplete after logical operator', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/filters/new')
|
||||
|
||||
// Wait for projects to be loaded
|
||||
await expect(page.getByRole('link', {name: 'Inbox', exact: true})).toBeVisible({timeout: 10000})
|
||||
|
||||
await page.locator('input#Title').fill('Complex Filter')
|
||||
|
||||
const filterInput = getFilterInput(page)
|
||||
await filterInput.click()
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
// Type a complex filter with autocomplete for project name
|
||||
await filterInput.pressSequentially('done = false && project = Pers', {delay: 50})
|
||||
|
||||
// Wait for autocomplete popup and select "Personal Tasks"
|
||||
const autocompletePopup = page.locator('#filter-autocomplete-popup')
|
||||
await expect(autocompletePopup).toBeVisible({timeout: 5000})
|
||||
await autocompletePopup.getByRole('button', {name: 'Personal Tasks'}).click()
|
||||
|
||||
// Verify correct filter text
|
||||
await expect(filterInput).toContainText('done = false && project = Personal Tasks')
|
||||
|
||||
// Save and verify no error
|
||||
await page.locator('button.is-primary.is-fullwidth').click()
|
||||
await expect(page.locator('.notification.is-danger')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edit Saved Filter with Multi-Value Autocomplete (Issue #2010 Regression)', () => {
|
||||
test('should preserve filter text after editing and adding trailing space', async ({authenticatedPage: page}) => {
|
||||
// This test covers the specific bug from Issue #2010:
|
||||
// Creating a filter with 'project in Work To Do, Inbox', then editing
|
||||
// and adding a trailing space should not corrupt the filter or cause errors
|
||||
|
||||
await page.goto('/filters/new')
|
||||
|
||||
// Wait for projects to be loaded
|
||||
await expect(page.locator('.menu-list a').filter({hasText: 'Work To Do'})).toBeVisible({timeout: 10000})
|
||||
|
||||
// Step 1: Create a filter with multi-value project using 'in' operator
|
||||
await page.locator('input#Title').fill('Work Filter')
|
||||
|
||||
const filterInput = getFilterInput(page)
|
||||
await filterInput.click()
|
||||
await page.keyboard.press('ControlOrMeta+a')
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
// Type 'project in Work' to trigger autocomplete
|
||||
await filterInput.pressSequentially('project in Work', {delay: 50})
|
||||
|
||||
// Wait for autocomplete popup with the specific option we want to click
|
||||
const autocompletePopup = page.locator('#filter-autocomplete-popup')
|
||||
await expect(autocompletePopup).toBeVisible({timeout: 5000})
|
||||
// Wait for the specific autocomplete option to be visible (ensures context is updated)
|
||||
const workToDoButton = autocompletePopup.getByRole('button', {name: 'Work To Do'})
|
||||
await expect(workToDoButton).toBeVisible({timeout: 2000})
|
||||
await workToDoButton.click()
|
||||
|
||||
// Wait for autocomplete to close and text to stabilize
|
||||
await expect(autocompletePopup).not.toBeVisible({timeout: 2000})
|
||||
await expect(filterInput).toContainText('project in Work To Do')
|
||||
|
||||
// Continue typing the second value: ', Inbox'
|
||||
await filterInput.click()
|
||||
await page.keyboard.press('End')
|
||||
await filterInput.pressSequentially(', Inbox', {delay: 50})
|
||||
|
||||
// Verify the filter text shows the multi-value 'in' clause
|
||||
await expect(filterInput).toContainText('project in Work To Do, Inbox')
|
||||
|
||||
// Step 2: Save the filter and verify no error
|
||||
await page.locator('button.is-primary.is-fullwidth').click()
|
||||
await expect(page.locator('.notification.is-danger')).not.toBeVisible()
|
||||
|
||||
// Wait for navigation to the saved filter view
|
||||
await expect(page).toHaveURL(/\/projects\/-\d+/, {timeout: 5000})
|
||||
|
||||
// Step 3: Open the filter settings menu from sidebar and click Edit
|
||||
// Find the Work Filter link in the sidebar and click its settings menu
|
||||
const filterLink = page.locator('.menu-list').getByRole('link', {name: 'Work Filter', exact: true})
|
||||
await expect(filterLink).toBeVisible()
|
||||
|
||||
// Hover over the filter to show the settings menu button
|
||||
const filterItem = filterLink.locator('..')
|
||||
await filterItem.hover()
|
||||
|
||||
// Click the settings menu button
|
||||
const settingsButton = filterItem.getByRole('button', {name: 'Open project settings menu'})
|
||||
await settingsButton.click()
|
||||
|
||||
// Click "Edit" link in the dropdown menu
|
||||
await page.getByRole('link', {name: 'Edit', exact: true}).click()
|
||||
|
||||
// Wait for the edit modal/form to be loaded
|
||||
await expect(page.locator('input#Title')).toHaveValue('Work Filter', {timeout: 5000})
|
||||
|
||||
// Find the filter input inside the Filters component
|
||||
const editFilterInput = page.locator('.filters .filter-input .ProseMirror')
|
||||
await expect(editFilterInput).toBeVisible()
|
||||
|
||||
// Verify the filter text is correctly loaded
|
||||
await expect(editFilterInput).toContainText('project in Work To Do, Inbox')
|
||||
|
||||
// Step 4: Add a trailing space (the bug trigger from #2010)
|
||||
await editFilterInput.click()
|
||||
// Move to end of text
|
||||
await page.keyboard.press('End')
|
||||
// Type a space
|
||||
await page.keyboard.type(' ')
|
||||
|
||||
// Step 5: Save again and wait for the modal to close (indicates save complete)
|
||||
const saveButton = page.locator('.card-footer .button.is-primary')
|
||||
await saveButton.click()
|
||||
// Wait for the edit card/modal to close after save
|
||||
await expect(saveButton).not.toBeVisible({timeout: 5000})
|
||||
|
||||
// Step 6: Assert no error occurred
|
||||
await expect(page.locator('.notification.is-danger')).not.toBeVisible()
|
||||
|
||||
// Step 7: Reload and re-open edit to verify the filter text is still intact
|
||||
await page.reload()
|
||||
await expect(page.locator('.menu-list').getByRole('link', {name: 'Work Filter', exact: true})).toBeVisible({timeout: 5000})
|
||||
|
||||
// Re-open the edit modal
|
||||
const filterLinkAfterReload = page.locator('.menu-list').getByRole('link', {name: 'Work Filter', exact: true})
|
||||
const filterItemAfterReload = filterLinkAfterReload.locator('..')
|
||||
await filterItemAfterReload.hover()
|
||||
await filterItemAfterReload.getByRole('button', {name: 'Open project settings menu'}).click()
|
||||
await page.getByRole('link', {name: 'Edit', exact: true}).click()
|
||||
|
||||
// Verify the filter title and content are intact
|
||||
await expect(page.locator('input#Title')).toHaveValue('Work Filter', {timeout: 5000})
|
||||
|
||||
const reloadedFilterInput = page.locator('.filters .filter-input .ProseMirror')
|
||||
// The trailing space may be trimmed, but the core filter should be preserved
|
||||
await expect(reloadedFilterInput).toContainText('project in Work To Do, Inbox')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user