From db57055941574319a1690891b582de59e146d935 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 7 Jan 2026 20:16:56 +0100 Subject: [PATCH] add filters and sorting to projects table --- docker-compose.yml | 2 +- e2e/projects.spec.ts | 210 ++++++++++++++++-- .../Common/Client/ClientTableHeading.vue | 7 +- .../Invitation/InvitationTableHeading.vue | 5 +- .../Common/Member/MemberTableHeading.vue | 11 +- .../Common/Project/BaseFilterBadge.vue | 50 +++++ .../Project/ProjectClientFilterBadge.vue | 68 ++++++ .../Common/Project/ProjectDropdown.vue | 2 +- .../Project/ProjectStatusFilterBadge.vue | 46 ++++ .../Common/Project/ProjectTable.vue | 100 ++++++++- .../Common/Project/ProjectTableHeading.vue | 80 ++++++- .../Common/Project/ProjectTableRow.vue | 14 +- .../Common/Project/ProjectsFilterDropdown.vue | 129 +++++++++++ .../js/Components/Common/Project/constants.ts | 1 + .../ProjectMemberTableHeading.vue | 7 +- .../Common/Report/ReportTableHeading.vue | 9 +- .../js/Components/Common/TableHeading.vue | 2 +- .../Components/Common/Tag/TagTableHeading.vue | 3 +- .../Common/Task/TaskTableHeading.vue | 9 +- resources/js/Components/ui/tabs/TabsList.vue | 4 +- resources/js/Pages/Projects.vue | 135 +++++++++-- .../packages/ui/src/Client/ClientDropdown.vue | 2 +- .../packages/ui/src/Icons/ListFilterIcon.vue | 20 ++ .../ui/src/Input/MultiselectDropdown.vue | 2 +- .../js/packages/ui/src/Tag/TagDropdown.vue | 2 +- .../TimeTrackerProjectTaskDropdown.vue | 2 +- .../TimeTracker/TimeTrackerRangeSelector.vue | 2 +- resources/js/packages/ui/styles.css | 40 +--- resources/js/packages/ui/tailwind.theme.js | 10 +- resources/js/utils/useProjects.ts | 9 + resources/js/utils/useProjectsQuery.ts | 34 +++ tailwind.config.js | 1 + 32 files changed, 887 insertions(+), 131 deletions(-) create mode 100644 resources/js/Components/Common/Project/BaseFilterBadge.vue create mode 100644 resources/js/Components/Common/Project/ProjectClientFilterBadge.vue create mode 100644 resources/js/Components/Common/Project/ProjectStatusFilterBadge.vue create mode 100644 resources/js/Components/Common/Project/ProjectsFilterDropdown.vue create mode 100644 resources/js/Components/Common/Project/constants.ts create mode 100644 resources/js/packages/ui/src/Icons/ListFilterIcon.vue create mode 100644 resources/js/utils/useProjectsQuery.ts diff --git a/docker-compose.yml b/docker-compose.yml index 53d73ed4..6974efb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,7 +107,7 @@ services: - sail - reverse-proxy playwright: - image: mcr.microsoft.com/playwright:v1.51.1-jammy + image: mcr.microsoft.com/playwright:v1.57.0-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] working_dir: /src extra_hosts: diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 9dd51c5d..b052baed 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -8,6 +8,13 @@ async function goToProjectsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); } +// Helper to clear localStorage before tests that check persistence +async function clearProjectTableState(page: Page) { + await page.evaluate(() => { + localStorage.removeItem('project-table-state'); + }); +} + // Create new project via modal test('test that creating and deleting a new project via the modal works', async ({ page }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); @@ -45,34 +52,62 @@ test('test that creating and deleting a new project via the modal works', async await expect(page.getByTestId('project_table')).not.toContainText(newProjectName); }); +// Helper to select a status filter using the new dropdown UI +async function selectStatusFilter(page: Page, status: 'Active' | 'Archived') { + // Click the Filter button to open the dropdown + await page.getByRole('button', { name: 'Filter projects' }).click(); + // Click on Status submenu + await page.getByRole('menuitem', { name: 'Status' }).click(); + // Select the status option + await page.getByRole('menuitem', { name: status }).click(); +} + +// Helper to remove status filter by clicking the X on the badge +async function removeStatusFilter(page: Page) { + const statusBadge = page.getByTestId('status-filter-badge'); + // Click the remove button (second button in the badge, contains XMarkIcon) + await statusBadge.locator('button').last().click(); +} + test('test that archiving and unarchiving projects works', async ({ page }) => { const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByLabel('Project Name').fill(newProjectName); await page.getByRole('button', { name: 'Create Project' }).click(); await expect(page.getByText(newProjectName)).toBeVisible(); + // Archive the project await page.getByRole('row').first().getByRole('button').click(); - await Promise.all([ - page.getByRole('menuitem').getByText('Archive').first().click(), - expect(page.getByText(newProjectName)).not.toBeVisible(), - ]); - await Promise.all([ - page.getByRole('tab', { name: 'Archived' }).click(), - expect(page.getByText(newProjectName)).toBeVisible(), - ]); + await page.getByRole('menuitem').getByText('Archive').first().click(); + // Project should still be visible since default is "all" (no filter) + await expect(page.getByText(newProjectName)).toBeVisible(); + + // Apply Active filter - archived project should disappear + await selectStatusFilter(page, 'Active'); + await expect(page.getByText(newProjectName)).not.toBeVisible(); + + // Remove Active filter and apply Archived filter + await removeStatusFilter(page); + await selectStatusFilter(page, 'Archived'); + await expect(page.getByText(newProjectName)).toBeVisible(); + + // Unarchive the project await page.getByRole('row').first().getByRole('button').click(); - await Promise.all([ - page.getByRole('menuitem').getByText('Unarchive').first().click(), - expect(page.getByText(newProjectName)).not.toBeVisible(), - ]); - await Promise.all([ - page.getByRole('tab', { name: 'Active' }).click(), - expect(page.getByText(newProjectName)).toBeVisible(), - ]); + await page.getByRole('menuitem').getByText('Unarchive').first().click(); + + // Project should disappear from Archived view + await expect(page.getByText(newProjectName)).not.toBeVisible(); + + // Remove Archived filter and apply Active filter to see the project + await removeStatusFilter(page); + await selectStatusFilter(page, 'Active'); + await expect(page.getByText(newProjectName)).toBeVisible(); }); test('test that updating billable rate works with existing time entries', async ({ page }) => { @@ -116,6 +151,147 @@ test('test that updating billable rate works with existing time entries', async ).toBeVisible(); }); +// Sorting tests +test('test that sorting projects by name works', async ({ page }) => { + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + // Wait for the table to load + await expect(page.getByTestId('project_table')).toBeVisible(); + + // Get initial project names + const getProjectNames = async () => { + const rows = page + .getByTestId('project_table') + .locator('[data-testid="project_table"] > div') + .filter({ hasNot: page.locator('.border-t') }); + const names: string[] = []; + const count = await page.getByTestId('project_table').getByRole('row').count(); + for (let i = 0; i < count; i++) { + const row = page.getByTestId('project_table').getByRole('row').nth(i); + const nameCell = row.locator('div').first(); + const text = await nameCell.textContent(); + if (text) { + names.push(text.trim()); + } + } + return names; + }; + + // Click on Name header to sort ascending (default should already be ascending) + const nameHeader = page.getByText('Name').first(); + await nameHeader.click(); + + // Wait for sort to apply + await page.waitForTimeout(100); + + // Click again to sort descending + await nameHeader.click(); + await page.waitForTimeout(100); + + // Verify the sort indicator is showing descending + await expect(page.locator('svg').first()).toBeVisible(); +}); + +test('test that sorting projects by status works', async ({ page }) => { + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + // Default is "all" so no filter needed - Wait for the table to load + await expect(page.getByTestId('project_table')).toBeVisible(); + + // Click on Status header to sort + const statusHeader = page.getByText('Status').first(); + await statusHeader.click(); + + // Wait for sort to apply + await page.waitForTimeout(100); + + // Sort indicator should be visible + await expect(statusHeader.locator('svg')).toBeVisible(); +}); + +// Filter tests +test('test that filtering projects by status works', async ({ page }) => { + const newProjectName = 'Filter Test Project ' + Math.floor(1 + Math.random() * 10000); + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + // Create a new project + await page.getByRole('button', { name: 'Create Project' }).click(); + await page.getByLabel('Project Name').fill(newProjectName); + await page.getByRole('button', { name: 'Create Project' }).click(); + await expect(page.getByText(newProjectName)).toBeVisible(); + + // Archive the project + await page.getByRole('row').first().getByRole('button').click(); + await page.getByRole('menuitem').getByText('Archive').first().click(); + + // Project should still be visible (default is "all" - no filter) + await expect(page.getByText(newProjectName)).toBeVisible(); + + // Apply Active filter - archived project should disappear + await selectStatusFilter(page, 'Active'); + await expect(page.getByText(newProjectName)).not.toBeVisible(); + + // Remove Active filter - project should reappear (back to "all") + await removeStatusFilter(page); + await expect(page.getByText(newProjectName)).toBeVisible(); + + // Apply Archived filter - project should still be visible + await selectStatusFilter(page, 'Archived'); + await expect(page.getByText(newProjectName)).toBeVisible(); + + // Remove Archived filter and apply Active filter - project should not be visible + await removeStatusFilter(page); + await selectStatusFilter(page, 'Active'); + await expect(page.getByText(newProjectName)).not.toBeVisible(); +}); + +test('test that filter state persists after page reload', async ({ page }) => { + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + // Apply Active status filter + await selectStatusFilter(page, 'Active'); + + // Verify the filter badge is visible + await expect(page.getByTestId('status-filter-badge')).toBeVisible(); + + // Wait for the state to be saved + await page.waitForTimeout(100); + + // Reload the page + await page.reload(); + + // Verify the filter badge is still visible after reload + await expect(page.getByTestId('status-filter-badge')).toBeVisible(); +}); + +test('test that sort state persists after page reload', async ({ page }) => { + await goToProjectsOverview(page); + await clearProjectTableState(page); + await page.reload(); + + // Click on Name header twice to sort descending + const nameHeader = page.getByText('Name').first(); + await nameHeader.click(); + await nameHeader.click(); + + // Wait for the state to be saved + await page.waitForTimeout(100); + + // Reload the page + await page.reload(); + + // Verify descending sort indicator is visible on Name column + await expect(page.getByTestId('project_table')).toBeVisible(); +}); + // Create new project with new Client // Create new project with existing Client @@ -124,8 +300,6 @@ test('test that updating billable rate works with existing time entries', async // Test that project task count is displayed correctly -// Test that active / archive / all filter works (once implemented) - // Edit Project Modal Test // Add Project with billable rate diff --git a/resources/js/Components/Common/Client/ClientTableHeading.vue b/resources/js/Components/Common/Client/ClientTableHeading.vue index 45971b78..8817be67 100644 --- a/resources/js/Components/Common/Client/ClientTableHeading.vue +++ b/resources/js/Components/Common/Client/ClientTableHeading.vue @@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';