Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
210 changes: 192 additions & 18 deletions e2e/projects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions resources/js/Components/Common/Client/ClientTableHeading.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import TableHeading from '@/Components/Common/TableHeading.vue';

<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary"></div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="px-3 py-1.5 text-left text-text-tertiary"></div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import TableHeading from '@/Components/Common/TableHeading.vue';

<template>
<TableHeading>
<div
class="px-3 py-1.5 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div class="px-3 py-1.5 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Email
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>
Expand Down
11 changes: 5 additions & 6 deletions resources/js/Components/Common/Member/MemberTableHeading.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@ import TableHeading from '@/Components/Common/TableHeading.vue';

<template>
<TableHeading>
<div
class="py-1.5 pr-3 text-left font-semibold text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div class="py-1.5 pr-3 text-left text-text-tertiary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
Name
</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Email</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Role</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Billable Rate</div>
<div class="px-3 py-1.5 text-left font-semibold text-text-primary">Status</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Email</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Role</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Billable Rate</div>
<div class="px-3 py-1.5 text-left text-text-tertiary">Status</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
<span class="sr-only">Edit</span>
</div>
Expand Down
50 changes: 50 additions & 0 deletions resources/js/Components/Common/Project/BaseFilterBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { XMarkIcon, ChevronDownIcon } from '@heroicons/vue/16/solid';
import type { Component } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';

defineProps<{
icon: Component;
label: string;
filterName: string;
}>();

defineEmits<{
remove: [];
}>();

defineSlots<{
default(): void;
}>();
</script>

<template>
<div
class="inline-flex items-center gap-0.5 rounded-md bg-tertiary dark:bg-secondary border border-border-secondary">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex items-center gap-1.5 px-2 py-1 text-sm hover:bg-quaternary dark:hover:bg-tertiary rounded-l-md transition-colors">
<component :is="icon" class="h-3.5 w-3.5 text-icon-default" />
<span class="font-medium text-foreground">{{ filterName }}</span>
<span class="text-muted-foreground">is</span>
<span class="text-foreground">{{ label }}</span>
<ChevronDownIcon class="h-3 w-3 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<slot />
</DropdownMenuContent>
</DropdownMenu>

<button
class="px-1.5 py-1 hover:bg-quaternary dark:hover:bg-tertiary h-full rounded-r-md transition-colors group border-l border-border-secondary"
@click="$emit('remove')">
<XMarkIcon class="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
</button>
</div>
</template>
Loading
Loading